CSS Animations & Easing — Transitions, Keyframes, Timing Functions, and Motion That Feels Right
Senior guide: transitions, animations, WAAPI, cubic-bezier/steps/linear(), compositor motion, @keyframes, @starting-style exits, and reduced motion.
Vì sao easing là nửa còn lại của animation
Bạn có thể mất hàng giờ chỉnh keyframes, nhưng nếu timing function sai, chuyển động vẫn cảm giác rẻ tiền. Modal mở đột ngột nhưng đóng mượt, loader tăng tốc khi lẽ ra phải giảm tốc, transition trang overshoot khi thoát — người dùng cảm nhận trước khi gọi tên được.
Bài này là bản đồ motion CSS cấp senior: khi nào dùng transition vs animation vs Web Animations API, timing function thực sự hoạt động thế nào, property nào an toàn trên compositor, và cách ship entry/exit mà không vướng display: none. về cost model pipeline và budget will-change, xem bài performance — ở đây tập trung thiết kế chuyển động trong CSS.
Demo trực tiếp — cảm nhận đường cong
Kéo handle Bézier, chọn preset, xem quả bóng đua hai easing cùng duration. Timing dễ nội hóa hơn khi bạn cảm nó lặp trên track.
Mở demo đầy đủ:
Ba API motion — chọn đúng công cụ
| API | Kích hoạt | Phù hợp | Ngoài main thread? |
|---|---|---|---|
| CSS transitions | Đổi một property | hover, focus, toggle | Đôi khi (prop compositor) |
| CSS animations | @keyframes + animation-* | loop, nhiều bước, timeline độc lập | Sometimes |
| Web Animations API (WAAPI) | element.animate(keyframes, options) | chuỗi JS, easing động, cancel từ code | Sometimes |
CSS transition — ngầm, hai điểm đầu cuối
Transition nội suy từ giá trị computed trước thay đổi sang sau. Bạn khai báo duration, delay, timing function, và property nào tham gia:
.panel {
transform: translateY(8px);
opacity: 0;
transition:
transform 280ms cubic-bezier(0.22, 1, 0.36, 1),
opacity 200ms ease-out;
}
.panel.is-open {
transform: translateY(0);
opacity: 1;
}
Điểm mạnh: cú pháp gọn, tốt cho toggle state. Giới hạn: một lần mỗi đổi property; không loop native; khó stagger không thêm markup.
CSS animation — keyframe tường minh
Animation định nghĩa nhiều stop và chạy độc lập input người dùng:
@keyframes pulse-ring {
0% { transform: scale(0.85); opacity: 0.9; }
70% { transform: scale(1.15); opacity: 0; }
100% { transform: scale(1.15); opacity: 0; }
}
.badge-dot::after {
content: "";
animation: pulse-ring 1.8s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
Dùng animation khi cần motion lặp, dàn dựng nhiều bước, hoặc animation-fill-mode: both giữ trạng thái đầu/cuối.
WAAPI — khi CSS chưa đủ
WAAPI giống CSS animation nhưng trả Animation object để pause, reverse, seek:
const slide = el.animate(
[
{ transform: 'translateX(-100%)', opacity: 0 },
{ transform: 'translateX(0)', opacity: 1 },
],
{
duration: 320,
easing: 'cubic-bezier(0.22, 1, 0.36, 1)',
fill: 'forwards',
}
);
slide.finished.then(() => el.removeAttribute('inert'));
Chọn WAAPI khi easing/duration phải tính runtime hoặc cần commitStyles() trước khi gỡ element giữa animation. Với morph cấp trang, View Transitions API là lớp riêng.
Quy tắc ngón tay cái: bắt với transition cho state UI; lên
@keyframescho loop và motion có stage; WAAPI khi JS phải điều khiển timing.
Timing function — đi sâu
Mỗi transition/animation có animation-timing-function ánh xạ thời gian đã trôi → tiến độ trên khoảng nội suy của property. Nó không đổi tổng duration — chỉ phân bổ tiến độ.
Preset keyword
| Keyword | Đường cong xấp xỉ | Cảm giác |
|---|---|---|
linear | Straight line | Cơ học, tốc độ đều |
ease | Slight ease-in-out | Mặc định trình duyệt; UI chung an toàn |
ease-in | Slow start | Element rời, tăng tốc |
ease-out | Slow end | Element đến, giảm tốc |
ease-in-out | Slow both ends | Di chuyển dài, đối xứng |
Heuristic thiết kế motion: ease-out vào, ease-in ra, ease-in-out khi đổi vị trí trên màn hình.
Vẽ curve riêng
Bốn số là control point P1 và P2 của Bézier cubic từ (0,0) đến (1,1) trong không gian time–progress. X phải trong [0, 1]; Y có thể ra ngoài [0, 1] để overshoot và anticipation:
/* Snappy enter — fast start, gentle settle */
.drawer {
transition: transform 340ms cubic-bezier(0.22, 1, 0.36, 1);
}
/* Playful overshoot — Y > 1 on P2 */
.chip-pop {
animation: pop-in 420ms cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
@keyframes pop-in {
from { transform: scale(0.6); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
Khi Y dưới 0 hoặc trên 1, curve vẫn hợp lệ — trình duyệt clamp output theo rule property, nhưng cảm giác vận tốc đổi mạnh. Đó là cách CSS có motion kiểu spring không cần engine vật lý.
Motion rời rạc
steps() chia interval thành n lát thời gian bằng nhau và nhảy ở mỗi biên:
/* Typewriter cursor blink — 2 steps per cycle */
.cursor {
animation: blink 1s steps(2, jump-none) infinite;
}
/* Sprite sheet — 8 frames, hold last frame */
.sprite-run {
animation: run 640ms steps(8, jump-end) infinite;
}
@keyframes run {
from { background-position: 0 0; }
to { background-position: -512px 0; }
}
| Jump term | Hành vi cuối mỗi step |
|---|---|
jump-end (default) | Giữ giá trị đầu đến biên step, rồi nhảy |
jump-start | Nhảy ngay, giữ đến biên sau |
jump-none | Giữ cả hai đầu |
jump-both | Nhảy đầu và cuối mỗi interval |
Dùng steps() cho art theo frame, số kiểu LED, UI staccato — không cho chuyển động không gian cần mượt.
Xấp xỉ bounce/spring
linear() định nghĩa timing function bằng đoạn thẳng nối qua các stop (progress, time%) tường minh. Nó tổng quát hóa linear và xấp xỉ curve khó bằng cubic-bezier thuần:
/* Staircase — hold progress flat, then jump */
.ticker {
animation: flip 600ms linear(
0,
0 0%,
0 25%,
0.5 25%,
0.5 50%,
1 50%,
1 75%,
0.75 75%,
0.75 100%
) infinite;
}
/* Bounce approximation — many stops, still one declaration */
.bounce-in {
animation: drop 700ms linear(
0, 0.03, 0.12, 0.25, 0.42, 0.58, 0.72, 0.85, 0.93, 1
) forwards;
}
Khi nào chọn gì:
| Nhu cầu | Ưu tiên |
|---|---|
| Standard UI easing | ease-out / custom cubic-bezier |
| Single overshoot | cubic-bezier with Y > 1 |
| Multi-bounce or complex spring | linear() with many stops, or WAAPI + spring lib |
| Sprite / frame animation | steps() |
| Typewriter / discrete reveal | steps() or linear() stairs |
DevTools (Chrome → Animations) hiện curve easing computed — dùng để debug cặp enter/exit lệch nhau.
Animate gì — compositor vs layout vs paint
Animate sai property là cách nhanh nhất trượt frame budget. Trình duyệt phân loại đổi property theo stage pipeline:
| Tier | Properties (examples) | Chi phí |
|---|---|---|
| Compositor-only | transform, opacity, filter (often), translate/scale/rotate individually | Thấp nhất — thường GPU composite |
| Paint | color, background, box-shadow, border-radius, clip-path | Repaint layer — dùng vừa phải |
| Layout | width, height, top, left, margin, padding, font-size | Kích layout — tránh trong loop 60fps |
/* ❌ animating layout every frame */
.sidebar {
transition: width 240ms ease;
}
/* ✅ same visual slide, compositor-friendly */
.sidebar {
transform: translateX(-100%);
transition: transform 240ms cubic-bezier(0.22, 1, 0.36, 1);
}
.sidebar.is-open {
transform: translateX(0);
}
Budget nhỏ, tác dụng lớn
will-change: transform báo trình duyệt promote layer trước animation. Lạm dụng sẽ phình memory và chậm paint không liên quan:
/* Apply only while motion is imminent, remove after */
.card:hover,
.card.is-animating {
will-change: transform, opacity;
}
.card:not(:hover):not(.is-animating) {
will-change: auto;
}
Coi will-change như khoản vay — mượn cho một tương tác, rồi trả. Chi tiết tradeoff promotion layer ở bài performance §9–10.
Cấu trúc, loop, và composition
Nhiều bước và cú pháp offset
Cả phần trăm và keyword offset đều được; trộn nhóm property mỗi stop:
@keyframes toast-life {
0% { transform: translateY(16px); opacity: 0; }
12% { transform: translateY(0); opacity: 1; }
88% { transform: translateY(0); opacity: 1; }
100% { transform: translateY(-8px); opacity: 0; }
}
.toast {
animation: toast-life 4s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
Dùng đoạn giữ (trùng giá trị trên dải % rộng) để element ổn định giữa các nhịp.
Khi keyframes va nhau
Khi nhiều animation cùng target một property, animation-composition điều khiển cách ghép giá trị:
.wobble {
animation:
shake 400ms ease-in-out,
fade 400ms ease-out;
animation-composition: add, replace;
}
| Value | Hiệu ứng |
|---|---|
replace (default) | Animation sau thắng |
add | Cộng dồn — hữu ích cho kênh transform lớp |
accumulate | Giống add, có quy tắc wrap/theo spec |
Hầu hết UI không cần — đến khi bạn xếp motion ambient và interactive trên một element.
Stagger không cần JavaScript
.list-item {
animation: rise 480ms cubic-bezier(0.22, 1, 0.36, 1) both;
animation-delay: calc(var(--i, 0) * 45ms);
}
@keyframes rise {
from { transform: translateY(12px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
Gán --i trong markup hoặc :nth-child() — giữ delay dưới ~400ms tổng cho list dài hơn 8 item.
Vào, ra, và display: none
Vấn đề kinh điển: không transition tới/từ display: none vì nó rời rạc. CSS hiện đại có ba công cụ bổ sung.
Animate frame đầu khi vào
Định nghĩa điểm bắt đầu khi element vào cây DOM:
dialog[open] {
opacity: 1;
transform: scale(1);
transition:
opacity 220ms ease-out,
transform 280ms cubic-bezier(0.22, 1, 0.36, 1);
}
@starting-style {
dialog[open] {
opacity: 0;
transform: scale(0.96);
}
}
Không có @starting-style, frame đầu đã khớp trạng thái mở — không có transition. Ghép với Popover hoặc <dialog> cho focus management native.
Animate property rời rạc
CSS Transitions Level 2 cho display, content-visibility, overlay tham gia transition khi bật:
.tooltip {
opacity: 0;
display: none;
transition:
opacity 180ms ease-out,
display 180ms ease-out allow-discrete;
}
.tooltip.is-visible {
display: block;
opacity: 1;
}
@starting-style {
.tooltip.is-visible {
opacity: 0;
}
}
Trình duyệt giữ display: block đến khi opacity transition xong, rồi gỡ theo thứ tự ngược khi thoát.
height: auto
Nội suy tới auto từng không thể. interpolate-size: allow-keywords và calc-size() bật animation thu gọn chiều cao trên trình duyệt hỗ trợ:
:root {
interpolate-size: allow-keywords;
}
.accordion-panel {
height: 0;
overflow: hidden;
transition: height 320ms cubic-bezier(0.22, 1, 0.36, 1);
}
.accordion-panel.is-open {
height: auto;
}
/* Explicit calc-size when you need a formula */
.drawer-body {
height: calc-size(auto, size);
transition: height 280ms ease-out;
}
Luôn có fallback (max-height hack hoặc toggle tức thì) cho trình duyệt chưa hỗ trợ.
@supports (interpolate-size: allow-keywords) {
.accordion-panel.is-open { height: auto; }
}
@supports not (interpolate-size: allow-keywords) {
.accordion-panel.is-open { max-height: 480px; }
}
Animation thoát: chạy opacity/transform trước, rồi bật
display: noneởtransitionend— hoặc dùngallow-discreteđể trình duyệt xếp sequence.
Không thể thương lượng
Rối loạn tiền đình và nhạy cảm motion là yêu cầu a11y thật. prefers-reduced-motion: reduce phải rút ngắn hoặc bỏ motion không thiết yếu:
@media (prefers-reduced-motion: no-preference) {
.hero-title {
animation: rise 600ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
Ưu tiên override có mục tiêu hơn rule global hạt nhân khi có thể — Giữ cross-fade opacity hỗ trợ hiểu, tắt parallax và dịch chuyển không gian lớn:
@media (prefers-reduced-motion: reduce) {
.carousel {
scroll-behavior: auto;
}
.carousel-slide {
transition: opacity 120ms linear;
transform: none;
}
}
Test bằng DevTools → Rendering → Emulate prefers-reduced-motion và bật setting OS.
Checklist production
| Check | Tiêu chí đạt |
|---|---|
| Easing matches intent | Vào = ease-out; ra = ease-in; overshoot chỉ surface vui |
| Compositor props | transform + opacity transform + opacity cho motion tần suất cao |
| Duration scale | Micro 100–200ms; UI 200–400ms; hero ≤ 600ms |
| Discrete exits | @starting-style + allow-discrete Thoát rời rạc: @starting-style + allow-discrete hoặc handler transitionend |
| Reduced motion | Không truyền thông tin thiết yếu chỉ bằng motion |
| Debug | DevTools Animations + slow-motion 10% |
Đọc thêm
- cost model layout/paint/composite và
will-change - transition cấp trang (tách khỏi easing property)
@starting-style, tổng quan scroll-driven animation- tham chiếu cú pháp chính thức
Motion không phải trang trí — nó truyền state, hierarchy, và nhân quả. Chọn đúng API, animate property rẻ, chỉnh curve đến khi cảm giác tất yếu, và tôn trọng người cần im lặng. Demo trên để bạn ngừng đoán cubic-bezier(0.34, 1.56, 0.64, 1) cảm giác thế nào — kéo, play, đua.