jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

CSS Easing Functions — cubic-bezier(), steps(), and linear()

Senior guide to CSS easing: keyword easings, reading cubic-bezier curves, steps() for sprites, and the linear() function for springs and bounces.

Chuyển động chỉ tốt ngang với easing của nó. Cùng một transition 200ms có thể trông rẻ tiền hoặc cao cấp tùy hoàn toàn vào timing function bạn chọn.

Bài này đi sâu vào bốn nhóm easing trong CSS — keyword, cubic-bezier(), steps(), và linear() hiện đại — kèm các công thức copy-paste được.

Interactive demo — open full screen · Demo tương tác — mở toàn màn hình

What easing actually is

Một animation nội suy một giá trị (vị trí, độ mờ, màu) từ đầu đến cuối trong một khoảng thời gian.

Easing là hàm ánh xạ thời gian đã trôi (0→1) sang tiến độ (0→1).

Chuyển động tuyến tính (tốc độ không đổi) gần như không tồn tại trong thế giới vật lý, nên mắt ta đọc nó là máy móc.

Vật thể thật có khối lượng: chúng tăng tốc rồi giảm tốc. Easing tốt mô phỏng điều đó, nên chuyển động có easing cảm thấy “tự nhiên” dù ta không giải thích được tại sao.

Hàm easing áp dụng cho một transition hoặc animation qua transition-timing-function hoặc animation-timing-function.

.box {
  /* shorthand: property | duration | timing-function | delay */
  transition: transform 240ms ease-out 0ms;
}

.spinner {
  animation: spin 1s linear infinite;
}

The keyword easings

CSS có sẵn năm easing đặt tên. Chúng là viết tắt cho các đường cong cubic-bezier() cụ thể.

.linear      { transition-timing-function: linear; }
.ease        { transition-timing-function: ease; }        /* default */
.ease-in     { transition-timing-function: ease-in; }
.ease-out    { transition-timing-function: ease-out; }
.ease-in-out { transition-timing-function: ease-in-out; }

Đây là ý nghĩa cảm nhận của mỗi loại.

  • tốc độ không đổi; máy móc. Tốt cho spinner, progress bar, marquee.
  • mặc định; bắt đầu nhẹ, giữa nhanh, dừng nhẹ.
  • bắt đầu chậm, tăng tốc về cuối. Cảm giác vật thể “rời đi” — tốt cho exit.
  • bắt đầu nhanh, giảm tốc đến khi dừng. Cảm giác phản hồi nhanh — tốt nhất cho phần tử đi vào hoặc phản ứng input.
  • chậm ở hai đầu, nhanh ở giữa. Tốt nhất cho di chuyển giữa hai vị trí trên màn hình.

Quy tắc kinh nghiệm: dùng ease-out cho thứ xuất hiện hoặc phản hồi người dùng (cảm giác tức thì vì bắt đầu nhanh), và ease-in chỉ cho thứ rời màn hình.

/* A button that responds to hover: ease-out feels snappy */
.btn {
  transition: transform 160ms ease-out, box-shadow 160ms ease-out;
}
.btn:hover {
  transform: translateY(-2px);
}

cubic-bezier(): the math behind the curve

Mọi easing mượt trong CSS đều là một đường cong Bézier bậc ba xác định bởi bốn số.

.custom {
  transition-timing-function: cubic-bezier(x1, y1, x2, y2);
}

Đường cong có bốn control point. P0 cố định tại (0, 0) và P3 cố định tại (1, 1) — điểm đầu và cuối.

Bốn tham số là hai control point bên trong: P1 = (x1, y1)P2 = (x2, y2).

Cách đọc đường cong (mô hình tư duy mở khóa mọi thứ):

  • Trục x là thời gian (0→1), trục y là tiến độ (0→1).
  • Đoạn dốc = chuyển động nhanh (nhiều tiến độ trên một đơn vị thời gian).
  • Đoạn phẳng = chuyển động chậm hoặc tạm dừng.
  • x1x2 bắt buộc trong [0, 1] (thời gian không chạy ngược).
  • y1y2 có thể ra ngoài [0, 1] — đó là cách tạo overshoot và anticipation.

Các keyword easing biểu diễn dưới dạng bezier:

.linear      { transition-timing-function: cubic-bezier(0, 0, 1, 1); }
.ease        { transition-timing-function: cubic-bezier(0.25, 0.1, 0.25, 1); }
.ease-in     { transition-timing-function: cubic-bezier(0.42, 0, 1, 1); }
.ease-out    { transition-timing-function: cubic-bezier(0, 0, 0.58, 1); }
.ease-in-out { transition-timing-function: cubic-bezier(0.42, 0, 0.58, 1); }

Building your own curves

Bắt đầu từ ý định, rồi tinh chỉnh. Một ease-out “đanh” hơn kéo control point thứ hai về phía đầu:

:root {
  /* A few production-grade curves worth memorizing */
  --ease-out-quad:   cubic-bezier(0.25, 0.46, 0.45, 0.94);
  --ease-out-cubic:  cubic-bezier(0.33, 1, 0.68, 1);   /* snappy, popular */
  --ease-out-expo:   cubic-bezier(0.16, 1, 0.3, 1);    /* very snappy entrance */
  --ease-in-out-cubic: cubic-bezier(0.65, 0, 0.35, 1);
}

.panel {
  transition: transform 300ms var(--ease-out-expo);
}

Mẹo: giữ các đường cong dưới dạng CSS custom property ở một chỗ. Nó biến “magic number” rải rác thành một bộ từ vựng motion nhỏ, có tên.

Overshoot and anticipation

Đẩy y ra ngoài [0, 1] để giá trị vượt mục tiêu trong chốc lát (overshoot) hoặc lùi lại trước khi đi (anticipation).

:root {
  /* y2 = 1.275 > 1 → the element overshoots, then settles back */
  --ease-back-out: cubic-bezier(0.34, 1.56, 0.64, 1);
  /* y1 = -0.6 < 0 → the element pulls back first (anticipation) */
  --ease-back-in:  cubic-bezier(0.36, -0.6, 0.7, 0);
}

.toast {
  /* Pops in past its final size, then snaps to rest */
  transition: transform 320ms var(--ease-back-out);
}
.toast[data-open] {
  transform: scale(1);
}

Overshoot rất hợp cho UI vui mắt, gây chú ý (toast, badge, like). Dùng tiết chế — quá nhiều “boing” sẽ trông như đồ chơi trên sản phẩm nghiêm túc.

Step functions: steps() and friends

Không phải chuyển động nào cũng mượt. steps() khiến giá trị nhảy theo từng khối rời rạc thay vì nội suy liên tục.

.timer {
  /* 4 equal jumps across the duration */
  transition-timing-function: steps(4);
}

Use case kinh điển là animation sprite-sheet: một dải ảnh gồm N frame, hiện từng frame một bằng cách dịch background-position.

.sprite {
  width: 96px;            /* one frame */
  height: 96px;
  background: url("/sprites/run.png") 0 0 / 960px 96px; /* 10 frames wide */
  animation: run 800ms steps(10) infinite;
}

@keyframes run {
  /* Move exactly one full strip; steps(10) snaps to each frame */
  to { background-position: -960px 0; }
}

The jump-term: where the steps land

steps() nhận tham số thứ hai tùy chọn điều khiển khi nào bước nhảy xảy ra trong mỗi khoảng.

.a { animation-timing-function: steps(4, jump-start); }  /* jump at start of each step */
.b { animation-timing-function: steps(4, jump-end); }    /* jump at end (default) */
.c { animation-timing-function: steps(4, jump-none); }   /* hits both 0 and 1, N-1 jumps */
.d { animation-timing-function: steps(4, jump-both); }   /* extra step at both ends */

Hai cách viết tắt bạn sẽ gặp trong code cũ:

  • nhảy ngay sang giá trị cuối.
  • giữ giá trị đầu, rồi nhảy ở phút chót.

Với sprite sheet, ưu tiên jump-none khi dải ảnh có cả frame đầu và cuối, để không frame nào bị bỏ qua hay hiện hai lần.

/* Typewriter caret / blinking dot using a 1-step jump */
.caret {
  animation: blink 1s step-end infinite;
}
@keyframes blink {
  50% { opacity: 0; }
}

linear(): springs and bounces without JS

Đường cong cubic Bézier có hình dạng đơn điệu giữa các control point — không thể nhấp nhô lên xuống nhiều lần. Nên chuyển động lò xo, nảy thật (dao động) trước đây bất khả thi trong CSS thuần.

Hàm easing linear() (Chrome 113+, Safari 17.2+, Firefox 112+, đều từ 2023+) giải quyết điều này. Nó định nghĩa easing là một chuỗi điểm nối bằng đoạn thẳng — một xấp xỉ tuyến tính từng đoạn cho bất kỳ đường cong nào, kể cả loại overshoot và dao động.

/* Each value is a progress (y) stop; positions are evenly spaced by default */
.bounce {
  transition-timing-function: linear(
    0, 0.063, 0.25, 0.563, 1, 0.813, 0.688, 0.813, 1, 0.938, 1
  );
}

Bạn cũng có thể ghim các stop cụ thể vào vị trí thời gian cụ thể bằng phần trăm.

.spring {
  /* value, time%  — control exactly where each progress value lands */
  transition-timing-function: linear(
    0,
    0.5 25%,
    1.1 50%,   /* overshoots to 110% halfway */
    0.95 70%,
    1 100%     /* settles at the target */
  );
}

Vì viết tay hàng chục stop rất khổ, hãy generate chúng. Chuẩn cộng đồng là linear() easing generator của Jake Archibald, chuyển một hàm spring/bounce JS hoặc SVG path thành linear() dán-là-chạy.

:root {
  /* A natural spring, generated then trimmed */
  --spring: linear(
    0, 0.009, 0.035, 0.078, 0.137, 0.211, 0.297, 0.394,
    0.5, 0.611, 0.724, 0.834, 0.938, 1.03, 1.107, 1.166,
    1.205, 1.224, 1.224, 1.206, 1.175, 1.133, 1.085, 1.035,
    0.988, 0.948, 0.918, 0.9, 0.895, 0.902, 1
  );
}

.modal {
  transition: transform 600ms var(--spring);
}

Tại sao quan trọng: bạn có chuyển động chất lượng spring-JS chạy hoàn toàn trên compositor, ngoài main thread — không cần thư viện animation, không requestAnimationFrame, không layout thrash.

Luôn cung cấp fallback hợp lý cho trình duyệt cũ, vì linear() bị bỏ qua âm thầm nếu không hỗ trợ.

.modal {
  transition-timing-function: var(--ease-out-cubic); /* fallback */
}
@supports (transition-timing-function: linear(0, 1)) {
  .modal {
    transition-timing-function: var(--spring);
  }
}

Easing per-property

Một transition có thể animate nhiều property với easing khác nhau. Liệt kê theo cùng thứ tự.

.card {
  transition:
    transform 300ms var(--ease-out-expo),
    opacity   200ms ease-out,
    box-shadow 300ms ease-in-out;
}

Đây là chiêu của dân senior để tạo cảm giác thật: trong card nhấc lên khi hover, transform có thể overshoot nhẹ trong khi opacity mờ tuyến tính và shadow ease nhẹ nhàng. Trộn curve theo từng property là thứ phân biệt “có animation” với “được thiết kế”.

Với @keyframes nhiều giai đoạn, đặt timing-function bên trong keyframe — nó điều khiển easing của đoạn bắt đầu từ keyframe đó.

@keyframes attention {
  0%   { transform: scale(1);    animation-timing-function: ease-in; }
  30%  { transform: scale(1.15); animation-timing-function: ease-out; }
  100% { transform: scale(1); }
}

Practical recipes

Điểm khởi đầu copy-paste cho các nhu cầu phổ biến nhất.

:root {
  --ease-out-cubic: cubic-bezier(0.33, 1, 0.68, 1);
  --ease-out-expo:  cubic-bezier(0.16, 1, 0.3, 1);
  --ease-back-out:  cubic-bezier(0.34, 1.56, 0.64, 1);
  --ease-in-out-cubic: cubic-bezier(0.65, 0, 0.35, 1);
}

/* 1. Snappy UI: button, toggle, tab — fast, decisive, ease-out */
.snappy {
  transition: transform 140ms var(--ease-out-cubic),
              background-color 140ms var(--ease-out-cubic);
}

/* 2. Overshoot: toast / badge popping in */
.pop-in {
  transition: transform 280ms var(--ease-back-out);
  transform: scale(0);
}
.pop-in[data-open] { transform: scale(1); }

/* 3. Anticipation: drawer that pulls back before sliding out */
.drawer {
  transition: transform 360ms cubic-bezier(0.36, -0.3, 0.66, -0.05);
  transform: translateX(-100%);
}
.drawer[data-open] { transform: translateX(0); }

/* 4. Smooth move between positions: hero crossfade, layout shift */
.move {
  transition: transform 420ms var(--ease-in-out-cubic);
}

/* 5. Entrance from offscreen: expo feels instant yet smooth */
.enter {
  transition: transform 500ms var(--ease-out-expo),
              opacity 300ms ease-out;
}

Respect reduced motion

Luôn tôn trọng tùy chọn OS của người dùng. Easing vô nghĩa nếu nó khiến ai đó chóng mặt.

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
}

Takeaways

  • Easing ánh xạ thời gian→tiến độ; đường cong phi tuyến đọc là “tự nhiên” vì vật thật tăng tốc.
  • Mặc định ease-out cho entrance và phản hồi input; để dành ease-in cho exit.
  • Đọc cubic-bezier() trên đồ thị time(x)/progress(y): dốc = nhanh, phẳng = chậm; y ngoài [0,1] = overshoot/anticipation.
  • Dùng steps() cho sprite sheet và UI rời rạc; chú ý jump-term.
  • Dùng linear() để có chuyển động spring/bounce trong CSS thuần — generate stop và thêm fallback @supports.
  • Trộn easing theo từng property và lưu curve dưới dạng custom property có tên cho một hệ motion mạch lạc.