Mở đầu — vì sao bài này quan trọng
Hãy thử nhớ lại lần gần nhất bạn rê chuột vào một nút bấm trên một website "xịn" — chẳng hạn trang chủ của Tiki hay Shopee. Cái nút không đột ngột đổi màu rồi đứng im. Nó mượt mà chuyển sang màu đậm hơn trong khoảng một phần năm giây, có khi còn nhích lên một chút như đang muốn nói "bấm tôi đi". Cảm giác đó — sự "có hồn" của giao diện — phần lớn đến từ hai công cụ bạn sẽ học hôm nay: transition và animation trong CSS.
Đây là điều khiến một sản phẩm trông "đắt tiền" hay "rẻ tiền". Một giao diện không có chuyển động nào cảm giác như một tờ giấy chết. Một giao diện chuyển động quá đà, giật cục lại gây khó chịu. Người designer giỏi nằm ở giữa: biết dùng chuyển động đúng lúc, đúng liều, để hướng dẫn ánh mắt người dùng và phản hồi cho mọi hành động của họ.
Trong toàn bộ khóa học, đây là bài "thực thi kỹ thuật" của chủ đề chuyển động. Bài 53 đã cho bạn tư duy về microinteractions — khi nào và tại sao nên dùng animation. Bài 54 này tập trung vào làm thế nào: cú pháp CSS cụ thể, các thuộc tính, easing, keyframes, và cách viết sao cho mượt mà mà không làm chậm trang. Học xong bài này, bạn cầm được cây bút để vẽ chuyển động bằng code thật.
Khái niệm cốt lõi
CSS cho bạn hai cơ chế tạo chuyển động. Hiểu rõ sự khác nhau giữa chúng là nền tảng cho mọi thứ phía sau.
Transition — chuyển động khi trạng thái thay đổi
transition là cách bạn nói với trình duyệt: "Khi một thuộc tính CSS thay đổi giá trị, đừng nhảy ngay sang giá trị mới — hãy chuyển dần trong một khoảng thời gian." Transition luôn cần một cú kích hoạt (trigger), thường là :hover, :focus, hoặc khi một class được thêm/xóa bằng JavaScript.
Ví dụ kinh điển từ chính dàn ý của bài:
.button {
background: blue;
transition: background 0.2s ease;
}
.button:hover {
background: darkblue;
}
Ở trạng thái bình thường, nút màu xanh. Khi rê chuột, nó cần thành xanh đậm. Nhờ dòng transition, sự thay đổi đó trải đều trong 0.2 giây thay vì nhảy phắt. Cú pháp đầy đủ của shorthand:
transition: <property> <duration> <timing-function> <delay>;
/ ví dụ /
transition: transform 0.3s ease-in-out 0s;
- property: thuộc tính nào sẽ được "làm mượt" (ví dụ
background,transform,opacity). Dùngallđể áp cho mọi thuộc tính, nhưng nên tránh vì kém tối ưu. - duration: kéo dài bao lâu, ví dụ
0.2shoặc200ms. - timing-function: đường cong tốc độ (easing), sẽ nói kỹ bên dưới.
- delay: chờ bao lâu trước khi bắt đầu.
transition: background 0.2s ease, transform 0.3s ease-out;
Animation — chuyển động tự chạy theo kịch bản
transition chỉ đi từ trạng thái A sang trạng thái B. Khi bạn cần một chuyển động nhiều bước, tự chạy (không cần hover), hoặc lặp vô hạn — ví dụ một spinner xoay tròn hoặc một trái tim đập — bạn cần @keyframes và thuộc tính animation.
@keyframes là nơi bạn định nghĩa "kịch bản" — trạng thái tại từng mốc thời gian:
@keyframes pulse {
0% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.15); opacity: 0.7; }
100% { transform: scale(1); opacity: 1; }
}.notification-dot {
animation: pulse 1.5s ease-in-out infinite;
}
Các thuộc tính của animation (shorthand):
animation: <name> <duration> <timing-function> <delay> <iteration-count> <direction> <fill-mode>;
- iteration-count: số lần lặp,
infiniteđể lặp mãi. - direction:
normal,reverse,alternate(chạy tới rồi chạy lui). - fill-mode:
forwardsgiữ lại trạng thái cuối sau khi chạy xong — rất hay dùng để animation không "nhảy về" trạng thái đầu.
Easing — linh hồn của chuyển động
Đây là phần nhiều bạn mới bỏ qua, nhưng nó quyết định chuyển động cảm giác tự nhiên hay máy móc. Easing là đường cong mô tả tốc độ thay đổi theo thời gian.
linear: tốc độ đều tăm tắp. Cảm giác cứng, robot. Chỉ hợp với chuyển động xoay liên tục (spinner).ease: mặc định — bắt đầu chậm, nhanh dần, chậm lại cuối. Khá tự nhiên.ease-out: bắt đầu nhanh, chậm dần. Tốt cho phần tử xuất hiện vào màn hình — cảm giác "đáp xuống" nhẹ nhàng.ease-in: bắt đầu chậm, nhanh dần. Tốt cho phần tử biến mất.cubic-bezier(...): tự định nghĩa đường cong. Ví dụcubic-bezier(0.34, 1.56, 0.64, 1)tạo hiệu ứng nảy nhẹ (overshoot) rất "đã" cho các nút.
linear. Xe phải tăng tốc và phanh. Vì vậy giao diện dùng easing đúng sẽ luôn cảm giác "thật" hơn.Hiệu năng — chỉ animate hai thứ rẻ
Đây là kiến thức tách bạn khỏi người mới. Trình duyệt vẽ trang qua nhiều giai đoạn: layout (tính kích thước/vị trí) → paint (tô màu) → composite (ghép lớp lên màn hình). Animate width, height, top, left, margin buộc trình duyệt tính lại layout mỗi khung hình — rất tốn, gây giật (janky).
Ngược lại, hai thuộc tính transform (di chuyển, xoay, phóng to) và opacity chỉ chạy ở giai đoạn composite, được GPU xử lý, mượt như bơ ngay cả trên điện thoại yếu. Quy tắc vàng: ưu tiên animate transform và opacity. Muốn dịch chuyển một phần tử, dùng transform: translateX(20px) thay vì left: 20px.
Tình huống thực tế
Ví dụ 1 — Nút "Thêm vào giỏ" của một sàn TMĐT Việt Nam
Một bạn junior designer làm cho startup TMĐT giả định "ChợViet" được giao làm nút "Thêm vào giỏ". Phiên bản đầu, nút đổi từ xám sang cam ngay khi hover — không transition. QA phản hồi: "Cảm giác bị khựng, như trang bị lag." Bạn ấy thêm vào:
.add-to-cart {
background: #e0e0e0;
transform: translateY(0);
transition: background 0.2s ease, transform 0.15s ease-out, box-shadow 0.2s ease;
}
.add-to-cart:hover {
background: #f5512c;
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(245, 81, 44, 0.35);
}
.add-to-cart:active {
transform: translateY(0);
}
Kết quả: nút giờ "nhô lên" nhẹ khi hover, đổ bóng cam mềm, và "nhấn xuống" khi click — đúng phản hồi vật lý người dùng mong đợi. Trong một A/B test giả định, biến thể có chuyển động này tăng tỷ lệ click nút thêm giỏ khoảng 4–6% so với nút "chết". Bài học: chuyển động không chỉ làm đẹp, nó tạo affordance — gợi ý cho người dùng rằng phần tử này có thể tương tác.
Ví dụ 2 — Loading spinner cho app giao đồ ăn
Đội frontend của một app giao đồ ăn (kiểu GrabFood/ShopeeFood) cần spinner trong lúc chờ tải danh sách quán. Một bạn dùng GIF nặng 80KB. Bạn senior thay bằng pure CSS:
@keyframes spin {
to { transform: rotate(360deg); }
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid #eee;
border-top-color: #00b14f;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
Chỉ vài dòng, không tải file, không tốn băng thông, mượt trên cả máy yếu. Lưu ý ở đây linear là đúng — vòng xoay đều mới tự nhiên, dùng ease sẽ thấy nó giật từng nhịp. Bài học: đúng công cụ cho đúng việc. Spinner xoay là một trong số rất ít trường hợp linear thắng ease. Và CSS animation thường thay thế được file ảnh động, vừa nhẹ vừa sắc nét trên màn hình retina.
Ví dụ 3 — Modal trượt lên trên mobile và bài học prefers-reduced-motion
Một team làm app fintech tại TP.HCM thiết kế modal xác nhận chuyển tiền trượt từ dưới lên (bottom sheet). Họ viết:
@keyframes slideUp {
from { transform: translateY(100%); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.sheet {
animation: slideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
Mọi thứ ổn cho đến khi một người dùng lớn tuổi phản hồi rằng các hiệu ứng trượt khiến họ chóng mặt. Đây là vấn đề accessibility thật. Họ bổ sung:
@media (prefers-reduced-motion: reduce) {
.sheet {
animation: none;
}
}
prefers-reduced-motion là tùy chọn hệ điều hành (iOS/Android/Windows) cho người nhạy cảm với chuyển động. Tôn trọng nó là chuẩn mực chuyên nghiệp. Bài học: chuyển động đẹp phải đi kèm trách nhiệm — luôn có "đường thoát" cho người dùng không chịu được animation.
Hướng dẫn từng bước
Giả sử bạn muốn làm một card sản phẩm "nâng lên" khi hover. Làm theo trình tự sau, đây cũng là quy trình tư duy bạn nên áp dụng cho mọi animation:
- Xác định trạng thái đầu và cuối. Trạng thái nghỉ: card phẳng, bóng nhẹ. Trạng thái hover: card nhô lên 4px, bóng đậm hơn. Viết CSS cho cả hai trạng thái trước, chưa cần transition.
- Quyết định animate thuộc tính nào. Nâng lên → dùng
transform: translateY(-4px)(không dùngmargin-top). Bóng →box-shadow. Tránh đụng tớiwidth/height/top.
- Thêm transition vào trạng thái nghỉ (không phải vào
:hover):
.product-card {
transform: translateY(0);
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
transition: transform 0.25s ease-out, box-shadow 0.25s ease-out;
}
.product-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 24px rgba(0,0,0,0.15);
}
Đặt transition ở trạng thái nghỉ để cả lúc "vào" hover và lúc "ra" khỏi hover đều mượt.
- Chọn duration hợp lý. Microinteraction giao diện thường nằm trong khoảng 150–300ms. Dưới 100ms gần như không cảm nhận được; trên 500ms bắt đầu cảm giác chậm chạp.
- Chọn easing. Phần tử xuất hiện/nâng lên →
ease-out. Cần nảy nhẹ →cubic-bezier.
- Kiểm tra hiệu năng và accessibility. Mở DevTools (tab Performance hoặc Rendering → bật "Paint flashing") xem có bị repaint thừa không. Thêm khối
@media (prefers-reduced-motion: reduce)nếu animation đáng kể.
@keyframes), quy trình tương tự nhưng bước 1 đổi thành "vẽ kịch bản từng mốc 0%/50%/100%".Lỗi thường gặp & mẹo
Lỗi: đặt transition vào trạng thái :hover thay vì trạng thái gốc. Khi đó lúc rê chuột vào thì mượt, nhưng lúc rê ra thì nhảy phắt. Luôn đặt transition ở selector gốc.
Lỗi: dùng transition: all. Nó áp transition lên mọi thuộc tính thay đổi, kể cả những thứ bạn không ngờ tới, dễ gây giật và khó debug. Hãy liệt kê rõ từng thuộc tính.
Lỗi: animate width, height, top, left. Gây giật trên mobile. Hầu hết trường hợp thay được bằng transform: scale() hoặc translate().
Lỗi: quên animation-fill-mode: forwards. Animation chạy xong "nhảy" về trạng thái đầu, gây chớp giật. Thêm forwards để giữ trạng thái cuối.
Lỗi: lạm dụng chuyển động. Mọi thứ đều bay, đều nảy → giao diện rối và rẻ tiền. Chuyển động nên kín đáo; người dùng cảm nhận được "mượt" mà không nói được chính xác vì sao.
Mẹo: dùng will-change: transform cho phần tử sắp animate phức tạp để báo trình duyệt chuẩn bị tối ưu — nhưng dùng tiết kiệm, lạm dụng sẽ tốn bộ nhớ.
Mẹo: thời lượng nên tỉ lệ với quãng đường. Phần tử di chuyển xa (modal toàn màn hình) cần lâu hơn (300–400ms); phần tử nhỏ (nút) nên nhanh (150–200ms).
Mẹo: trang web Easings.net và công cụ cubic-bezier.com giúp bạn "nghịch" đường cong easing trực quan trước khi dán vào code.
Bài tập thực hành
- Nút có hồn: Tạo một nút với background xanh, khi hover đổi sang xanh đậm, nâng lên 2px và có bóng. Khi
:activethì nhấn xuống. Dùng transition cho cả ba thuộc tính, duration 200ms, easingease-out.
- Skeleton shimmer: Viết
@keyframestạo hiệu ứng "lấp lánh" chạy ngang (gradient di chuyển) cho một ô skeleton loading, lặp vô hạn, duration 1.5s. Gợi ý: animatebackground-position.
- Card nâng lên: Áp dụng đúng 6 bước trong phần Hướng dẫn để làm một product card hover. Mở DevTools bật "Paint flashing", xác nhận chỉ có composite, không có layout/paint thừa.
- Tôn trọng người dùng: Lấy bất kỳ animation nào ở trên, bọc lại bằng
@media (prefers-reduced-motion: reduce)để tắt hoặc giảm chuyển động. Bật tùy chọn "Reduce Motion" trong cài đặt máy để kiểm tra thật.
- Thử thách easing: Tạo một icon trái tim "đập" (scale 1 → 1.3 → 1) bằng
@keyframes, thử lần lượtlinear,ease-in-out, và mộtcubic-beziercó overshoot. Tự cảm nhận sự khác biệt và viết một câu mô tả cái nào hợp nhất.
Tóm tắt
- Transition làm mượt sự thay đổi giữa hai trạng thái và cần một trigger (hover, focus, đổi class). Animation + @keyframes tạo chuyển động nhiều bước, tự chạy, có thể lặp.
- Cú pháp transition:
transition: property duration timing-function delay. Đặt nó ở trạng thái gốc, không phải ở:hover. - Easing là linh hồn:
ease-outcho phần tử xuất hiện,ease-incho biến mất,linearchỉ cho spinner,cubic-bezierđể tùy biến và tạo nảy. - Hiệu năng: ưu tiên animate
transformvàopacity— chúng chạy trên GPU, mượt mà. Tránhwidth/height/top/left. - Thời lượng microinteraction lý tưởng: 150–300ms.
- Luôn tôn trọng
prefers-reduced-motion— chuyển động đẹp phải đi cùng accessibility. - Chuyển động giỏi là chuyển động kín đáo: người dùng cảm thấy "mượt" mà không nhận ra vì sao. Đó chính là dấu ấn của một designer chuyên nghiệp.