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.
Why easing is half the animation {Vì sao easing là nửa còn lại của animation}
You can spend hours polishing keyframes {Bạn có thể mất hàng giờ chỉnh keyframes}, but if the timing function is wrong, the motion still feels cheap {nhưng nếu timing function sai, chuyển động vẫn cảm giác rẻ tiền}. A modal that snaps open but eases closed {Modal mở đột ngột nhưng đóng mượt}, a loader that accelerates when it should decelerate {loader tăng tốc khi lẽ ra phải giảm tốc}, a page transition that overshoots on exit — users feel these mismatches before they can name them {transition trang overshoot khi thoát — người dùng cảm nhận trước khi gọi tên được}.
This post is a senior-level map of CSS motion {Bài này là bản đồ motion CSS cấp senior}: when to use transitions vs animations vs the Web Animations API {khi nào dùng transition vs animation vs Web Animations API}, how timing functions actually work {timing function thực sự hoạt động thế nào}, which properties are safe to animate on the compositor {property nào an toàn trên compositor}, and how to ship entry/exit motion without fighting display: none {và cách ship entry/exit mà không vướng display: none}. For render-pipeline cost models and will-change budgets, see /blog/css-performance-deep-dive/ — here we focus on motion design in CSS itself {về cost model pipeline và budget will-change, xem bài performance — ở đây tập trung thiết kế chuyển động trong CSS}.
Live demo — feel the curve {Demo trực tiếp — cảm nhận đường cong}
Drag the Bézier handles, pick presets (ease, linear(), spring-like overshoot), and watch a ball race two easings with the same duration {Kéo handle Bézier, chọn preset, xem quả bóng đua hai easing cùng duration}. Timing is easier to internalize when you feel it loop on a track {Timing dễ nội hóa hơn khi bạn cảm nó lặp trên track}.
Open the full demo {Mở demo đầy đủ}: /tools/css-easing-demo/.
Three motion APIs — pick the right tool {Ba API motion — chọn đúng công cụ}
| API | Trigger {Kích hoạt} | Best for {Phù hợp} | Runs off main thread? {Ngoài main thread?} |
|---|---|---|---|
| CSS transitions | Single property change on an element {Đổi một property} | Hover, focus, toggles, :target, discrete → continuous handoffs {hover, focus, toggle} | Sometimes (compositor props) {Đôi khi (prop compositor)} |
| CSS animations | @keyframes + animation-* | Loops, multi-step, independent timelines, @starting-style entry {loop, nhiều bước, timeline độc lập} | Sometimes |
| Web Animations API (WAAPI) | element.animate(keyframes, options) | JS-driven sequences, scroll sync, dynamic easing, cancel/pause from code {chuỗi JS, easing động, cancel từ code} | Sometimes |
CSS transitions — implicit, two-endpoint {CSS transition — ngầm, hai điểm đầu cuối}
A transition interpolates from the computed value before the change to the value after {Transition nội suy từ giá trị computed trước thay đổi sang sau}. You declare duration, delay, timing function, and which properties participate {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;
}
Strengths {Điểm mạnh}: tiny syntax, great for state toggles, pairs naturally with classes and :hover {cú pháp gọn, tốt cho toggle state}. Limits {Giới hạn}: one shot per property change; no native loop; hard to orchestrate stagger without extra markup or custom properties {một lần mỗi đổi property; không loop native; khó stagger không thêm markup}.
CSS animations — explicit keyframes {CSS animation — keyframe tường minh}
Animations define multiple stops and can run independently of user input {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;
}
Use animations when you need repeating motion, multi-step choreography, or animation-fill-mode: both to hold start/end states across cycles {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 — when CSS is not enough {WAAPI — khi CSS chưa đủ}
WAAPI mirrors CSS animations but returns Animation objects you can pause, reverse, or seek {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'));
Reach for WAAPI when easing or duration must be computed at runtime (gesture velocity, spring physics from a library) or when you need commitStyles() before removing an element mid-animation {Chọn WAAPI khi easing/duration phải tính runtime hoặc cần commitStyles() trước khi gỡ element giữa animation}. For page-level morphing between routes, the View Transitions API is a separate layer — see /blog/view-transitions-api-guide/ {Với morph cấp trang, View Transitions API là lớp riêng}.
Rule of thumb {Quy tắc ngón tay cái}: start with transitions for UI state; promote to
@keyframesfor loops and staged motion; use WAAPI when JS must drive timing or synchronize with non-CSS systems {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 functions — the deep dive {Timing function — đi sâu}
Every transition and animation has an animation-timing-function (or transition-timing-function) that maps elapsed time → progress along the property’s interpolation range {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}. It does not change total duration — only how progress is distributed {Nó không đổi tổng duration — chỉ phân bổ tiến độ}.
Keyword presets {Preset keyword}
| Keyword | Approximate curve {Đường cong xấp xỉ} | Feel {Cảm giác} |
|---|---|---|
linear | Straight line | Mechanical, constant speed {Cơ học, tốc độ đều} |
ease | Slight ease-in-out | Browser default; safe general UI {Mặc định trình duyệt; UI chung an toàn} |
ease-in | Slow start | Elements leaving, accelerating away {Element rời, tăng tốc} |
ease-out | Slow end | Elements arriving, decelerating in {Element đến, giảm tốc} |
ease-in-out | Slow both ends | Longer moves, symmetric {Di chuyển dài, đối xứng} |
Motion-design heuristic {Heuristic thiết kế motion}: ease-out for entrances, ease-in for exits, ease-in-out for repositioning on screen {ease-out vào, ease-in ra, ease-in-out khi đổi vị trí trên màn hình}.
cubic-bezier(x1, y1, x2, y2) — draw your own {Vẽ curve riêng}
The four numbers are control points P1 and P2 of a cubic Bézier from (0,0) to (1,1) in time–progress space {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 values must stay in [0, 1]; Y can leave [0, 1] to create overshoot and anticipation {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; }
}
When Y goes below 0 or above 1, the curve is still valid — the browser clamps output per property rules, but velocity feel changes dramatically {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}. That is how CSS achieves spring-like motion without a physics engine {Đó là cách CSS có motion kiểu spring không cần engine vật lý}.
steps(n, jump-term) — discrete motion {Motion rời rạc}
steps() splits the interval into n equal time slices and jumps at each boundary {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 | Behavior at end of each step {Hành vi cuối mỗi step} |
|---|---|
jump-end (default) | Hold start value until step boundary, then jump {Giữ giá trị đầu đến biên step, rồi nhảy} |
jump-start | Jump immediately, hold until next boundary {Nhảy ngay, giữ đến biên sau} |
jump-none | Hold both ends — steps(n) behaves like steps(n + 1, jump-end) with no double hold {Giữ cả hai đầu} |
jump-both | Jump at start and end of each interval {Nhảy đầu và cuối mỗi interval} |
Use steps() for frame-based art, LED-style digits, and staccato UI — not for spatial movement you want to feel smooth {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}.
linear() — piecewise linear and bounce/spring approximations {Xấp xỉ bounce/spring}
CSS linear() (Baseline 2023+) defines a timing function as connected line segments through explicit (progress, time%) stops {linear() định nghĩa timing function bằng đoạn thẳng nối qua các stop (progress, time%) tường minh}. It generalizes linear and approximates curves that were awkward in pure cubic-bezier {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;
}
When to choose what {Khi nào chọn gì}:
| Need {Nhu cầu} | Prefer {Ư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 |
Browser DevTools (Chrome → Animations panel) expose the computed easing curve — use it to debug mismatched enter/exit pairs {DevTools (Chrome → Animations) hiện curve easing computed — dùng để debug cặp enter/exit lệch nhau}.
What to animate — compositor vs layout vs paint {Animate gì — compositor vs layout vs paint}
Animating the wrong property is the fastest way to miss frame budget {Animate sai property là cách nhanh nhất trượt frame budget}. The browser classifies property changes into pipeline stages {Trình duyệt phân loại đổi property theo stage pipeline}:
| Tier | Properties (examples) | Cost {Chi phí} |
|---|---|---|
| Compositor-only | transform, opacity, filter (often), translate/scale/rotate individually | Lowest — often GPU-composited {Thấp nhất — thường GPU composite} |
| Paint | color, background, box-shadow, border-radius, clip-path | Repaint layer — OK sparingly {Repaint layer — dùng vừa phải} |
| Layout | width, height, top, left, margin, padding, font-size | Triggers layout — avoid in 60fps loops {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);
}
will-change — small budget, big stick {Budget nhỏ, tác dụng lớn}
will-change: transform hints the browser to promote a layer ahead of the animation {will-change: transform báo trình duyệt promote layer trước animation}. Abuse it and you inflate memory and slow unrelated paints {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;
}
Treat will-change like a loan — borrow for one interaction, then return it {Coi will-change như khoản vay — mượn cho một tương tác, rồi trả}. Detailed layer promotion tradeoffs live in /blog/css-performance-deep-dive/ §9–10 {Chi tiết tradeoff promotion layer ở bài performance §9–10}.
@keyframes — structure, loops, and composition {Cấu trúc, loop, và composition}
Multi-step and offset syntax {Nhiều bước và cú pháp offset}
Both percentage and keyword offsets work; mix property groups per stop {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;
}
Use hold segments (duplicate values across a wide % range) to keep the element stable between beats {Dùng đoạn giữ (trùng giá trị trên dải % rộng) để element ổn định giữa các nhịp}.
animation-composition — when keyframes collide {Khi keyframes va nhau}
When multiple animations target the same property, animation-composition controls how values combine (Baseline 2023+) {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 | Effect {Hiệu ứng} |
|---|---|
replace (default) | Later animation wins {Animation sau thắng} |
add | Additive — useful for layered transform channels {Cộng dồn — hữu ích cho kênh transform lớp} |
accumulate | Like add, but wraps/transform-list aware per spec rules {Giống add, có quy tắc wrap/theo spec} |
Most UI code never needs this — until you stack ambient and interactive motion on one element {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 without JavaScript {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; }
}
Set --i in markup or via :nth-child() — keep delays under ~400ms total for lists longer than eight items {Gán --i trong markup hoặc :nth-child() — giữ delay dưới ~400ms tổng cho list dài hơn 8 item}.
Entry, exit, and display: none {Vào, ra, và display: none}
Classic problem: you cannot transition to or from display: none because it is discrete {Vấn đề kinh điển: không transition tới/từ display: none vì nó rời rạc}. Modern CSS gives three complementary tools {CSS hiện đại có ba công cụ bổ sung}.
@starting-style — animate the first frame in {Animate frame đầu khi vào}
Define where the element starts when it enters the tree {Đị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);
}
}
Without @starting-style, the first paint already matches the open state — no transition runs {Không có @starting-style, frame đầu đã khớp trạng thái mở — không có transition}. Pair with the Popover or <dialog> APIs for native focus management {Ghép với Popover hoặc <dialog> cho focus management native}.
transition-behavior: allow-discrete — animate discrete properties {Animate property rời rạc}
CSS Transitions Level 2 lets display, content-visibility, and overlay participate in transitions when you opt in {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;
}
}
The browser holds display: block until the opacity transition finishes, then removes it on exit in reverse order {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 — interpolate-size and calc-size() {height: auto}
Interpolating to auto used to be impossible {Nội suy tới auto từng không thể}. interpolate-size: allow-keywords (and the calc-size() function) enable height collapse animations in supporting browsers {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;
}
Always provide a fallback (max-height hack or instant toggle) for browsers without support {Luôn có fallback (max-height hack hoặc toggle tức thì) cho trình duyệt chưa hỗ trợ}. Feature queries:
@supports (interpolate-size: allow-keywords) {
.accordion-panel.is-open { height: auto; }
}
@supports not (interpolate-size: allow-keywords) {
.accordion-panel.is-open { max-height: 480px; }
}
Exit animations {Animation thoát}: run opacity/transform first, then flip
display: noneontransitionend— or useallow-discreteso the browser sequences discrete + continuous for you {chạy opacity/transform trước, rồi bậtdisplay: noneởtransitionend— hoặc dùngallow-discreteđể trình duyệt xếp sequence}.
prefers-reduced-motion — non-negotiable {Không thể thương lượng}
Vestibular disorders and motion sensitivity are real accessibility requirements {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 must shorten or remove non-essential motion {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;
}
}
Prefer surgical overrides over global nuclear rules when you can {Ưu tiên override có mục tiêu hơn rule global hạt nhân khi có thể} — preserve opacity cross-fades that aid comprehension, kill parallax and large spatial shifts {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 with DevTools → Rendering → Emulate prefers-reduced-motion and with OS settings enabled {Test bằng DevTools → Rendering → Emulate prefers-reduced-motion và bật setting OS}.
Production checklist {Checklist production}
| Check | Pass criteria {Tiêu chí đạt} |
|---|---|
| Easing matches intent | Enter = ease-out; exit = ease-in; overshoot only on playful surfaces {Vào = ease-out; ra = ease-in; overshoot chỉ surface vui} |
| Compositor props | transform + opacity for high-frequency motion {transform + opacity cho motion tần suất cao} |
| Duration scale | Micro 100–200ms; UI 200–400ms; hero ≤ 600ms {Micro 100–200ms; UI 200–400ms; hero ≤ 600ms} |
| Discrete exits | @starting-style + allow-discrete or transitionend handler {Thoát rời rạc: @starting-style + allow-discrete hoặc handler transitionend} |
| Reduced motion | No essential info conveyed only through motion {Không truyền thông tin thiết yếu chỉ bằng motion} |
| Debug | DevTools Animations panel + slow-motion (10% speed) {DevTools Animations + slow-motion 10%} |
Further reading {Đọc thêm}
- /blog/css-performance-deep-dive/ — layout/paint/composite cost model and
will-change{cost model layout/paint/composite vàwill-change} - /blog/view-transitions-api-guide/ — page-level transitions (separate from property easing) {transition cấp trang (tách khỏi easing property)}
- /blog/modern-css-features-2026/ —
@starting-style, scroll-driven animations overview {@starting-style, tổng quan scroll-driven animation} - MDN: easing-function — authoritative syntax reference {tham chiếu cú pháp chính thức}
Motion is not decoration — it communicates state, hierarchy, and causality {Motion không phải trang trí — nó truyền state, hierarchy, và nhân quả}. Pick the right API, animate the cheap properties, tune the curve until it feels inevitable, and respect users who need stillness {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}. The demo above exists so you stop guessing what cubic-bezier(0.34, 1.56, 0.64, 1) feels like — drag, play, race {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}.