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.

Motion is only as good as its easing. The same 200ms transition can feel cheap or premium depending entirely on the timing function you pick. {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.}

This guide goes deep on the four families of CSS easing — keywords, cubic-bezier(), steps(), and the modern linear() — with copy-pasteable recipes. {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

An animation interpolates a value (position, opacity, color) from a start to an end over a duration. {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 is the function that maps elapsed time (0→1) onto progress (0→1). {Easing là hàm ánh xạ thời gian đã trôi (0→1) sang tiến độ (0→1).}

Linear motion (constant speed) almost never appears in the physical world, so our eyes read it as robotic. {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.}

Real objects have mass: they accelerate and decelerate. Good easing mimics that, which is why eased motion feels “natural” even when we can’t articulate why. {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.}

The easing function applies to a single transition or animation via transition-timing-function or animation-timing-function. {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 ships five named easings. They’re shorthands for specific cubic-bezier() curves. {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; }

Here is what each one means perceptually. {Đây là ý nghĩa cảm nhận của mỗi loại.}

  • linear — constant speed; mechanical. Good for spinners, progress bars, marquees. {tốc độ không đổi; máy móc. Tốt cho spinner, progress bar, marquee.}
  • ease — the default; gentle start, fast middle, gentle stop. Equivalent to cubic-bezier(0.25, 0.1, 0.25, 1). {mặc định; bắt đầu nhẹ, giữa nhanh, dừng nhẹ.}
  • ease-in — starts slow, accelerates into the end. Feels like an object “leaving” — good for exits. {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.}
  • ease-out — starts fast, decelerates to a stop. Feels responsive — best for elements entering or reacting to input. {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.}
  • ease-in-out — slow on both ends, fast in the middle. Best for movement between two on-screen positions. {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.}

The senior rule of thumb: use ease-out for things appearing or responding to a user (it feels instant because it starts fast), and ease-in only for things leaving the screen. {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

Every smooth CSS easing is a cubic Bézier curve defined by four numbers. {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);
}

The curve has four control points. P0 is fixed at (0, 0) and P3 is fixed at (1, 1) — start and end. {Đườ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.}

The four arguments are the two inner control points: P1 = (x1, y1) and P2 = (x2, y2). {Bốn tham số là hai control point bên trong: P1 = (x1, y1)P2 = (x2, y2).}

How to read the curve (the mental model that unlocks everything): {Cách đọc đường cong (mô hình tư duy mở khóa mọi thứ):}

  • The x-axis is time (0→1), the y-axis is progress (0→1). {Trục x là thời gian (0→1), trục y là tiến độ (0→1).}
  • A steep part of the curve = fast motion (lots of progress per unit time). {Đoạn dốc = chuyển động nhanh (nhiều tiến độ trên một đơn vị thời gian).}
  • A flat part = slow motion or a pause. {Đoạn phẳng = chuyển động chậm hoặc tạm dừng.}
  • x1 and x2 must stay within [0, 1] (time can’t run backwards). {x1x2 bắt buộc trong [0, 1] (thời gian không chạy ngược).}
  • y1 and y2 can go outside [0, 1] — that’s how you get overshoot and anticipation. {y1y2 có thể ra ngoài [0, 1] — đó là cách tạo overshoot và anticipation.}

The keyword easings expressed as bezier curves: {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

Start from intent, then tweak. A snappier ease-out pulls the second control point toward the start: {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);
}

Tip: keep your curves as CSS custom properties in one place. It turns “magic numbers” scattered across files into a small, named motion vocabulary. {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

Push y outside [0, 1] to make the value briefly exceed its target (overshoot) or back up before moving (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 is great for playful, attention-grabbing UI (toasts, badges, likes). Use it sparingly — too much “boing” feels toy-like on serious products. {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

Not all motion is smooth. steps() makes the value jump in discrete chunks instead of interpolating continuously. {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);
}

The classic use case is sprite-sheet animation: a single image strip of N frames, revealed one frame at a time by shifting background-position. {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() takes an optional second argument controlling when the jump happens relative to each interval. {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 */

Two shorthands you’ll see in older code: {Hai cách viết tắt bạn sẽ gặp trong code cũ:}

  • step-startsteps(1, jump-start) — jumps to the end value immediately. {nhảy ngay sang giá trị cuối.}
  • step-endsteps(1, jump-end) — holds the start value, then jumps at the very end. {giữ giá trị đầu, rồi nhảy ở phút chót.}

For sprite sheets, prefer jump-none when your strip includes both the first and last frame, so no frame is skipped or shown twice. {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

Cubic Bézier curves are monotonic in shape between their control points — they can’t wiggle up and down multiple times. So real springy, bouncy motion (which oscillates) was historically impossible in pure CSS. {Đườ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.}

The linear() easing function (Chrome 113+, Safari 17.2+, Firefox 112+, all 2023+) fixes this. It defines easing as a series of points connected by straight lines — a piecewise-linear approximation of any curve, including ones that overshoot and oscillate. {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
  );
}

You can also pin specific stops to specific time positions using a percentage. {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 */
  );
}

Because hand-writing dozens of stops is painful, generate them. The community standard is Jake Archibald’s linear() easing generator, which converts a spring/bounce JS function or SVG path into a ready-to-paste linear(). {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);
}

Why this matters: you get JS-spring quality motion that runs entirely on the compositor, off the main thread — no animation library, no requestAnimationFrame, no layout thrash. {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.}

Always provide a sane fallback for older browsers, since linear() is silently ignored if unsupported. {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

A single transition can animate multiple properties with different easings. List them in the same order. {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;
}

This is a senior trick for realism: in a card that lifts on hover, the transform can overshoot slightly while opacity fades linearly and the shadow eases gently. Mixing curves per-property is what separates “animated” from “designed”. {Đâ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ế”.}

For multi-stage @keyframes, set the timing-function inside keyframes — it controls the easing of the segment starting at that keyframe. {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

Copy-paste starting points for the most common needs. {Đ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

Always honor the user’s OS preference. Easing means nothing if it makes someone nauseous. {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 maps time→progress; non-linear curves read as “natural” because real objects accelerate. {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.}
  • Default to ease-out for entrances and input responses; reserve ease-in for exits. {Mặc định ease-out cho entrance và phản hồi input; để dành ease-in cho exit.}
  • Read cubic-bezier() on a time(x)/progress(y) graph: steep = fast, flat = slow; y outside [0,1] = overshoot/anticipation. {Đọc cubic-bezier() trên đồ thị time(x)/progress(y): dốc = nhanh, phẳng = chậm; y ngoài [0,1] = overshoot/anticipation.}
  • Use steps() for sprite sheets and discrete UI; mind the jump-term. {Dùng steps() cho sprite sheet và UI rời rạc; chú ý jump-term.}
  • Reach for linear() to get spring/bounce motion in pure CSS — generate the stops and add an @supports fallback. {Dùng linear() để có chuyển động spring/bounce trong CSS thuần — generate stop và thêm fallback @supports.}
  • Mix easings per-property and store curves as named custom properties for a coherent motion system. {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.}