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 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ả.
Đâ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.
Mở demo đầy đủ:
The mental model: interpolation between two states
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.
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.
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
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: 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: 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: đường cong easing —ease,linear,ease-in-out,cubic-bezier(...), hoặcsteps(...).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.
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
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);
}
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.
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: 0s và transition-timing-function: ease, xoá mọi longhand trước đó.
What is and isn’t animatable
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).
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.
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.
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
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>
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
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);
}
Vì transition được khai báo trên .button (trạng thái nghỉ), nó chi phối cả lúc hover vào và hover ra. Phần tử luôn biết cách animate quay lại.
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 */
}
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+)
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.
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; */
}
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
Đâ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;
}
}
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ừ đó.
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
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; }
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 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
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 transform và opacity — 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
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
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.
- 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.
- 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).
| Aspect | Transition | Animation |
|---|---|---|
| States | Two (from → to) | Many (@keyframes) |
| Trigger | Required (state change) | Optional (can autoplay) |
| Looping | No | Yes (animation-iteration-count) |
| Reversibility | Automatic on reverse trigger | Manual (direction) |
| Best for | Hover/focus, toggles, enter/exit | Loaders, complex sequences |
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
- 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.
- Khai báo
transitiontrên trạng thái nghỉ để cả hai chiều đều animate. - Nêu tên thuộc tính rõ ràng; tránh
allvà các thuộc tính kích layout; ưu tiêntransform/opacity. - Với transition
display: none, kết hợptransition-behavior: allow-discrete,@starting-style, và (cho top-layer)overlay. - Luôn bảo vệ bằng
prefers-reduced-motion.