You’ve written the same accordion three times. Click handler, el.scrollHeight, set max-height to that number, transition, then unset it on transitionend so the panel can grow with its content. About twenty-five lines of JavaScript and one re-layout to do what should have been a CSS problem.
You can stop.
Two CSS features quietly closed this gap — interpolate-size and calc-size() — and as of 2026 the browser support is finally good enough to ship. One line on :root and your height: 0 → height: auto transitions just work. No measurement, no transitionend cleanup, no extra render.
That’s the headline. The catch — and what to actually do about it — is the rest of this article.
Why height: auto Never Animated (and Why That Just Changed)
CSS animations interpolate between two computed values. The problem with auto was that it isn’t a computed length — it’s resolved later, at layout time, against whatever the content happens to need. So height: 0 → height: auto gave the engine nothing to interpolate against. The element snapped.
Every workaround was a guess at what auto would eventually resolve to. max-height: 9999px ran the animation against the wrong number, so short panels felt slow and long panels felt snappy and clipped. The scrollHeight measurement in JS gave you the right number but cost you a layout read and a brittle dance to clean up afterward. The CSS Grid 0fr → 1fr trick worked surprisingly well but needed a wrapper element whose only job was to exist.
interpolate-size: allow-keywords tells the engine: when one side of the transition is a keyword like auto, resolve it first, then interpolate. That’s the whole change.
It ships opt-in for one reason: older sites accidentally relied on the snap. Animating to auto on a hover or focus would have shifted layouts that were never designed to move. Backwards compatibility is why you have to ask for it — not because the spec is shy.
So how do you actually wire this into the components you ship?
The One Line That Turns It On
This is the entire feature:
:root {
interpolate-size: allow-keywords;
}
It inherits. Set it once at the root and every element on the page can transition between a length and an intrinsic size — auto, min-content, max-content, fit-content, content — on either height or width.
There is one limit worth knowing up front: you can’t animate between two intrinsic keywords. auto → min-content won’t work. One side has to be a concrete length like 0, 200px, or 10rem. In practice this almost never bites, because the natural use case is “closed to open,” and “closed” is always a length.
If you’re nervous about applying it document-wide, you can scope it. Drop it on a specific component root — say, your accordion wrapper — and only that subtree gets the new behaviour. Useful if a legacy area of the app relies on the old snap.
My take: put it on :root and move on. The whole point of this property is to delete code, not to gate the fix behind a wrapper class. If you discover an old hover that genuinely needs to snap, that’s one targeted interpolate-size: numeric-only override on the offending element. Far less work than scoping the fix everywhere it’s wanted.
Now the part you came for.
Three Components You Can Delete the JS From Today
Three patterns. For each one: the old hack, why it’s bad, and what replaces it. All three assume :root { interpolate-size: allow-keywords; } is already set.
Pattern 1: The Accordion (replaces the scrollHeight JS hack)
The old version. You’ve written this:
button.addEventListener('click', () => {
const panel = button.nextElementSibling;
if (panel.style.maxHeight) {
panel.style.maxHeight = null;
} else {
panel.style.maxHeight = panel.scrollHeight + 'px';
}
});
…paired with max-height: 0 and a transition on the panel. It works. It’s also a forced layout read on every click, a stale measurement the moment the content changes, and a transitionend listener you have to add if you ever want max-height: none so the panel grows naturally afterward.
The replacement uses native <details> and the ::details-content pseudo-element (Baseline since September 2025):
details::details-content {
block-size: 0;
overflow: clip;
transition:
block-size 0.3s ease,
content-visibility 0.3s allow-discrete;
}
details[open]::details-content {
block-size: auto;
}
Ten lines. No JS. Free keyboard handling. Exclusive accordions for free via <details name="faq">. overflow: clip instead of hidden because hidden creates a scroll container, which steals focus rings during the animation. allow-discrete on content-visibility keeps the content findable when collapsed — covered in more detail in the @starting-style guide.
You deleted twenty-five lines of JS, the layout read, the cleanup, and the accessibility you forgot to add. And if your accordion trigger still uses a click handler that could be a checkbox — CSS :has() deletes three more JavaScript patterns the same way. That’s a good Tuesday.
Pattern 2: The Collapsible Panel (replaces the max-height: 9999px hack)
The old version:
.panel { max-height: 0; transition: max-height 0.3s; overflow: hidden; }
.panel.is-open { max-height: 9999px; }
Timing is wrong by design. The engine interpolates from 0 to 9999px linearly, but the actual content might be 180px. A 300ms transition spends most of its time animating an invisible gap above the content. Short panels feel sluggish. Long panels finish early and the rest of the animation runs past the bottom — and if your content is taller than 9999px, congratulations, it’s clipped.
Now you can transition the real value:
.panel {
block-size: 0;
overflow: clip;
transition: block-size 0.25s ease;
}
.panel.is-open {
block-size: auto;
}
Eight lines. The engine resolves auto to the actual height first, then interpolates against the real number. Timing is honest at every content length. overflow: clip again — hidden is a scroll container, clip isn’t.
If you want a soft fade on the content as it appears, transition opacity on a child element. Don’t pile it on the panel itself; you want height and opacity to be independently tunable.
Pattern 3: The Slide-Down Menu (replaces the CSS Grid 0fr → 1fr trick)
The old version is clever, and that’s the problem:
.menu-wrapper { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 0.2s; }
.menu-wrapper.is-open { grid-template-rows: 1fr; }
.menu { min-height: 0; overflow: hidden; }
It works. It needs an extra wrapper element whose only job is to be a 1-row grid. And if the menu starts at display: none (for the keyboard/screen-reader story), you still don’t get an entry animation on first appearance.
The full solution combines three features:
.menu {
display: none;
block-size: auto;
transition:
block-size 0.2s ease,
display 0.2s allow-discrete;
}
.menu.is-open {
display: block;
}
@starting-style {
.menu.is-open {
block-size: 0;
}
}
Three pieces, each doing one job. interpolate-size (from :root) handles the height transition. transition-behavior: allow-discrete — applied here via the shorthand on display — makes display: none → block a transitionable change instead of an instant flip. @starting-style gives the engine a “before” state for the first paint, so the entry animates from block-size: 0 rather than starting at the final auto value.
No wrapper. The menu element holds its own state.
That’s three components, three deletes. But every honest article has to answer the next question: what about everywhere your code already runs?
When You Need calc-size() Instead
interpolate-size is a global switch. calc-size() is a per-element arithmetic function. Different jobs, same neighbourhood.
Reach for calc-size() when you need math against an intrinsic size:
.panel.is-open {
block-size: calc-size(auto, size + 2rem);
}
That’s “whatever auto resolves to, plus 32 pixels of breathing room” — something regular calc() can’t express because auto isn’t a length it can do arithmetic on. Useful for animations that need a deliberate visual gap below the content, or for peek-style reveals:
.teaser {
block-size: calc-size(auto, size * 0.3);
}
.teaser.is-open {
block-size: auto;
}
The teaser shows the top 30% of the content as a hint, then expands to the full intrinsic height on click. With interpolate-size on at the root, both states are interpolable.
Decision rule: 99% of the time you want interpolate-size on :root and you’re done. Add calc-size() only when the design calls for arithmetic against an intrinsic size. The two compose — they don’t compete.
That covers what to write. The honest question is whether you can ship it everywhere.
Browser Support and the Progressive Enhancement You Actually Need
As of May 2026: Chrome 129+, Edge 129+, Firefox 131+, Safari 18.2+. Roughly 68% of global traffic. Cross-check caniuse before you ship — sources disagree on Safari’s exact state for height specifically, and the situation is still moving.
The fallback behaviour without the feature is the same snap your code does today. The functionality still works. You lose the polish, not the function — which for most apps is an acceptable floor.
If snapping isn’t acceptable for your audience, gate the new CSS with @supports:
@supports (interpolate-size: allow-keywords) {
:root { interpolate-size: allow-keywords; }
/* new patterns here */
}
And keep the CSS Grid 0fr → 1fr trick in the default block for unsupported browsers. Don’t bring back the scrollHeight JS as a fallback. The Grid trick is the right floor — pure CSS, works everywhere, degrades quietly. Ship JS only when it’s earning its bundle weight.
While you’re in there, respect prefers-reduced-motion. Shorten the duration to 0.01s or drop the transition entirely. Reasonable defaults are covered in the @starting-style guide and apply here unchanged.
The Bottom Line
That scrollHeight hack you’ve shipped three times? Delete it.
Add interpolate-size: allow-keywords to :root. Replace your accordion with <details> and ::details-content. Replace your max-height: 9999px panel with a real block-size transition. Replace your CSS Grid trick with display, allow-discrete, and @starting-style. Gate with @supports if your audience skews older Safari.
What changed in 2026 isn’t that this is finally possible — calc-size() has been around in Chrome since 2024. What changed is that the fallback behaviour (snap instead of animate) is now acceptable for most production apps, because most production apps already snap on browsers older than two years. That’s what makes this the year to actually delete the code.
Pick one component you’ve already shipped the JS hack into. Replace it this afternoon. Watch the bundle shrink. For scroll-linked motion — progress bars, parallax, reveal-on-scroll — CSS scroll-driven animations replace your GSAP scroll triggers the same way. Then do the next one.