jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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.

For over a decade, two of the most-wanted CSS animations were simply impossible. {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.} You could not transition display, and you could not transition height: auto. {Bạn không thể transition display, và cũng không thể transition height: auto.} In 2024+, Chromium and (progressively) other engines closed that gap with a small family of features. {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ỏ.}

This post walks through why it was impossible, the hacks we used to fake it, and the modern primitives that finally make it real. {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.}

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

Why display and height:auto were never animatable

CSS transitions interpolate between two values over time. {CSS transition nội suy giữa hai giá trị theo thời gian.} That only works when there are intermediate values to compute. {Điều đó chỉ hoạt động khi tồn tại các giá trị trung gian để tính toán.}

display is a discrete property — its values are keywords like none, block, flex. {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.} There is no “halfway between none and block”, so the browser historically just jumped at the end of the transition. {Không có “nửa đường giữa noneblock”, nên trình duyệt trước đây chỉ nhảy thẳng ở cuối transition.}

height: auto is a different problem. {height: auto lại là vấn đề khác.} auto is not a length — it is a keyword that resolves to an intrinsic size computed during layout. {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.} The engine refused to interpolate from 0px to a keyword whose pixel value it only knows after 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.}

So we faked both. {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

The classic way to “animate height” was to transition max-height between 0 and a number larger than the content. {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 */
}

The problems are well known. {Các vấn đề thì ai cũng biết.} You must guess a magic number; if content is taller it gets clipped, if shorter the timing feels wrong because the easing runs over phantom space. {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

To avoid the magic number, we reached for JavaScript and read scrollHeight. {Để 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'; });
}

This works but it is fragile. {Cách này chạy được nhưng mong manh.} You fight layout thrashing, requestAnimationFrame double-frames, and transitionend not firing if the transition is interrupted. {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

The most elegant pure-CSS hack used a grid track animating from 0fr to 1fr. {Thủ thuật CSS thuần thanh lịch nhất dùng một grid track chạy từ 0fr đến 1fr.} fr units are interpolable, so this animates to intrinsic height with no JS and no magic number. {Đơ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 */
}

This was the best option for years. {Đây là lựa chọn tốt nhất trong nhiều năm.} Its only real downsides: it needs an extra wrapper element, and display itself still snaps (you cannot fade an element out and remove it from the box tree). {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 gỡ nó khỏi box tree).}

The modern solution, part 1 — transition-behavior: allow-discrete

The first new primitive is transition-behavior: allow-discrete. {Primitive mới đầu tiên là transition-behavior: allow-discrete.} It tells the browser: for discrete properties listed in this transition, do not snap at the start — keep the old value until the very end of the transition, then flip. {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ị đến tận cuối transition rồi mới lật.}

That single rule is what lets display: none participate. {Chính quy tắc đó cho phép display: none tham gia.} When hiding, display stays block for the whole 300ms (so your opacity fade is visible), then becomes none. {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 */
}

You can also set it globally for the rule rather than per property. {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 fixes the exit, but the enter has its own trap. {allow-discrete sửa được exit, nhưng enter lại có cái bẫy riêng.} When an element switches from display: none to display: block, it has no “before” state to transition from — it is being rendered for the first time this frame. {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 defines the initial values the browser should use as the starting point the first time the element is rendered. {@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;
  }
}

Read it as three states working together. {Hãy đọc nó như ba trạng thái phối hợp với nhau.} @starting-style is the enter from, the base rule is the visible state, and the .hidden rule is the exit to. {@starting-styleenter from, rule gốc là trạng thái hiển thị, và rule .hiddenexit to.}

The modern solution, part 3 — interpolate-size & calc-size for height:auto

Now the headline feature: animating to and from intrinsic sizes like auto, min-content, fit-content. {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.}

Opt in with interpolate-size: allow-keywords. {Bật bằng interpolate-size: allow-keywords.} It is usually set on :root so the whole document can animate intrinsic sizes. {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 */
}

Under the hood the engine uses calc-size() to make this work. {Bên dưới, engine dùng calc-size() để làm việc này.} calc-size() is like calc() but it is allowed to operate on a single intrinsic keyword, capturing the computed intrinsic size so it can be used in interpolation and arithmetic. {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 is the easy, document-wide switch; calc-size() is the explicit, surgical tool when you need to do math with the intrinsic value. {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

Here is a complete disclosure that animates height: auto with zero JavaScript and zero wrapper hacks. {Đây là một disclosure hoàn chỉnh animate height: auto với 0 JavaScript và 0 thủ thuật wrapper.} It leans on the native <details> element for state and accessibility. {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;
  }
}

The result: clicking the summary smoothly expands the panel to its true content height and collapses it back, fully accessible, no JS. {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

A toast is the canonical “animate display” case: it should fade in when added and fade out before being removed from the layout. {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);
  }
}

The JavaScript shrinks to almost nothing — it only toggles a data attribute and removes the node after the transition. {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 });
}

Because display is held until the end thanks to allow-discrete, the fade-out is fully visible before the node disappears. {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

These features degrade gracefully: in an unsupporting browser, the element still appears and disappears — it just snaps instead of animating. {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.} That is an acceptable baseline. {Đó là baseline chấp nhận được.}

When you want a different fallback (for example the grid 0fr → 1fr trick on old browsers), gate the modern path behind @supports. {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;
  }
}

You can feature-detect allow-discrete similarly by testing the property syntax. {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;
  }
}

Always respect motion preferences too. {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

A few things to keep in mind in 2024+. {Vài điều cần nhớ trong giai đoạn 2024+.}

  • transition-behavior, @starting-style, interpolate-size and calc-size() landed in Chromium first; Safari and Firefox support is arriving progressively, so always ship a baseline. {transition-behavior, @starting-style, interpolate-sizecalc-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 is inherited, so setting it on :root is intentional and convenient. {interpolate-size được kế thừa, nên đặt trên :root là có chủ đích và tiện lợi.}
  • Put discrete properties (display, content-visibility, overlay) explicitly in your transition list — transition: all will not pick them up reliably. {Hãy đưa các thuộc tính rời rạc (display, content-visibility, overlay) vào danh sách transition một cách tường minh — transition: all sẽ không bắt chúng đáng tin cậy.}
  • For top-layer elements (dialog, popover), also transition the overlay property with allow-discrete so the exit animation is visible. {Với phần tử top-layer (dialog, popover), cũng transition thuộc tính overlay với allow-discrete để hiệu ứng exit hiển thị.}

Summary

The decade-old “you can’t animate that” rules are finally falling. {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-discrete lets display:none and other discrete properties animate by deferring the snap to the end. {transition-behavior: allow-discrete cho phép display:none và 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-style supplies the “enter from” values for first render. {@starting-style cung cấp giá trị “enter from” cho lần render đầu.}
  • interpolate-size: allow-keywords and calc-size() make height: auto and other intrinsic sizes interpolable. {interpolate-size: allow-keywordscalc-size() khiến height: auto và các kích thước nội tại có thể nội suy.}
  • Wrap the modern path in @supports and keep the grid 0fr → 1fr trick as a graceful fallback. {Bọc nhánh hiện đại trong @supports và giữ thủ thuật grid 0fr → 1fr làm fallback êm ái.}

The era of measuring scrollHeight in JavaScript is ending — and good riddance. {Kỷ nguyên đo scrollHeight bằng JavaScript đang khép lại — và thật nhẹ nhõm.}