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.

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ụ

APIKích hoạtPhù hợpNgoài main thread?
CSS transitionsĐổi một propertyhover, focus, toggleĐôi khi (prop compositor)
CSS animations@keyframes + animation-*loop, nhiều bước, timeline độc lậpSometimes
Web Animations API (WAAPI)element.animate(keyframes, options)chuỗi JS, easing động, cancel từ codeSometimes

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 @keyframes cho 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
linearStraight lineCơ học, tốc độ đều
easeSlight ease-in-outMặc định trình duyệt; UI chung an toàn
ease-inSlow startElement rời, tăng tốc
ease-outSlow endElement đến, giảm tốc
ease-in-outSlow both endsDi 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 P1P2 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 termHành vi cuối mỗi step
jump-end (default)Giữ giá trị đầu đến biên step, rồi nhảy
jump-startNhảy ngay, giữ đến biên sau
jump-noneGiữ cả hai đầu
jump-bothNhả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 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

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:

TierProperties (examples)Chi phí
Compositor-onlytransform, opacity, filter (often), translate/scale/rotate individuallyThấp nhất — thường GPU composite
Paintcolor, background, box-shadow, border-radius, clip-pathRepaint layer — dùng vừa phải
Layoutwidth, height, top, left, margin, padding, font-sizeKí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;
}
ValueHiệu ứng
replace (default)Animation sau thắng
addCộng dồn — hữu ích cho kênh transform lớp
accumulateGiố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 ambientinteractive 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-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;
}

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: nonetransitionend — hoặc dùng allow-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

CheckTiêu chí đạt
Easing matches intentVào = ease-out; ra = ease-in; overshoot chỉ surface vui
Compositor propstransform + opacity transform + opacity cho motion tần suất cao
Duration scaleMicro 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 motionKhông truyền thông tin thiết yếu chỉ bằng motion
DebugDevTools 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.