jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

CSS Transitions Deep Dive — Interpolation, allow-discrete, and the Gotchas

A senior guide to CSS transitions: the transition model, animatable properties, transition-behavior allow-discrete, transitioning display:none, and pitfalls.

CSS transitions look trivial until you try to fade out an element that uses display: none, and suddenly nothing animates. {CSS transitions trông có vẻ tầm thường cho đến khi bạn cố làm mờ một phần tử dùng display: none, và đột nhiên không có gì animate cả.}

This is a senior-level tour of the transition model: what actually interpolates, how the shorthand parses, the modern transition-behavior: allow-discrete escape hatch, and the gotchas that bite everyone at least once. {Đây là một chuyến đi sâu cấp senior về mô hình transition: cái gì thực sự được nội suy, shorthand parse ra sao, escape hatch hiện đại transition-behavior: allow-discrete, và những cái bẫy mà ai cũng dính ít nhất một lần.}

Open the full demo {Mở demo đầy đủ}: /tools/css-transitions-demo/.

The mental model: interpolation between two states

A transition is not an animation timeline. It is the browser interpolating a property between two computed values: the value before a change and the value after. {Transition không phải là một timeline animation. Nó là việc trình duyệt nội suy một thuộc tính giữa hai giá trị computed: giá trị trước khi thay đổi và giá trị sau.}

When a property’s computed value changes (because a class was added, a pseudo-class matched, a style was set via JS), the browser checks: is this property in the element’s transition-property list, and is it interpolable? If yes, it animates from old to new over the duration. {Khi giá trị computed của một thuộc tính thay đổi (vì class được thêm, pseudo-class khớp, style được set qua JS), trình duyệt kiểm tra: thuộc tính này có nằm trong danh sách transition-property không, và nó có nội suy được không? Nếu có, nó animate từ cũ sang mới trong khoảng duration.}

The implication: you need a start state and an end state that both exist as real computed values. No keyframes, no intermediate steps — just A to B. {Hệ quả: bạn cần một trạng thái đầu và một trạng thái cuối cùng tồn tại như những giá trị computed thật. Không keyframes, không bước trung gian — chỉ A đến B.}

The four longhand properties

A transition is configured by four properties. Most people only learn the shorthand and never understand what they’re really setting. {Một transition được cấu hình bởi bốn thuộc tính. Đa số chỉ học shorthand và không bao giờ hiểu thật sự mình đang set cái gì.}

.box {
  transition-property: background-color, transform;
  transition-duration: 200ms, 400ms;
  transition-timing-function: ease-out, cubic-bezier(0.2, 0, 0, 1);
  transition-delay: 0ms, 100ms;
}
  • transition-property: which properties to watch. all (default) watches everything; a comma list targets specific ones. {transition-property: những thuộc tính nào cần theo dõi. all (mặc định) theo dõi tất cả; danh sách phân tách bằng dấu phẩy nhắm vào những cái cụ thể.}
  • transition-duration: how long each transition takes. 0s is the default, which is why nothing animates if you forget it. {transition-duration: mỗi transition kéo dài bao lâu. Mặc định là 0s, nên nếu quên thì chẳng có gì animate.}
  • transition-timing-function: the easing curve — ease, linear, ease-in-out, cubic-bezier(...), or steps(...). {transition-timing-function: đường cong easing — ease, linear, ease-in-out, cubic-bezier(...), hoặc steps(...).}
  • transition-delay: how long to wait before starting. Negative values start the transition partway through. {transition-delay: chờ bao lâu trước khi bắt đầu. Giá trị âm khiến transition bắt đầu từ giữa chừng.}

When lists have different lengths, the shorter ones repeat (cycle) to match transition-property. Keeping them aligned by hand avoids surprises. {Khi các danh sách có độ dài khác nhau, danh sách ngắn hơn sẽ lặp lại (cycle) để khớp với transition-property. Tự căn chỉnh chúng bằng tay giúp tránh bất ngờ.}

The transition shorthand

The shorthand packs everything into one declaration. The parser disambiguates by type, not position — but there is one ordering rule for time values. {Shorthand gói tất cả vào một khai báo. Parser phân biệt theo kiểu chứ không theo vị trí — nhưng có một quy tắc thứ tự cho các giá trị thời gian.}

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

/* multiple transitions, comma-separated */
.card {
  transition:
    box-shadow 200ms ease,
    transform 300ms cubic-bezier(0.2, 0, 0, 1);
}

The single ordering rule: the first <time> is parsed as duration, the second as delay. Everything else is matched by type. So transition: 300ms transform 100ms ease works, but it’s unreadable — stick to the canonical order. {Quy tắc thứ tự duy nhất: <time> đầu tiên được parse thành duration, cái thứ hai thành delay. Mọi thứ còn lại khớp theo kiểu. Nên transition: 300ms transform 100ms ease vẫn chạy, nhưng khó đọc — hãy theo thứ tự chuẩn.}

A subtle trap: the shorthand resets all longhands you don’t mention. Writing transition: transform 300ms silently sets transition-delay: 0s and transition-timing-function: ease, wiping any earlier longhand. {Một cái bẫy tinh tế: shorthand reset tất cả longhand mà bạn không nhắc đến. Viết transition: transform 300ms âm thầm set transition-delay: 0stransition-timing-function: ease, xoá mọi longhand trước đó.}

What is and isn’t animatable

Properties fall into two buckets: interpolable (a continuous range of values exists between A and B) and discrete (no meaningful in-between). {Các thuộc tính rơi vào hai nhóm: nội suy được (tồn tại một dải giá trị liên tục giữa A và B) và rời rạc (không có giá trị giữa có nghĩa).}

Interpolable properties include opacity, color, background-color, transform, width, height, margin, padding, box-shadow, filter, border-radius, and many more. The browser can compute every frame between the endpoints. {Các thuộc tính nội suy được gồm opacity, color, background-color, transform, width, height, margin, padding, box-shadow, filter, border-radius, và nhiều nữa. Trình duyệt có thể tính mọi frame giữa hai đầu mút.}

Discrete properties include display, visibility, position, flex-direction, and similar layout/keyword switches. Historically these snap at the 50% mark of the duration instead of interpolating. {Các thuộc tính rời rạc gồm display, visibility, position, flex-direction, và những kiểu chuyển keyword/layout tương tự. Trước đây chúng nhảy tại mốc 50% của duration thay vì nội suy.}

The “snap at 50%” rule matters: by default a discrete property change is applied halfway through the transition, which is why a display change used to feel like it ignored your duration entirely. {Quy tắc “nhảy tại 50%” rất quan trọng: mặc định một thay đổi thuộc tính rời rạc được áp dụng ở giữa transition, đó là lý do thay đổi display từng cảm giác như bỏ qua hoàn toàn duration của bạn.}

Transitioning multiple properties with staggered delays

Per-property transition-delay lets you stagger a single state change into a small choreography. {transition-delay riêng cho từng thuộc tính cho phép bạn dàn một thay đổi trạng thái đơn lẻ thành một màn dàn dựng nhỏ.}

.menu-item {
  opacity: 0;
  transform: translateY(8px);
  transition:
    opacity 200ms ease-out,
    transform 200ms ease-out;
}

.menu.is-open .menu-item {
  opacity: 1;
  transform: translateY(0);
}

/* stagger each item by index using a custom property */
.menu-item {
  transition-delay: calc(var(--i, 0) * 40ms);
}
<ul class="menu">
  <li class="menu-item" style="--i: 0">Home</li>
  <li class="menu-item" style="--i: 1">Blog</li>
  <li class="menu-item" style="--i: 2">About</li>
</ul>

Each item inherits its own --i, so the delay grows per item and you get a cascade without JavaScript or keyframes. {Mỗi item nhận --i riêng, nên delay tăng dần theo từng item và bạn có hiệu ứng đổ tầng mà không cần JavaScript hay keyframes.}

Transitions on pseudo-classes and the reverse

The most common trigger is a pseudo-class like :hover or :focus. The key insight: where you put the transition decides both directions. {Trigger phổ biến nhất là một pseudo-class như :hover hoặc :focus. Điểm mấu chốt: đặt transition ở đâu sẽ quyết định cả hai chiều.}

/* transition lives on the base state → applies both ways */
.button {
  background: var(--bg);
  transition: background 200ms ease, transform 150ms ease;
}
.button:hover {
  background: var(--accent);
  transform: translateY(-2px);
}

Because the transition is declared on .button (the resting state), it governs the hover-in and the hover-out. The element always knows how to animate back. {Vì transition được khai báo trên .button (trạng thái nghỉ), nó chi phối cả lúc hover vào hover ra. Phần tử luôn biết cách animate quay lại.}

If you instead put the transition only inside :hover, the hover-in animates but the hover-out snaps instantly — there’s no transition on the base state to drive the reverse. {Nếu bạn lại chỉ đặt transition bên trong :hover, lúc hover vào sẽ animate nhưng hover ra thì nhảy tức thì — không có transition trên trạng thái nghỉ để điều khiển chiều ngược lại.}

/* asymmetric: fast in, slow out — sometimes intentional */
.tooltip {
  opacity: 0;
  transition: opacity 400ms ease; /* fade out: 400ms */
}
.tooltip:hover {
  opacity: 1;
  transition: opacity 100ms ease; /* fade in: 100ms */
}

This asymmetry is a legitimate technique: each state defines the transition used to enter it, so you can have a snappy reveal and a gentle dismissal. {Sự bất đối xứng này là một kỹ thuật hợp lệ: mỗi trạng thái định nghĩa transition dùng để bước vào nó, nên bạn có thể có một màn hiện ra dứt khoát và một màn biến mất nhẹ nhàng.}

transition-behavior: allow-discrete (2024+)

Baseline since 2024, transition-behavior: allow-discrete lets discrete properties — including display — participate in transitions instead of snapping. {Là baseline từ 2024, transition-behavior: allow-discrete cho phép các thuộc tính rời rạc — gồm cả display — tham gia transition thay vì nhảy.}

With allow-discrete, a discrete property flips at the start or end of the transition rather than at 50%, so it can be coordinated with an interpolable property like opacity. {Với allow-discrete, một thuộc tính rời rạc lật ở đầu hoặc cuối transition thay vì ở 50%, nên nó có thể được phối hợp với một thuộc tính nội suy được như opacity.}

.panel {
  transition: opacity 300ms ease, display 300ms allow-discrete;
  /* or set it explicitly: transition-behavior: allow-discrete; */
}

Specifically: when transitioning to display: none, the element stays visible for the full duration and only becomes none at the end. When transitioning from display: none to visible, display flips to its visible value immediately at the start so the rest can animate. {Cụ thể: khi transition đến display: none, phần tử vẫn hiển thị suốt thời lượng và chỉ trở thành none ở cuối. Khi transition từ display: none sang hiển thị, display lật sang giá trị hiển thị ngay ở đầu để phần còn lại có thể animate.}

Transitioning to and from display: none

This is the headline use case, and it needs three modern pieces working together: allow-discrete, the @starting-style rule, and overlay for top-layer elements. {Đây là tình huống tiêu biểu, và nó cần ba mảnh hiện đại phối hợp: allow-discrete, quy tắc @starting-style, và overlay cho phần tử top-layer.}

.dialog {
  opacity: 0;
  transition:
    opacity 300ms ease,
    display 300ms allow-discrete;
}

.dialog.is-open {
  display: block;
  opacity: 1;
}

/* the "from" state used the very first time the element renders
   in its open form — without this, opening won't animate */
@starting-style {
  .dialog.is-open {
    opacity: 0;
  }
}

Why @starting-style? When an element switches from display: none to visible, it has no “previous” computed value to interpolate from. @starting-style supplies that initial value the browser animates out of. {Tại sao cần @starting-style? Khi một phần tử chuyển từ display: none sang hiển thị, nó không có giá trị computed “trước đó” để nội suy. @starting-style cung cấp giá trị khởi đầu mà trình duyệt animate đi ra từ đó.}

For elements promoted to the top layer (<dialog> with showModal(), or popover), also transition the overlay property with allow-discrete so the element stays in the top layer until the exit animation finishes. {Với các phần tử được đẩy lên top layer (<dialog> dùng showModal(), hoặc popover), hãy cũng transition thuộc tính overlay với allow-discrete để phần tử ở lại top layer cho đến khi animation thoát kết thúc.}

[popover] {
  opacity: 0;
  transition:
    opacity 300ms ease,
    display 300ms allow-discrete,
    overlay 300ms allow-discrete;
}
[popover]:popover-open {
  opacity: 1;
}
@starting-style {
  [popover]:popover-open {
    opacity: 0;
  }
}

Common gotchas

Transition firing on initial render

If a transitioned property has a starting value at first paint, the browser may animate it on page load — an unwanted flash. The classic fix is to add the transition only after the first frame, or scope it so the initial computed value matches. {Nếu một thuộc tính có transition đã có giá trị khởi đầu ngay lần vẽ đầu tiên, trình duyệt có thể animate nó lúc tải trang — một cú nháy không mong muốn. Cách sửa kinh điển là chỉ thêm transition sau frame đầu tiên, hoặc giới hạn phạm vi để giá trị computed ban đầu khớp.}

/* guard: no transition until JS marks the page ready */
.box { transition: none; }
.js-ready .box { transition: transform 300ms ease; }

Note that @starting-style is the standards-based answer for the open/close case, but the JS-ready flag is still useful for broad “don’t animate on load” guards. {Lưu ý @starting-style là câu trả lời theo chuẩn cho tình huống mở/đóng, nhưng cờ JS-ready vẫn hữu ích cho việc chặn “đừng animate khi tải” trên diện rộng.}

Transitioning all

transition: all 200ms is convenient but dangerous: it watches every property, so an unrelated layout change (a class toggling width, top, or font-size) animates unexpectedly, and the browser does more interpolation work than needed. Name your properties explicitly. {transition: all 200ms tiện nhưng nguy hiểm: nó theo dõi mọi thuộc tính, nên một thay đổi layout không liên quan (một class bật/tắt width, top, hay font-size) sẽ animate ngoài ý muốn, và trình duyệt làm nhiều việc nội suy hơn cần thiết. Hãy nêu tên các thuộc tính một cách rõ ràng.}

Layout-triggering properties

Transitioning width, height, top, left, or margin forces layout (reflow) on every frame, which is expensive and janky. Prefer transform and opacity — they run on the compositor and can hit 60fps cheaply. {Transition width, height, top, left, hay margin buộc layout (reflow) ở mỗi frame, vừa tốn kém vừa giật. Hãy ưu tiên transformopacity — chúng chạy trên compositor và đạt 60fps một cách rẻ.}

/* ❌ janky: animates layout every frame */
.slide-bad { transition: left 300ms ease; }
.slide-bad.open { left: 0; }

/* ✅ smooth: compositor-only */
.slide-good { transition: transform 300ms ease; }
.slide-good.open { transform: translateX(0); }

Respect reduced motion

Always honor users who prefer less motion. A transition that’s pleasant for most can be nauseating for some. {Luôn tôn trọng người dùng muốn ít chuyển động. Một transition dễ chịu với đa số có thể gây khó chịu với một số người.}

@media (prefers-reduced-motion: reduce) {
  * {
    transition-duration: 0.01ms !important;
  }
}

Transition vs animation: when to use which

Transitions and keyframe animations overlap but solve different problems. Choosing right keeps your CSS lean. {Transition và keyframe animation chồng lấn nhưng giải quyết vấn đề khác nhau. Chọn đúng giúp CSS của bạn gọn gàng.}

  • Use a transition when you have a clear two-state change driven by a trigger (hover, focus, class toggle, JS state). It’s the simplest tool for A↔B. {Dùng transition khi bạn có một thay đổi hai trạng thái rõ ràng được kích bởi một trigger (hover, focus, đổi class, state JS). Đó là công cụ đơn giản nhất cho A↔B.}
  • Use an animation (@keyframes) when you need multiple steps, looping, fine control over intermediate frames, or autoplay without a trigger (a spinner, a pulsing dot, a multi-stage entrance). {Dùng animation (@keyframes) khi bạn cần nhiều bước, lặp, kiểm soát chi tiết các frame trung gian, hoặc tự chạy mà không cần trigger (spinner, chấm nhấp nháy, hiệu ứng vào nhiều giai đoạn).}
AspectTransitionAnimation
StatesTwo (from → to)Many (@keyframes)
TriggerRequired (state change)Optional (can autoplay)
LoopingNoYes (animation-iteration-count)
ReversibilityAutomatic on reverse triggerManual (direction)
Best forHover/focus, toggles, enter/exitLoaders, complex sequences

A good rule of thumb: if you can describe the effect as “from this to that,” reach for a transition first. Only escalate to @keyframes when two states aren’t enough. {Một quy tắc tốt: nếu bạn mô tả được hiệu ứng là “từ cái này sang cái kia,” hãy dùng transition trước. Chỉ nâng lên @keyframes khi hai trạng thái là không đủ.}

Takeaways

  • A transition interpolates one property between two real computed values — no in-between states you didn’t define. {Một transition nội suy một thuộc tính giữa hai giá trị computed thật — không có trạng thái giữa nào mà bạn chưa định nghĩa.}
  • Declare transition on the base state so both directions animate. {Khai báo transition trên trạng thái nghỉ để cả hai chiều đều animate.}
  • Name properties explicitly; avoid all and layout-triggering properties; prefer transform/opacity. {Nêu tên thuộc tính rõ ràng; tránh all và các thuộc tính kích layout; ưu tiên transform/opacity.}
  • For display: none transitions, combine transition-behavior: allow-discrete, @starting-style, and (for top-layer) overlay. {Với transition display: none, kết hợp transition-behavior: allow-discrete, @starting-style, và (cho top-layer) overlay.}
  • Always guard with prefers-reduced-motion. {Luôn bảo vệ bằng prefers-reduced-motion.}