For years CSS could not transition to height: auto or out of display: none. The usual workarounds were a guessed max-height, or JavaScript that measured the element and wrote a pixel height. Modern CSS now covers both cases: interpolate-size and calc-size() let height animate to intrinsic keywords, @starting-style gives elements a value to animate in from, and transition-behavior: allow-discrete keeps display and overlay alive until an exit animation ends.
Support is uneven. @starting-style and transition-behavior are Baseline 2024 and ::details-content is Baseline 2025, but interpolate-size and calc-size() only ship in Chromium so far. That is why every demo here has a fallback, including the grid-template-rows 0fr to 1fr technique, which animates in every current browser.
The eight demos below cover an accordion, a side-by-side timing comparison with the old max-height hack, a show more expander, an animated details element, a dialog and popover, toast notifications, and two @property examples: a spinning gradient border and a number counter. Each one is a single HTML file you can copy.
Accordion that animates to height: auto
interpolate-size: allow-keywords is an inherited property that lets the browser interpolate between a length and an intrinsic sizing keyword such as auto, min-content or fit-content. Set it once on :root and height: 0 to height: auto becomes a normal transition. It is opt-in because switching it on everywhere could start animations on existing sites that never expected them.
Each panel here goes from height: 0 to height: auto, with overflow: clip so content does not spill out mid-animation. visibility is transitioned alongside height: it stays visible for the whole closing transition and then flips to hidden, which takes the collapsed answer out of the tab order and the accessibility tree.
In Firefox and Safari, which do not support interpolate-size yet, an @supports not block swaps in the grid-template-rows technique from the next demo, so the accordion still animates. A few lines of JavaScript toggle aria-expanded on the button and a data-open attribute on the item.
Show the code
<div class="acc">
<div class="acc-item" data-open>
<h3><button class="acc-trigger" id="acc-b1" aria-expanded="true" aria-controls="acc-p1">How long does delivery take?<span class="acc-icon" aria-hidden="true"></span></button></h3>
<div class="acc-panel" id="acc-p1" role="region" aria-labelledby="acc-b1">
<div class="acc-inner"><div class="acc-body"><p>Orders placed before 2 pm ship the same day. Standard delivery takes 2–4 working days inside the EU; express arrives the next working day.</p></div></div>
</div>
</div>
<div class="acc-item">
<h3><button class="acc-trigger" id="acc-b2" aria-expanded="false" aria-controls="acc-p2">Can I return an opened item?<span class="acc-icon" aria-hidden="true"></span></button></h3>
<div class="acc-panel" id="acc-p2" role="region" aria-labelledby="acc-b2">
<div class="acc-inner"><div class="acc-body"><p>Yes. You have 30 days to return anything, opened or not. Print the prepaid label from your order page and drop the parcel at any pickup point. Refunds land within 5 working days of the item reaching us.</p></div></div>
</div>
</div>
<div class="acc-item">
<h3><button class="acc-trigger" id="acc-b3" aria-expanded="false" aria-controls="acc-p3">Do you ship outside Europe?<span class="acc-icon" aria-hidden="true"></span></button></h3>
<div class="acc-panel" id="acc-p3" role="region" aria-labelledby="acc-b3">
<div class="acc-inner"><div class="acc-body"><p>We ship to the UK, Norway, Switzerland, the US and Canada. Import duties for non-EU countries are calculated at checkout, so there is nothing extra to pay on the doorstep.</p></div></div>
</div>
</div>
</div>/* 1. Opt in once: lets height/width interpolate to keywords like auto */
:root{interpolate-size:allow-keywords}
.acc{width:100%;max-width:620px;background:#fff;border:1px solid #e3e8f0;border-radius:16px;overflow:hidden;
box-shadow:0 16px 38px -24px rgba(20,30,55,.25)}
.acc-item+.acc-item{border-top:1px solid #edf0f5}
.acc-item h3{font-size:1rem;margin:0}
.acc-trigger{all:unset;box-sizing:border-box;display:flex;align-items:center;justify-content:space-between;gap:16px;width:100%;
padding:16px 20px;font-weight:650;cursor:pointer;color:#1b2433}
.acc-trigger:hover{background:#f7f9fc}
.acc-trigger:focus-visible{outline:3px solid #2563eb;outline-offset:-3px}
.acc-icon{flex:none;width:12px;height:12px;border-right:2px solid #475569;border-bottom:2px solid #475569;
transform:translateY(-3px) rotate(45deg);transition:transform .3s ease}
.acc-item[data-open] .acc-icon{transform:translateY(2px) rotate(225deg)}
/* 2. The panel: height 0 -> auto, visibility keeps closed content out of the tab order */
.acc-panel{height:0;overflow:clip;visibility:hidden;transition:height .35s ease,visibility .35s}
.acc-item[data-open] .acc-panel{height:auto;visibility:visible}
.acc-body{padding:0 20px 18px;color:#3b4658;font-size:.92rem}
.acc-body a{color:#1d4ed8}
/* 3. Fallback for Firefox and Safari: the grid-template-rows 0fr -> 1fr technique */
@supports not (interpolate-size: allow-keywords){
.acc-panel{height:auto;display:grid;grid-template-rows:0fr;transition:grid-template-rows .35s ease,visibility .35s}
.acc-item[data-open] .acc-panel{grid-template-rows:1fr}
.acc-inner{min-height:0;overflow:hidden} /* padding lives on .acc-body inside, so 0fr really is 0 */
}
@media (prefers-reduced-motion:reduce){
.acc-panel,.acc-icon{transition:none}
}document.querySelectorAll('.acc-trigger').forEach((btn) => {
btn.addEventListener('click', () => {
const item = btn.closest('.acc-item');
const open = !item.hasAttribute('data-open');
item.toggleAttribute('data-open', open);
btn.setAttribute('aria-expanded', String(open));
});
});Why the max-height hack feels slow: a side-by-side comparison
The old trick transitions max-height from 0 to a value comfortably larger than the content, such as 40rem. The transition runs over the full 40rem, not over the real height. Opening, the panel reaches its full size early and the rest of the duration does nothing. Closing, max-height first has to shrink through all the empty space above the content before anything visibly moves.
Press Close all three and watch. All three columns use the same 0.8s ease. In our measurements in Chromium, the max-height column held still for roughly the first 450ms of the close, while the grid-template-rows and interpolate-size columns started moving at once.
The grid version wraps the content in a single-row grid and transitions grid-template-rows from 0fr to 1fr. The child needs min-height: 0 and overflow: hidden, otherwise its minimum content size keeps the row open. Animating grid tracks works in Chrome 107+, Firefox 66+ and Safari 16+, which makes it the safest technique today.
Show the code
<div class="cols is-open">
<section class="col bad" aria-labelledby="h-a">
<h2 id="h-a">A. max-height</h2><code>0 → 40rem</code>
<div class="mh" id="cmp-a">
<ul class="lines"><li><span>Espresso beans 1 kg</span><span>€24.00</span></li><li><span>Paper filters ×100</span><span>€4.50</span></li><li><span>Shipping</span><span>€3.90</span></li><li><strong>Total</strong><strong>€32.40</strong></li></ul>
</div>
</section>
<section class="col" aria-labelledby="h-b">
<h2 id="h-b">B. grid-template-rows</h2><code>0fr → 1fr</code>
<div class="gr" id="cmp-b"><div>
<ul class="lines"><li><span>Espresso beans 1 kg</span><span>€24.00</span></li><li><span>Paper filters ×100</span><span>€4.50</span></li><li><span>Shipping</span><span>€3.90</span></li><li><strong>Total</strong><strong>€32.40</strong></li></ul>
</div></div>
</section>
<section class="col" aria-labelledby="h-c">
<h2 id="h-c">C. interpolate-size</h2><code>height: 0 → auto</code>
<div class="ia" id="cmp-c">
<ul class="lines"><li><span>Espresso beans 1 kg</span><span>€24.00</span></li><li><span>Paper filters ×100</span><span>€4.50</span></li><li><span>Shipping</span><span>€3.90</span></li><li><strong>Total</strong><strong>€32.40</strong></li></ul>
</div>
</section>
</div>
/* All three use the same 0.8s ease, so any difference you see is the technique */
/* A. The old hack: guess a max-height bigger than the content */
.mh{max-height:0;overflow:hidden;transition:max-height .8s ease,visibility .8s}
.is-open .mh{max-height:40rem} /* content is ~9rem, so most of the 0.8s is spent "animating" empty space */
/* B. Grid rows: animate the track from 0fr to 1fr (works in every current browser) */
.gr{display:grid;grid-template-rows:0fr;transition:grid-template-rows .8s ease,visibility .8s}
.gr>*{min-height:0;overflow:hidden}
.is-open .gr{grid-template-rows:1fr}
/* C. Real height: auto (Chromium 129+) */
.ia{interpolate-size:allow-keywords;height:0;overflow:clip;transition:height .8s ease,visibility .8s}
.is-open .ia{height:auto}
/* Closed panels are hidden from screen readers too; visibility flips after the close transition */
.mh,.gr,.ia{visibility:hidden}
.is-open :is(.mh,.gr,.ia){visibility:visible}
@media (prefers-reduced-motion:reduce){
.mh,.gr,.ia{transition-duration:.01s}
}const btn = document.querySelector('.toggle-all');
const cols = document.querySelector('.cols');
btn.addEventListener('click', () => {
const open = cols.classList.toggle('is-open');
btn.setAttribute('aria-expanded', String(open));
btn.textContent = open ? 'Close all three' : 'Open all three';
});Show more text with calc-size()
calc-size() does arithmetic on an intrinsic size. Its first argument is the basis (auto, min-content, max-content or fit-content) and the second is a calculation where the keyword size stands for that basis. Using calc-size() in a value also makes it interpolable, the same way interpolate-size does.
The collapsed review uses height: calc-size(auto, min(size, 7.5rem)): the real content height, capped at 7.5rem. A long review stops at 7.5rem and animates open to height: auto. A short review is never padded out to 7.5rem, so the script can measure it and hide the Read full review button when nothing is clipped.
A plain height: 7.5rem comes first as a fallback for browsers that do not parse calc-size(). Inside @supports not, the fallback switches to max-height: 7.5rem so short reviews still fit their content; the expander then opens without animation. calc-size() cannot interpolate between two different keywords, for example min-content to max-content.
Show the code
<div class="reviews">
<article class="review more">
<h2>Finally a grinder that stays consistent</h2>
<p class="meta"><span class="stars" aria-hidden="true">★★★★★</span> 5 out of 5 · Marta K., Riga</p>
<div class="more-text" id="rv1">
<p>I switched from a blade grinder and the difference in the cup was immediate. Pour-over finally tastes sweet instead of bitter, and the grind size does not drift after a week like my last burr grinder did.</p>
<p>The dial has 40 clicks, which is plenty for filter and French press. It is not fine enough for espresso, so if that matters to you, look at the Pro model. Cleaning takes two minutes with the included brush, and the hopper lid seals properly.</p>
<p>Only complaint: the cable is short, so it has to live right next to the socket.</p>
</div>
<button type="button" class="more-btn" aria-expanded="false" aria-controls="rv1">Read full review</button>
</article>
<article class="review more">
<h2>Quiet and quick</h2>
<p class="meta"><span class="stars" aria-hidden="true">★★★★☆</span> 4 out of 5 · Jonas P., Tallinn</p>
<div class="more-text" id="rv2">
<p>Grinds 20 g in about 15 seconds and is quiet enough to use before the rest of the house is awake.</p>
</div>
<button type="button" class="more-btn" aria-expanded="false" aria-controls="rv2">Read full review</button>
</article>
</div>
/* Collapsed height = the smaller of the real content height and 7.5rem.
Short reviews are never clipped, long ones stop at 7.5rem. */
.more-text{
height:7.5rem; /* fallback value, overridden where calc-size() works */
height:calc-size(auto, min(size, 7.5rem));
overflow:clip;
transition:height .45s ease;
mask-image:linear-gradient(#000 55%, transparent);
}
.more[data-open] .more-text{height:auto;mask-image:none}
.more-text p{font-size:.9rem;color:#3b4658}
.more-text p+p{margin-top:.6em}
.more-btn{margin-top:8px;font:inherit;font-size:.85rem;font-weight:650;color:#1d4ed8;background:none;border:0;padding:4px 2px;
cursor:pointer;border-radius:4px;text-decoration:underline;text-underline-offset:3px}
.more-btn:focus-visible{outline:3px solid #2563eb;outline-offset:2px}
.more.fits .more-text{mask-image:none}
.more.fits .more-btn{display:none}
/* Without calc-size(): cap with max-height instead, so short text is not padded out (opens without animation) */
@supports not (height: calc-size(auto, size)){
.more-text{height:auto;max-height:7.5rem}
.more[data-open] .more-text{max-height:none}
}
@media (prefers-reduced-motion:reduce){
.more-text{transition:none}
}document.querySelectorAll('.more').forEach((card) => {
const text = card.querySelector('.more-text');
const btn = card.querySelector('.more-btn');
// Hide the button when the text already fits in the collapsed height
if (text.scrollHeight <= text.clientHeight + 1) { card.classList.add('fits'); return; }
btn.addEventListener('click', () => {
const open = card.toggleAttribute('data-open');
btn.setAttribute('aria-expanded', String(open));
btn.textContent = open ? 'Show less' : 'Read full review';
});
});Animating the details element open and close with ::details-content
::details-content selects the part of a details element that expands and collapses, everything except the summary. When the element is closed the browser hides that part with content-visibility: hidden, so an exit animation needs content-visibility in the transition list with allow-discrete. That keeps the content visible until the closing transition has finished.
In Chromium the content box goes from block-size: 0 to block-size: auto, with interpolate-size set on details so the pseudo-element inherits it. Safari 18.4+ and Firefox 143+ support ::details-content but not interpolate-size, so a second @supports block keeps the height at auto there and the answer fades instead of sliding.
The three items share name="workshop-faq", which makes them an exclusive accordion: opening one closes the others, with no JavaScript. The name attribute works in Chrome 120+, Firefox 130+ and Safari 17.2+. Browsers without ::details-content open the answers instantly, and the element stays fully keyboard and screen reader accessible.
Show the code
<div class="faq">
<details name="workshop-faq" open>
<summary>What should I bring to the pottery workshop?</summary>
<div class="answer"><p>Just clothes you don't mind getting clay on. Aprons, tools and 2 kg of stoneware clay per person are included.</p></div>
</details>
<details name="workshop-faq">
<summary>When can I collect my pieces?</summary>
<div class="answer"><p>Pieces are bisque fired, glazed and fired again, which takes about three weeks. We email you when they are ready for pickup.</p></div>
</details>
<details name="workshop-faq">
<summary>Can I move my booking to another date?</summary>
<div class="answer"><p>Yes, free of charge up to 48 hours before the session. Inside 48 hours we can still move you if someone on the waiting list takes your seat.</p></div>
</details>
</div>.faq{width:100%;max-width:620px;display:flex;flex-direction:column;gap:8px}
.faq details{background:#fff;border:1px solid #e3e8f0;border-radius:12px}
.faq summary{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:14px 18px;font-weight:650;cursor:pointer;
list-style:none;border-radius:12px}
.faq summary::-webkit-details-marker{display:none}
.faq summary::after{content:"";flex:none;width:10px;height:10px;border-right:2px solid #475569;border-bottom:2px solid #475569;
transform:translateY(-3px) rotate(45deg);transition:transform .3s ease}
.faq details[open] summary::after{transform:translateY(2px) rotate(225deg)}
.faq summary:focus-visible{outline:3px solid #2563eb;outline-offset:2px}
.faq .answer{padding:0 18px 16px;color:#3b4658;font-size:.92rem}
/* Tier 1 (Chromium 131+): slide the content box from 0 to its real height */
@supports selector(::details-content){
.faq details{interpolate-size:allow-keywords} /* inherited by ::details-content */
.faq details::details-content{
opacity:0;
block-size:0;
overflow-y:clip;
transition:block-size .4s ease, opacity .4s ease, content-visibility .4s allow-discrete;
}
.faq details[open]::details-content{opacity:1;block-size:auto}
}
/* Tier 2 (Safari 18.4+, Firefox 143+): no interpolate-size yet, so fade only */
@supports selector(::details-content) and (not (interpolate-size: allow-keywords)){
.faq details::details-content{block-size:auto}
}
@media (prefers-reduced-motion:reduce){
.faq details::details-content,.faq summary::after{transition:none}
}Dialog and popover entry and exit with @starting-style
A transition needs a before value. When an element first renders or switches from display: none, it has none, so normally nothing animates. @starting-style supplies that before value. Here the open dialog starts from opacity: 0 and scale: 0.94 and transitions to opacity: 1 and scale: 1. Place the @starting-style block after the rule it overrides: it has the same specificity, so order decides.
For the exit, display and overlay are listed in the transition with allow-discrete. display stays block until the fade ends instead of flipping to none at once, and overlay keeps the dialog in the top layer until then. The ::backdrop gets its own transition and its own standalone @starting-style block, because @starting-style cannot be nested inside a pseudo-element rule.
A plain transition declaration comes first. Browsers that do not recognise allow-discrete drop the whole second declaration and fall back to the first. overlay is Chromium-only, so in Safari the dialog can leave the top layer before the fade finishes. Firefox does not transition display, so dialogs animate in but close instantly. The popover uses the same pattern with :popover-open and needs no JavaScript at all.
Show the code
<div class="card">
<h1>Pro plan · renews 1 November</h1>
<p>Travelling for a while? Pause instead of cancelling and keep your saved routes.</p>
<div class="row">
<button type="button" class="btn primary" id="open-dlg">Pause membership</button>
<button type="button" class="btn" popovertarget="pause-info">What happens to my data?</button>
</div>
<p class="status" id="status" role="status"></p>
</div>
<dialog class="dlg" id="pause-dlg" aria-labelledby="dlg-title">
<h2 id="dlg-title">Pause for one month?</h2>
<p>You won't be charged on 1 November. Your routes, stats and friends stay exactly where they are, and billing restarts on 1 December.</p>
<form method="dialog">
<button class="btn" value="keep">Keep membership</button>
<button class="btn primary" value="pause">Pause until 1 December</button>
</form>
</dialog>
<div class="pop" id="pause-info" popover>
<strong>While paused</strong>
<ul><li>Saved routes and history are kept</li><li>Offline maps stop syncing</li><li>You can resume any time</li></ul>
</div>/* ---------- Modal dialog ---------- */
.dlg{
width:min(420px, calc(100% - 32px));margin:auto;border:0;border-radius:16px;padding:22px;color:#1b2433;
box-shadow:0 24px 60px -20px rgba(15,23,42,.45);
opacity:0;scale:.94;
transition:opacity .25s ease, scale .25s ease; /* fallback for browsers without allow-discrete */
transition:opacity .25s ease, scale .25s ease,
overlay .25s ease allow-discrete, /* keep it in the top layer while it fades out */
display .25s ease allow-discrete; /* keep display:block until the exit finishes */
}
.dlg[open]{opacity:1;scale:1}
@starting-style{ .dlg[open]{opacity:0;scale:.94} } /* where the entry animation starts from */
.dlg::backdrop{
background-color:rgb(15 23 42 / 0);
transition:background-color .25s ease, overlay .25s ease allow-discrete, display .25s ease allow-discrete;
}
.dlg[open]::backdrop{background-color:rgb(15 23 42 / .55)}
@starting-style{ .dlg[open]::backdrop{background-color:rgb(15 23 42 / 0)} } /* pseudo-elements: standalone block */
.dlg h2{font-size:1.05rem;margin-bottom:6px}
.dlg p{font-size:.9rem;color:#475569}
.dlg form{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:10px;margin-top:18px}
/* ---------- Popover ---------- */
.pop{
inset:auto;top:24px;left:50%;margin:0;transform:translateX(-50%);
width:min(360px, calc(100% - 32px));border:1px solid #e3e8f0;border-radius:12px;padding:14px 16px;
background:#fff;color:#1b2433;font-size:.88rem;box-shadow:0 18px 40px -18px rgba(15,23,42,.4);
opacity:0;translate:0 -8px;
transition:opacity .2s ease, translate .2s ease;
transition:opacity .2s ease, translate .2s ease, overlay .2s allow-discrete, display .2s allow-discrete;
}
.pop:popover-open{opacity:1;translate:0 0}
@starting-style{ .pop:popover-open{opacity:0;translate:0 -8px} }
.pop ul{padding-left:18px;color:#3b4658}
@media (prefers-reduced-motion:reduce){
.dlg,.pop{scale:1;translate:0 0}
@starting-style{ .dlg[open]{scale:1} .pop:popover-open{translate:0 0} }
}const dlg = document.getElementById('pause-dlg');
document.getElementById('open-dlg').addEventListener('click', () => dlg.showModal());
dlg.addEventListener('close', () => {
document.getElementById('status').textContent =
dlg.returnValue === 'pause' ? 'Membership paused until 1 December.' : '';
});Toast notifications that animate in and out of display: none
@starting-style also applies when an element is inserted into the DOM, so a new toast can fade and slide in without a JavaScript animation library or a requestAnimationFrame trick. The same rule works when you toggle an existing element from display: none to display: flex.
To dismiss, the script adds an is-leaving class that sets display: none, opacity: 0 and a small offset. Because display is transitioned with allow-discrete, the toast stays rendered while it fades, then the script removes it after 350ms. A timer is used instead of transitionend because browsers that do not transition display never fire that event.
The container is a polite live region (role="status"), so screen readers announce each new message. Toasts close on their own after six seconds, unless the pointer moves over one or keyboard focus enters it, which keeps it open until dismissed. Dismissing a focused toast moves focus back to the Save draft button instead of dropping it on the body.
Show the code
<div class="doc">
<h1>Quarterly report draft</h1>
<p>Last edited by Ilze, 4 minutes ago</p>
<div class="row">
<button type="button" class="btn primary" data-toast="Draft saved. All changes are synced.">Save draft</button>
<button type="button" class="btn" data-toast="Link copied to the clipboard.">Copy share link</button>
</div>
</div>
<div class="toasts" role="status" aria-live="polite"></div>
<template id="toast-tpl">
<div class="toast"><p></p><button type="button">Dismiss</button></div>
</template>
.toasts{position:fixed;right:16px;bottom:16px;left:16px;display:flex;flex-direction:column;align-items:flex-end;gap:8px;pointer-events:none}
.toast{
display:flex;align-items:center;gap:12px;max-width:360px;width:100%;pointer-events:auto;
background:#111827;color:#f9fafb;border-radius:12px;padding:12px 12px 12px 16px;font-size:.9rem;
box-shadow:0 14px 30px -14px rgba(0,0,0,.55);
opacity:1;translate:0 0;
transition:opacity .3s ease, translate .3s ease; /* fallback */
transition:opacity .3s ease, translate .3s ease, display .3s allow-discrete;
}
/* Entry: the first frame after insertion (or after display:none -> flex) starts from here */
@starting-style{ .toast{opacity:0;translate:0 16px} }
/* Exit: display:none is held back until the fade has finished */
.toast.is-leaving{display:none;opacity:0;translate:0 8px}
.toast p{flex:1}
.toast button{flex:none;font:inherit;font-size:.8rem;font-weight:700;color:#bfdbfe;background:none;border:1px solid #4b5563;
border-radius:8px;padding:5px 9px;cursor:pointer}
.toast button:hover{background:#1f2937}
.toast button:focus-visible{outline:2px solid #bfdbfe;outline-offset:2px}
@media (prefers-reduced-motion:reduce){
.toast{translate:0 0}
@starting-style{ .toast{translate:0 0} }
.toast.is-leaving{translate:0 0}
}const region = document.querySelector('.toasts');
const tpl = document.getElementById('toast-tpl');
function dismiss(toast) {
if (toast.contains(document.activeElement)) document.querySelector('[data-toast]').focus();
toast.classList.add('is-leaving');
setTimeout(() => toast.remove(), 350); // just after the 0.3s exit transition
}
function showToast(message) {
const toast = tpl.content.firstElementChild.cloneNode(true);
toast.querySelector('p').textContent = message;
toast.querySelector('button').addEventListener('click', () => dismiss(toast));
region.append(toast); // @starting-style animates it in
let timer = setTimeout(() => dismiss(toast), 6000);
toast.addEventListener('mouseenter', () => clearTimeout(timer));
toast.addEventListener('focusin', () => clearTimeout(timer));
}
document.querySelectorAll('[data-toast]').forEach((btn) =>
btn.addEventListener('click', () => showToast(btn.dataset.toast)));Animated gradient border with @property
Custom properties are untyped strings by default, so the browser cannot interpolate them: a transition just flips from one value to the other. @property registers a property with a syntax, an inherits flag and an initial value. Once --angle is registered with the angle syntax, a keyframe can take it from 0deg to 360deg.
The border is two background layers: a white linear-gradient clipped to padding-box on top, and a conic-gradient(from var(--angle)) on the border-box underneath, shown through a transparent 3px border. The button registers --from and --to with the color syntax and transitions them on hover and focus, which is not possible with a gradient background on its own.
The border spins forever, so there is a pause button (WCAG 2.2.2 asks for a way to stop motion that lasts more than five seconds), and prefers-reduced-motion turns the animation off. var(--angle, 0deg) keeps a valid gradient in browsers without @property; they see a static gradient border.
Show the code
<article class="plan">
<span class="tag">Most popular</span>
<h1>Studio</h1>
<p class="price">€18 <span>per editor / month</span></p>
<ul>
<li>Unlimited projects and brand kits</li>
<li>4K export with no watermark</li>
<li>Shared review links with comments</li>
</ul>
<div class="actions">
<button type="button" class="cta">Start 14-day trial</button>
<button type="button" class="pause" aria-pressed="false">Pause border animation</button>
</div>
</article>/* Register typed custom properties so the browser can interpolate them */
@property --angle{syntax:"<angle>";inherits:false;initial-value:0deg}
@property --from{syntax:"<color>";inherits:false;initial-value:#1d4ed8}
@property --to{syntax:"<color>";inherits:false;initial-value:#7c3aed}
/* 1. Spinning gradient border: the conic gradient's start angle animates */
.plan{
width:100%;max-width:420px;padding:24px;border-radius:18px;border:3px solid transparent;
background:
linear-gradient(#fff, #fff) padding-box,
conic-gradient(from var(--angle, 0deg), #2563eb, #7c3aed, #db2777, #f59e0b, #2563eb) border-box;
animation:spin 4s linear infinite;
}
@keyframes spin{ to{ --angle:360deg } }
.plan.is-paused{animation-play-state:paused}
/* 2. Gradient button: the two colour stops transition on hover/focus */
.cta{
font:inherit;font-weight:700;color:#fff;border:0;border-radius:999px;padding:11px 20px;cursor:pointer;
background:#1d4ed8 linear-gradient(120deg, var(--from, #1d4ed8), var(--to, #7c3aed));
transition:--from .4s ease, --to .4s ease;
}
.cta:hover,.cta:focus-visible{--from:#7c3aed;--to:#be185d}
.cta:focus-visible{outline:3px solid #1b2433;outline-offset:3px}
@media (prefers-reduced-motion:reduce){
.plan{animation:none}
.cta{transition:none}
.pause{display:none}
}
.plan .tag{display:inline-block;font-size:.72rem;font-weight:800;letter-spacing:.06em;text-transform:uppercase;color:#5b21b6;
background:#f3e8ff;border-radius:999px;padding:3px 10px}
.plan h1{font-size:1.25rem;margin:10px 0 2px}
.plan .price{font-size:1.9rem;font-weight:800;letter-spacing:-.02em}
.plan .price span{font-size:.9rem;font-weight:500;color:#475569}
.plan ul{list-style:none;margin:12px 0 18px;font-size:.9rem;color:#3b4658}
.plan li::before{content:"✓ ";color:#15803d;font-weight:700}
.plan .actions{display:flex;flex-wrap:wrap;align-items:center;gap:10px}
.pause{font:inherit;font-size:.82rem;font-weight:650;color:#1b2433;background:#fff;border:1px solid #cbd5e1;border-radius:999px;
padding:8px 14px;cursor:pointer}
.pause:hover{background:#f1f5f9}
.pause:focus-visible{outline:3px solid #2563eb;outline-offset:2px}// Moving content that runs for more than 5 seconds needs a way to pause it (WCAG 2.2.2)
const plan = document.querySelector('.plan');
const pause = plan.querySelector('.pause');
pause.addEventListener('click', () => {
const paused = plan.classList.toggle('is-paused');
pause.setAttribute('aria-pressed', String(paused));
});Number counter with @property and counter()
Register --num with the integer syntax and it can be animated like any number. counter-reset: num var(--num) feeds it into a CSS counter, and content: counter(num) prints it in a pseudo-element. The keyframes only define from { --num: 0 }; with no to keyframe, the animation runs to the element's own value, set per stat with --target.
counter() prints integers only, with no thousands separators or decimals, so this suits short figures such as percentages and counts. For formatted numbers, use JavaScript with Intl.NumberFormat.
The animated number is aria-hidden, and the real value sits in visually hidden text next to it, so screen readers announce 98% once rather than whatever value the animation has reached. Without @property support the counter is not animated and shows the final values straight away.
Show the code
<ul class="stats">
<li class="stat">
<span class="stat-value" style="--target:98" data-suffix="%" aria-hidden="true"></span>
<span class="sr-only">98%</span>
<p class="stat-label">of orders delivered on time</p>
</li>
<li class="stat">
<span class="stat-value" style="--target:342" aria-hidden="true"></span>
<span class="sr-only">342</span>
<p class="stat-label">five-star reviews this month</p>
</li>
<li class="stat">
<span class="stat-value" style="--target:27" aria-hidden="true"></span>
<span class="sr-only">27</span>
<p class="stat-label">countries we ship to</p>
</li>
</ul>/* An <integer> custom property can be animated; counter() turns it into text */
@property --num{syntax:"<integer>";inherits:false;initial-value:0}
.stats{width:100%;max-width:640px;display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;list-style:none}
@media (max-width:520px){.stats{grid-template-columns:1fr}}
.stat{background:#fff;border:1px solid #e3e8f0;border-radius:16px;padding:18px 20px}
.stat-value{
--num:var(--target); /* end value, set per stat in the HTML */
counter-reset:num var(--num);
font-size:2.2rem;font-weight:800;letter-spacing:-.02em;color:#1d4ed8;font-variant-numeric:tabular-nums;
animation:count-up 1.8s cubic-bezier(.2,.7,.2,1);
}
.stat-value::before{content:counter(num)}
.stat-value[data-suffix]::after{content:attr(data-suffix)}
@keyframes count-up{ from{ --num:0 } } /* no "to": it animates to the element's own value */
.stat-label{font-size:.88rem;color:#475569}
@media (prefers-reduced-motion:reduce){
.stat-value{animation:none}
}Fallbacks and progressive enhancement
Treat these animations as an enhancement on top of a working component. Every demo here is fully usable with no animation at all: panels open, dialogs close, toasts appear. Each one also shows a short note when the current browser is missing the feature.
Feature queries do most of the work: @supports (interpolate-size: allow-keywords), @supports (height: calc-size(auto, size)) and @supports selector(::details-content) are all reliable. @starting-style and @property cannot be tested with @supports, so the demos check for CSSStartingStyleRule and CSS.registerProperty in JavaScript. Whether display transitions work is detected by setting display: none with a transition and reading the computed value back.
Two declaration-order tricks help as well. Put a plain transition before the one that uses allow-discrete, and put a fixed height before a calc-size() height. Browsers that do not understand the second declaration keep the first one. If you only need one technique that animates everywhere today, use grid-template-rows 0fr to 1fr.
Accessibility and reduced motion
Height animations hide content visually, not semantically. Pair them with visibility: hidden (transitioned, so it flips after the close) or the native closed state of the details element, so collapsed content is skipped by keyboard and screen reader users. Use aria-expanded and aria-controls on custom toggle buttons.
Every demo wraps its motion in prefers-reduced-motion: reduce. Height animations are switched off, dialogs and toasts keep a short fade without scale or movement, and the looping border animation stops. Anything that moves for longer than five seconds also gets a pause control.
Animating height or grid-template-rows triggers layout on every frame, unlike opacity and transform. Keep these transitions short (around 200 to 400ms) and limited to the component that is changing.
Frequently asked questions
Can you transition height: auto in CSS?
Yes, in Chromium 129 and later. Set interpolate-size: allow-keywords (usually on :root) and height: 0 to height: auto will animate, or use calc-size(auto, size). Firefox and Safari do not support either yet, so use the grid-template-rows 0fr to 1fr technique as a fallback; it animates in every current browser.
Why does my max-height transition have a delay when closing?
The transition runs over the whole max-height range, not over the content's real height. If max-height is 40rem and the content is 9rem, most of the closing time is spent shrinking empty space before the content starts to move. Use interpolate-size or the grid 0fr to 1fr technique, which animate the real height.
How do I animate an element from display: none?
Use @starting-style to give the element its starting values (for example opacity: 0) for the first frame after it becomes visible. For the exit, add display to the transition with allow-discrete so the element stays rendered until the transition ends. @starting-style works in Chrome 117, Firefox 129 and Safari 17.5; Firefox does not yet transition display, so exits are instant there.
What is the difference between interpolate-size and calc-size()?
interpolate-size: allow-keywords is an inherited switch that lets lengths animate to keywords like auto across a whole subtree. calc-size() is a value that does maths on one intrinsic size, for example calc-size(auto, size + 2rem) or min(size, 7.5rem). Use interpolate-size for plain auto animations and calc-size() when you need a calculation.
Why do I need @property to animate a gradient?
Gradients themselves cannot be interpolated, and unregistered custom properties are treated as plain strings, so they jump between values. Registering a custom property with @property and a syntax such as angle or color lets the browser interpolate it, and the gradient that uses it updates on every frame. @property is Baseline 2024: Chrome 85, Firefox 128 and Safari 16.4.
More from the Modern CSS lab
- CSS :has() Selector: 8 Practical Examples of the Parent Selector
- CSS Container Queries: 8 Practical @container Examples
- CSS Anchor Positioning: 8 Practical Examples
- HTML Popover API: 8 Practical Examples
- CSS Scroll-Driven Animations: 8 Examples with animation-timeline
- CSS View Transitions: 8 Live Examples of the View Transition API
- CSS Subgrid: Align Cards, Forms and Layouts Across Nested Grids