Animating display and height:auto in CSS — allow-discrete & interpolate-size
Modern CSS finally animates display:none and height:auto. Learn transition-behavior allow-discrete, @starting-style, interpolate-size and calc-size with real examples.
Suốt hơn một thập kỷ, hai hiệu ứng động được mong muốn nhất lại đơn giản là bất khả thi. Bạn không thể transition display, và cũng không thể transition height: auto. Từ 2024 trở đi, Chromium và (dần dần) các engine khác đã lấp khoảng trống đó bằng một nhóm tính năng nhỏ.
Bài viết này đi qua vì sao nó bất khả thi, những thủ thuật ta dùng để giả lập, và các primitive hiện đại cuối cùng đã biến nó thành hiện thực.
Mở demo đầy đủ:
Why display and height:auto were never animatable
CSS transition nội suy giữa hai giá trị theo thời gian. Điều đó chỉ hoạt động khi tồn tại các giá trị trung gian để tính toán.
display là thuộc tính rời rạc — giá trị của nó là các từ khóa như none, block, flex. Không có “nửa đường giữa none và block”, nên trình duyệt trước đây chỉ nhảy thẳng ở cuối transition.
height: auto lại là vấn đề khác. auto không phải độ dài — nó là từ khóa phân giải thành kích thước nội tại được tính trong giai đoạn layout. Engine từ chối nội suy từ 0px đến một từ khóa mà giá trị pixel chỉ biết được sau layout.
Vì vậy chúng ta đã giả lập cả hai.
The old hacks (and why each one hurt)
Hack 1 — the max-height trick
Cách kinh điển để “animate height” là transition max-height giữa 0 và một số lớn hơn nội dung.
.panel {
max-height: 0;
overflow: hidden;
transition: max-height 300ms ease;
}
.panel.open {
max-height: 500px; /* must guess > content height */
}
Các vấn đề thì ai cũng biết. Bạn phải đoán một con số ma thuật; nếu nội dung cao hơn nó bị cắt, nếu thấp hơn thì timing sai vì easing chạy qua khoảng trống ảo.
Hack 2 — measuring scrollHeight with JS
Để tránh con số ma thuật, ta dùng JavaScript đọc scrollHeight.
function open(panel) {
panel.style.height = panel.scrollHeight + 'px';
// after transition ends, set to auto so it reflows naturally
panel.addEventListener(
'transitionend',
() => { panel.style.height = 'auto'; },
{ once: true }
);
}
function close(panel) {
// pin the current pixel height first, then animate to 0
panel.style.height = panel.scrollHeight + 'px';
requestAnimationFrame(() => { panel.style.height = '0px'; });
}
Cách này chạy được nhưng mong manh. Bạn phải vật lộn với layout thrashing, double-frame của requestAnimationFrame, và transitionend không kích hoạt nếu transition bị ngắt.
Hack 3 — the grid-template-rows 0fr → 1fr trick
Thủ thuật CSS thuần thanh lịch nhất dùng một grid track chạy từ 0fr đến 1fr. fr Đơn vị fr có thể nội suy, nên cách này animate đến chiều cao nội tại mà không cần JS hay số ma thuật.
.disclosure {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 300ms ease;
}
.disclosure.open {
grid-template-rows: 1fr;
}
.disclosure > .content {
overflow: hidden; /* required so the child clips while the track shrinks */
}
Đây là lựa chọn tốt nhất trong nhiều năm. Nhược điểm thực sự duy nhất: cần thêm phần tử bao bọc, và bản thân display vẫn nhảy giật (bạn không thể fade phần tử ra và gỡ nó khỏi box tree).
The modern solution, part 1 — transition-behavior: allow-discrete
Primitive mới đầu tiên là transition-behavior: allow-discrete. Nó nói với trình duyệt: với các thuộc tính rời rạc trong transition này, đừng nhảy ở đầu — giữ giá trị cũ đến tận cuối transition rồi mới lật.
Chính quy tắc đó cho phép display: none tham gia. Khi ẩn, display giữ block suốt 300ms (để fade opacity hiển thị được), rồi mới thành none.
.toast {
opacity: 1;
transition:
opacity 300ms ease,
display 300ms allow-discrete; /* per-property keyword */
}
.toast.hidden {
opacity: 0;
display: none; /* now honored only after the fade */
}
Bạn cũng có thể đặt nó toàn cục cho rule thay vì từng thuộc tính.
.toast {
transition: opacity 300ms ease, display 300ms;
transition-behavior: allow-discrete;
}
The modern solution, part 2 — @starting-style for the enter transition
allow-discrete sửa được exit, nhưng enter lại có cái bẫy riêng. Khi phần tử chuyển từ display: none sang display: block, nó không có trạng thái “trước” để transition từ đó — nó vừa được render lần đầu trong frame này.
@starting-style định nghĩa các giá trị ban đầu trình duyệt nên dùng làm điểm xuất phát ở lần đầu phần tử được render.
.toast {
opacity: 1;
transition: opacity 300ms ease, display 300ms allow-discrete;
}
/* the value to animate FROM when first displayed */
@starting-style {
.toast {
opacity: 0;
}
}
Hãy đọc nó như ba trạng thái phối hợp với nhau. @starting-style là enter from, rule gốc là trạng thái hiển thị, và rule .hidden là exit to.
The modern solution, part 3 — interpolate-size & calc-size for height:auto
Giờ đến tính năng nổi bật: animate đến và đi từ các kích thước nội tại như auto, min-content, fit-content.
Bật bằng interpolate-size: allow-keywords. Thường đặt trên :root để toàn tài liệu có thể animate kích thước nội tại.
:root {
interpolate-size: allow-keywords;
}
.disclosure {
height: 0;
overflow: hidden;
transition: height 300ms ease;
}
.disclosure.open {
height: auto; /* now actually interpolates */
}
Bên dưới, engine dùng calc-size() để làm việc này. calc-size() giống calc() nhưng được phép thao tác trên một từ khóa nội tại, nắm bắt kích thước nội tại đã tính để dùng trong nội suy và phép tính.
.disclosure.open {
/* animate to intrinsic height PLUS some breathing room */
height: calc-size(auto, size + 1rem);
}
interpolate-size: allow-keywords là công tắc dễ dùng cho toàn tài liệu; calc-size() là công cụ tường minh, chính xác khi bạn cần tính toán với giá trị nội tại.
Real example 1 — a pure-CSS accordion / disclosure
Đây là một disclosure hoàn chỉnh animate height: auto với 0 JavaScript và 0 thủ thuật wrapper. Nó dựa vào phần tử <details> gốc cho trạng thái và khả năng truy cập.
<details class="disclosure">
<summary>What is allow-discrete?</summary>
<div class="disclosure__body">
<p>It lets discrete properties like display participate in a
transition instead of snapping at the start.</p>
</div>
</details>
:root {
interpolate-size: allow-keywords;
}
.disclosure__body {
height: 0;
overflow: hidden;
opacity: 0;
transition:
height 300ms ease,
opacity 300ms ease,
content-visibility 300ms allow-discrete;
/* content-visibility:hidden keeps it out of the a11y tree while closed */
content-visibility: hidden;
}
.disclosure[open] .disclosure__body {
height: auto;
opacity: 1;
content-visibility: visible;
}
/* enter-from values the first time the body is shown */
@starting-style {
.disclosure[open] .disclosure__body {
height: 0;
opacity: 0;
}
}
Kết quả: click vào summary mở panel mượt mà đến đúng chiều cao nội dung và thu lại, hoàn toàn truy cập được, không cần JS.
Real example 2 — a toast that mounts and unmounts
Toast là ví dụ kinh điển cho “animate display”: nó nên fade vào khi thêm và fade ra trước khi bị gỡ khỏi layout.
.toast {
opacity: 1;
transform: translateY(0);
transition:
opacity 250ms ease,
transform 250ms ease,
display 250ms allow-discrete;
}
/* exit state: faded, nudged down, then removed */
.toast[data-state='leaving'] {
opacity: 0;
transform: translateY(0.5rem);
display: none;
}
/* enter-from state for the first render */
@starting-style {
.toast {
opacity: 0;
transform: translateY(-0.5rem);
}
}
Phần JavaScript co lại gần như bằng không — nó chỉ bật/tắt một data attribute và gỡ node sau transition.
function dismissToast(toast) {
toast.dataset.state = 'leaving';
toast.addEventListener('transitionend', () => toast.remove(), { once: true });
}
Vì display được giữ đến cuối nhờ allow-discrete, hiệu ứng fade-out hiển thị trọn vẹn trước khi node biến mất.
Progressive enhancement & @supports fallbacks
Các tính năng này suy giảm êm ái: ở trình duyệt chưa hỗ trợ, phần tử vẫn xuất hiện và biến mất — chỉ là nhảy giật thay vì animate. Đó là baseline chấp nhận được.
Khi bạn muốn fallback khác (ví dụ thủ thuật grid 0fr → 1fr trên trình duyệt cũ), hãy đặt nhánh hiện đại sau @supports.
/* fallback: grid track trick, works broadly */
.disclosure__body {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 300ms ease;
}
.disclosure[open] .disclosure__body {
grid-template-rows: 1fr;
}
/* enhancement: real height:auto where supported */
@supports (interpolate-size: allow-keywords) {
:root { interpolate-size: allow-keywords; }
.disclosure__body {
display: block;
grid-template-rows: none;
height: 0;
overflow: hidden;
transition: height 300ms ease;
}
.disclosure[open] .disclosure__body {
height: auto;
}
}
Bạn có thể feature-detect allow-discrete tương tự bằng cách kiểm tra cú pháp thuộc tính.
@supports (transition-behavior: allow-discrete) {
.toast {
transition: opacity 250ms ease, display 250ms allow-discrete;
}
}
Cũng luôn tôn trọng tùy chọn về chuyển động.
@media (prefers-reduced-motion: reduce) {
.toast,
.disclosure__body {
transition-duration: 1ms; /* effectively instant, still functional */
}
}
Browser support & gotchas
Vài điều cần nhớ trong giai đoạn 2024+.
transition-behavior,@starting-style,interpolate-sizevàcalc-size()xuất hiện ở Chromium trước; Safari và Firefox hỗ trợ dần, nên luôn ship một baseline.interpolate-sizeđược kế thừa, nên đặt trên:rootlà có chủ đích và tiện lợi.- Hãy đưa các thuộc tính rời rạc (
display,content-visibility,overlay) vào danh sáchtransitionmột cách tường minh —transition: allsẽ không bắt chúng đáng tin cậy. - Với phần tử top-layer (
dialog, popover), cũng transition thuộc tínhoverlayvớiallow-discreteđể hiệu ứng exit hiển thị.
Summary
Những quy tắc “không thể animate cái đó” tồn tại cả thập kỷ cuối cùng cũng sụp đổ.
transition-behavior: allow-discretecho phépdisplay:nonevà các thuộc tính rời rạc animate bằng cách hoãn cú nhảy tới cuối.@starting-stylecung cấp giá trị “enter from” cho lần render đầu.interpolate-size: allow-keywordsvàcalc-size()khiếnheight: autovà các kích thước nội tại có thể nội suy.- Bọc nhánh hiện đại trong
@supportsvà giữ thủ thuật grid0fr → 1frlàm fallback êm ái.
Kỷ nguyên đo scrollHeight bằng JavaScript đang khép lại — và thật nhẹ nhõm.