jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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ụ}

APITrigger {Kích hoạt}Best for {Phù hợp}Runs off main thread? {Ngoài main thread?}
CSS transitionsSingle 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 @keyframes for 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 @keyframes cho 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}

KeywordApproximate curve {Đường cong xấp xỉ}Feel {Cảm giác}
linearStraight lineMechanical, constant speed {Cơ học, tốc độ đều}
easeSlight ease-in-outBrowser default; safe general UI {Mặc định trình duyệt; UI chung an toàn}
ease-inSlow startElements leaving, accelerating away {Element rời, tăng tốc}
ease-outSlow endElements arriving, decelerating in {Element đến, giảm tốc}
ease-in-outSlow both endsLonger 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 P1P2 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 termBehavior 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-startJump immediately, hold until next boundary {Nhảy ngay, giữ đến biên sau}
jump-noneHold both ends — steps(n) behaves like steps(n + 1, jump-end) with no double hold {Giữ cả hai đầu}
jump-bothJump 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 easingease-out / custom cubic-bezier
Single overshootcubic-bezier with Y > 1
Multi-bounce or complex springlinear() with many stops, or WAAPI + spring lib
Sprite / frame animationsteps()
Typewriter / discrete revealsteps() 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}:

TierProperties (examples)Cost {Chi phí}
Compositor-onlytransform, opacity, filter (often), translate/scale/rotate individuallyLowest — often GPU-composited {Thấp nhất — thường GPU composite}
Paintcolor, background, box-shadow, border-radius, clip-pathRepaint layer — OK sparingly {Repaint layer — dùng vừa phải}
Layoutwidth, height, top, left, margin, padding, font-sizeTriggers 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;
}
ValueEffect {Hiệu ứng}
replace (default)Later animation wins {Animation sau thắng}
addAdditive — useful for layered transform channels {Cộng dồn — hữu ích cho kênh transform lớp}
accumulateLike 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 ambientinteractive 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: autointerpolate-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-keywordscalc-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: none on transitionend — or use allow-discrete so the browser sequences discrete + continuous for you {chạy opacity/transform trước, rồi bật display: nonetransitionend — hoặc dùng allow-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}

CheckPass criteria {Tiêu chí đạt}
Easing matches intentEnter = ease-out; exit = ease-in; overshoot only on playful surfaces {Vào = ease-out; ra = ease-in; overshoot chỉ surface vui}
Compositor propstransform + opacity for high-frequency motion {transform + opacity cho motion tần suất cao}
Duration scaleMicro 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 motionNo essential info conveyed only through motion {Không truyền thông tin thiết yếu chỉ bằng motion}
DebugDevTools Animations panel + slow-motion (10% speed) {DevTools Animations + slow-motion 10%}

Further reading {Đọc thêm}

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}.