12 / 20
All 20 in 20 CSS View Transitions
- 01Basic SPA View Transition
- 02Cross Document MPA Transition
- 03Dynamic View Transition Name
- 04Custom View Transition Keyframes
- 05View Transition Types
- 06Card to Full Page Detail Morph
- 07Image Grid to Lightbox Transition
- 08List Reorder and Filtering
- 09Tab Switcher Indicator Movement
- 10Circular Clip Path Theme Toggle
- 11Add to Cart Badge Counter Morph
- 12Accordion Expand Smooth Height
- 13Off Canvas Sidebar Drawer
- 14Native Modal Dialog Open Close
- 15Multi Step Form Wizard
- 16Accessible Transitions Reduced Motion
- 17Feature Detection Graceful Fallback
- 18Fixing Aspect Ratio Image Distortion
- 19Nested View Transition Isolation
- 20Preventing Flicker and Z Index Stacking
Accordion Expand Smooth Height
Animating to intrinsic height without max-height guesswork or JavaScript measurement: name the panel, toggle it open inside the callback, and the browser interpolates the box. Also weighs the two honest alternatives — grid-template-rows: 0fr to 1fr and interpolate-size: allow-keywords — because the API is not always right for a disclosure.
Build interactive UI like this from scratch: 20 HTML, CSS & JavaScript projects
ZTM · JavaScript Web Projects · 30‑day money-back guarantee · Affiliate link
The code
<section class="vtr-12" aria-labelledby="vtr-12-heading">
<fieldset class="vtr-12__theme">
<legend class="vtr-12__themelegend">Theme</legend>
<input class="vtr-12__themeinput" type="radio" name="vtr-12-theme" id="vtr-12-theme-auto" checked>
<label class="vtr-12__themeopt" for="vtr-12-theme-auto">Auto</label>
<input class="vtr-12__themeinput" type="radio" name="vtr-12-theme" id="vtr-12-theme-light">
<label class="vtr-12__themeopt" for="vtr-12-theme-light">Light</label>
<input class="vtr-12__themeinput" type="radio" name="vtr-12-theme" id="vtr-12-theme-dark">
<label class="vtr-12__themeopt" for="vtr-12-theme-dark">Dark</label>
</fieldset>
<div class="vtr-12__stage">
<header class="vtr-12__head">
<p class="vtr-12__kicker">Sable Support</p>
<h2 class="vtr-12__heading" id="vtr-12-heading">Height, without a measurement</h2>
<p class="vtr-12__sub">The panel is named. The open state is toggled. Nothing reads scrollHeight.</p>
</header>
<div class="vtr-12__acc" id="vtr-12-acc">
<div class="vtr-12__item" data-open="true">
<button class="vtr-12__trigger" type="button" aria-expanded="true" data-vtr12-toggle>
<span>How do I move a workspace to another region</span>
<span class="vtr-12__chev" aria-hidden="true"></span>
</button>
<div class="vtr-12__panel">
<p>Open the workspace settings, choose the destination region and confirm. Sable copies object storage first, then cuts over the database in a single write-locked window of about ninety seconds.</p>
<p>Existing API keys keep working. Region-pinned endpoints do not, so update any hardcoded hostnames before the cutover.</p>
</div>
</div>
<div class="vtr-12__item">
<button class="vtr-12__trigger" type="button" aria-expanded="false" data-vtr12-toggle>
<span>What happens to scheduled jobs during a plan change</span>
<span class="vtr-12__chev" aria-hidden="true"></span>
</button>
<div class="vtr-12__panel">
<p>Jobs already running finish on the old plan's limits. Anything queued starts under the new limits, which can mean a shorter timeout — check the concurrency ceiling before downgrading.</p>
</div>
</div>
<div class="vtr-12__item">
<button class="vtr-12__trigger" type="button" aria-expanded="false" data-vtr12-toggle>
<span>Can I export audit logs on the Team plan</span>
<span class="vtr-12__chev" aria-hidden="true"></span>
</button>
<div class="vtr-12__panel">
<p>Yes, as newline-delimited JSON for the last ninety days. Longer retention and streaming export to a bucket are Scale features.</p>
</div>
</div>
</div>
<div class="vtr-12__alts">
<p class="vtr-12__altstitle">When not to use this</p>
<ul class="vtr-12__altlist">
<li><code>grid-template-rows: 0fr to 1fr</code><span>Pure CSS, no API, works everywhere. Better for a lone disclosure.</span></li>
<li><code>interpolate-size: allow-keywords</code><span>Cleanest syntax, transitions height: auto directly. Chrome only today.</span></li>
<li><code>view-transition-name</code><span>Worth it when the open state also moves siblings, badges or icons.</span></li>
</ul>
</div>
</div>
</section><section class="vtr-12" aria-labelledby="vtr-12-heading">
<fieldset class="vtr-12__theme">
<legend class="vtr-12__themelegend">Theme</legend>
<input class="vtr-12__themeinput" type="radio" name="vtr-12-theme" id="vtr-12-theme-auto" checked>
<label class="vtr-12__themeopt" for="vtr-12-theme-auto">Auto</label>
<input class="vtr-12__themeinput" type="radio" name="vtr-12-theme" id="vtr-12-theme-light">
<label class="vtr-12__themeopt" for="vtr-12-theme-light">Light</label>
<input class="vtr-12__themeinput" type="radio" name="vtr-12-theme" id="vtr-12-theme-dark">
<label class="vtr-12__themeopt" for="vtr-12-theme-dark">Dark</label>
</fieldset>
<div class="vtr-12__stage">
<header class="vtr-12__head">
<p class="vtr-12__kicker">Sable Support</p>
<h2 class="vtr-12__heading" id="vtr-12-heading">Height, without a measurement</h2>
<p class="vtr-12__sub">The panel is named. The open state is toggled. Nothing reads scrollHeight.</p>
</header>
<div class="vtr-12__acc" id="vtr-12-acc">
<div class="vtr-12__item" data-open="true">
<button class="vtr-12__trigger" type="button" aria-expanded="true" data-vtr12-toggle>
<span>How do I move a workspace to another region</span>
<span class="vtr-12__chev" aria-hidden="true"></span>
</button>
<div class="vtr-12__panel">
<p>Open the workspace settings, choose the destination region and confirm. Sable copies object storage first, then cuts over the database in a single write-locked window of about ninety seconds.</p>
<p>Existing API keys keep working. Region-pinned endpoints do not, so update any hardcoded hostnames before the cutover.</p>
</div>
</div>
<div class="vtr-12__item">
<button class="vtr-12__trigger" type="button" aria-expanded="false" data-vtr12-toggle>
<span>What happens to scheduled jobs during a plan change</span>
<span class="vtr-12__chev" aria-hidden="true"></span>
</button>
<div class="vtr-12__panel">
<p>Jobs already running finish on the old plan's limits. Anything queued starts under the new limits, which can mean a shorter timeout — check the concurrency ceiling before downgrading.</p>
</div>
</div>
<div class="vtr-12__item">
<button class="vtr-12__trigger" type="button" aria-expanded="false" data-vtr12-toggle>
<span>Can I export audit logs on the Team plan</span>
<span class="vtr-12__chev" aria-hidden="true"></span>
</button>
<div class="vtr-12__panel">
<p>Yes, as newline-delimited JSON for the last ninety days. Longer retention and streaming export to a bucket are Scale features.</p>
</div>
</div>
</div>
<div class="vtr-12__alts">
<p class="vtr-12__altstitle">When not to use this</p>
<ul class="vtr-12__altlist">
<li><code>grid-template-rows: 0fr to 1fr</code><span>Pure CSS, no API, works everywhere. Better for a lone disclosure.</span></li>
<li><code>interpolate-size: allow-keywords</code><span>Cleanest syntax, transitions height: auto directly. Chrome only today.</span></li>
<li><code>view-transition-name</code><span>Worth it when the open state also moves siblings, badges or icons.</span></li>
</ul>
</div>
</div>
</section>.vtr-12 {
--bg: #f5f1eb;
--surface: #fbf8f4;
--line: #ddd2c4;
--ink: #2b2118;
--muted: #6f6053;
--accent: #8a6a4b;
--accent-soft: #ece1d4;
--font-display: "Bookman Old Style", Rockwell, Georgia, serif;
width: 100%;
min-height: 100vh;
display: block;
box-sizing: border-box;
background: var(--bg);
color: var(--ink);
font-family: system-ui, sans-serif;
color-scheme: light dark;
}
.vtr-12 *,
.vtr-12 *::before,
.vtr-12 *::after {
box-sizing: border-box;
}
@supports (color: oklch(0% 0 0)) {
.vtr-12 {
--bg: oklch(95.5% 0.014 75);
--surface: oklch(97.5% 0.01 75);
--line: oklch(86% 0.024 72);
--ink: oklch(25% 0.025 60);
--muted: oklch(50% 0.028 62);
--accent: oklch(55% 0.06 62);
--accent-soft: oklch(90% 0.024 70);
}
}
@media (prefers-color-scheme: dark) {
.vtr-12 {
--bg: #161210;
--surface: #1e1815;
--line: #322821;
--ink: #f2e8de;
--muted: #a89684;
--accent: #c9a37a;
--accent-soft: #2a211a;
}
}
.vtr-12[data-theme="light"] {
--bg: #f5f1eb;
--surface: #fbf8f4;
--line: #ddd2c4;
--ink: #2b2118;
--muted: #6f6053;
--accent: #8a6a4b;
--accent-soft: #ece1d4;
}
.vtr-12[data-theme="dark"] {
--bg: #161210;
--surface: #1e1815;
--line: #322821;
--ink: #f2e8de;
--muted: #a89684;
--accent: #c9a37a;
--accent-soft: #2a211a;
}
.vtr-12__stage {
max-width: 48rem;
margin: 0 auto;
padding: clamp(1.25rem, 4vw, 2.75rem) clamp(0.9rem, 4vw, 2rem);
display: grid;
gap: 1.1rem;
}
.vtr-12__kicker {
margin: 0 0 0.3rem;
font-size: 0.68rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--accent);
}
.vtr-12__heading {
margin: 0;
font-family: var(--font-display);
font-size: clamp(1.3rem, 4.2vw, 1.8rem);
font-weight: 700;
letter-spacing: -0.01em;
}
.vtr-12__sub {
margin: 0.3rem 0 0;
font-size: 0.86rem;
color: var(--muted);
}
.vtr-12__acc {
border-top: 1px solid var(--line);
}
.vtr-12__item {
border-bottom: 1px solid var(--line);
}
.vtr-12__trigger {
width: 100%;
min-height: 44px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.7rem 0.15rem;
border: 0;
background: none;
color: var(--ink);
font-family: var(--font-display);
font-size: 0.92rem;
font-weight: 700;
text-align: left;
cursor: pointer;
}
.vtr-12__trigger:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.vtr-12__trigger[aria-expanded="true"] {
color: var(--accent);
}
.vtr-12__chev {
flex: none;
width: 0.6rem;
height: 0.6rem;
border-right: 2px solid currentColor;
border-bottom: 2px solid currentColor;
transform: rotate(45deg);
}
.vtr-12__trigger[aria-expanded="true"] .vtr-12__chev {
transform: rotate(-135deg);
}
.vtr-12__panel {
height: 0;
overflow: hidden;
}
.vtr-12__item[data-open="true"] .vtr-12__panel {
height: auto;
padding-bottom: 0.8rem;
view-transition-name: vtr-12-panel;
}
.vtr-12__panel p {
margin: 0 0 0.6rem;
padding-right: 1rem;
font-size: 0.85rem;
line-height: 1.6;
color: var(--muted);
max-width: 38rem;
}
.vtr-12__panel p:last-child {
margin-bottom: 0;
}
.vtr-12__alts {
padding: 0.85rem 0.9rem;
background: var(--surface);
border: 1px solid var(--line);
}
.vtr-12__altstitle {
margin: 0 0 0.5rem;
font-size: 0.68rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent);
}
.vtr-12__altlist {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 0.5rem;
}
.vtr-12__altlist li {
display: grid;
gap: 0.1rem;
min-inline-size: 0;
}
.vtr-12__altlist code {
font-family: ui-monospace, Menlo, monospace;
font-size: 0.72rem;
word-break: break-word;
}
.vtr-12__altlist span {
font-size: 0.76rem;
color: var(--muted);
}
::view-transition-group(vtr-12-panel) {
animation-duration: 380ms;
animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
}
@media (prefers-reduced-motion: reduce) {
::view-transition-group(vtr-12-panel) {
animation-duration: 1ms;
}
}
/* Theme switch (added 2026-08-24). Pure CSS: three radios + :has(). Auto
follows the OS and the site's [data-theme] stamp; Light and Dark pin the
palette. Values are the demo's own [data-theme] palettes reused verbatim,
so the toggle can never drift from the site-theme rendering. */
.vtr-12 {
position: relative;
}
.vtr-12__theme {
position: absolute;
inset-block-start: 0.875rem;
inset-inline-end: 0.875rem;
z-index: 9;
display: flex;
align-items: center;
gap: 0.125rem;
margin: 0;
padding: 0.1875rem;
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
border-radius: 999px;
background: color-mix(in srgb, currentColor 8%, transparent);
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
font-family: system-ui, sans-serif;
}
.vtr-12__themelegend {
position: absolute;
inline-size: 1px;
block-size: 1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
}
.vtr-12__themeinput {
position: absolute;
inline-size: 1px;
block-size: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
}
.vtr-12__themeopt {
display: inline-flex;
align-items: center;
justify-content: center;
min-block-size: 2.25rem;
min-inline-size: 2.75rem;
padding: 0 0.6875rem;
border-radius: 999px;
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: inherit;
opacity: 0.55;
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.vtr-12__themeopt:hover {
opacity: 0.85;
}
.vtr-12__themeinput:checked + .vtr-12__themeopt {
opacity: 1;
background: color-mix(in srgb, currentColor 15%, transparent);
}
.vtr-12__themeinput:focus-visible + .vtr-12__themeopt {
outline: 2px solid currentColor;
outline-offset: 2px;
}
/* Explicit pins beat both the OS query and the site stamp. */
.vtr-12:has(#vtr-12-theme-light:checked) {
--bg: #f5f1eb;
--surface: #fbf8f4;
--line: #ddd2c4;
--ink: #2b2118;
--muted: #6f6053;
--accent: #8a6a4b;
--accent-soft: #ece1d4;
}
.vtr-12:has(#vtr-12-theme-dark:checked) {
--bg: #161210;
--surface: #1e1815;
--line: #322821;
--ink: #f2e8de;
--muted: #a89684;
--accent: #c9a37a;
--accent-soft: #2a211a;
}.vtr-12 {
--bg: #f5f1eb;
--surface: #fbf8f4;
--line: #ddd2c4;
--ink: #2b2118;
--muted: #6f6053;
--accent: #8a6a4b;
--accent-soft: #ece1d4;
--font-display: "Bookman Old Style", Rockwell, Georgia, serif;
width: 100%;
min-height: 100vh;
display: block;
box-sizing: border-box;
background: var(--bg);
color: var(--ink);
font-family: system-ui, sans-serif;
color-scheme: light dark;
}
.vtr-12 *,
.vtr-12 *::before,
.vtr-12 *::after {
box-sizing: border-box;
}
@supports (color: oklch(0% 0 0)) {
.vtr-12 {
--bg: oklch(95.5% 0.014 75);
--surface: oklch(97.5% 0.01 75);
--line: oklch(86% 0.024 72);
--ink: oklch(25% 0.025 60);
--muted: oklch(50% 0.028 62);
--accent: oklch(55% 0.06 62);
--accent-soft: oklch(90% 0.024 70);
}
}
@media (prefers-color-scheme: dark) {
.vtr-12 {
--bg: #161210;
--surface: #1e1815;
--line: #322821;
--ink: #f2e8de;
--muted: #a89684;
--accent: #c9a37a;
--accent-soft: #2a211a;
}
}
.vtr-12[data-theme="light"] {
--bg: #f5f1eb;
--surface: #fbf8f4;
--line: #ddd2c4;
--ink: #2b2118;
--muted: #6f6053;
--accent: #8a6a4b;
--accent-soft: #ece1d4;
}
.vtr-12[data-theme="dark"] {
--bg: #161210;
--surface: #1e1815;
--line: #322821;
--ink: #f2e8de;
--muted: #a89684;
--accent: #c9a37a;
--accent-soft: #2a211a;
}
.vtr-12__stage {
max-width: 48rem;
margin: 0 auto;
padding: clamp(1.25rem, 4vw, 2.75rem) clamp(0.9rem, 4vw, 2rem);
display: grid;
gap: 1.1rem;
}
.vtr-12__kicker {
margin: 0 0 0.3rem;
font-size: 0.68rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--accent);
}
.vtr-12__heading {
margin: 0;
font-family: var(--font-display);
font-size: clamp(1.3rem, 4.2vw, 1.8rem);
font-weight: 700;
letter-spacing: -0.01em;
}
.vtr-12__sub {
margin: 0.3rem 0 0;
font-size: 0.86rem;
color: var(--muted);
}
.vtr-12__acc {
border-top: 1px solid var(--line);
}
.vtr-12__item {
border-bottom: 1px solid var(--line);
}
.vtr-12__trigger {
width: 100%;
min-height: 44px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.7rem 0.15rem;
border: 0;
background: none;
color: var(--ink);
font-family: var(--font-display);
font-size: 0.92rem;
font-weight: 700;
text-align: left;
cursor: pointer;
}
.vtr-12__trigger:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.vtr-12__trigger[aria-expanded="true"] {
color: var(--accent);
}
.vtr-12__chev {
flex: none;
width: 0.6rem;
height: 0.6rem;
border-right: 2px solid currentColor;
border-bottom: 2px solid currentColor;
transform: rotate(45deg);
}
.vtr-12__trigger[aria-expanded="true"] .vtr-12__chev {
transform: rotate(-135deg);
}
.vtr-12__panel {
height: 0;
overflow: hidden;
}
.vtr-12__item[data-open="true"] .vtr-12__panel {
height: auto;
padding-bottom: 0.8rem;
view-transition-name: vtr-12-panel;
}
.vtr-12__panel p {
margin: 0 0 0.6rem;
padding-right: 1rem;
font-size: 0.85rem;
line-height: 1.6;
color: var(--muted);
max-width: 38rem;
}
.vtr-12__panel p:last-child {
margin-bottom: 0;
}
.vtr-12__alts {
padding: 0.85rem 0.9rem;
background: var(--surface);
border: 1px solid var(--line);
}
.vtr-12__altstitle {
margin: 0 0 0.5rem;
font-size: 0.68rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent);
}
.vtr-12__altlist {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 0.5rem;
}
.vtr-12__altlist li {
display: grid;
gap: 0.1rem;
min-inline-size: 0;
}
.vtr-12__altlist code {
font-family: ui-monospace, Menlo, monospace;
font-size: 0.72rem;
word-break: break-word;
}
.vtr-12__altlist span {
font-size: 0.76rem;
color: var(--muted);
}
::view-transition-group(vtr-12-panel) {
animation-duration: 380ms;
animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
}
@media (prefers-reduced-motion: reduce) {
::view-transition-group(vtr-12-panel) {
animation-duration: 1ms;
}
}
/* Theme switch (added 2026-08-24). Pure CSS: three radios + :has(). Auto
follows the OS and the site's [data-theme] stamp; Light and Dark pin the
palette. Values are the demo's own [data-theme] palettes reused verbatim,
so the toggle can never drift from the site-theme rendering. */
.vtr-12 {
position: relative;
}
.vtr-12__theme {
position: absolute;
inset-block-start: 0.875rem;
inset-inline-end: 0.875rem;
z-index: 9;
display: flex;
align-items: center;
gap: 0.125rem;
margin: 0;
padding: 0.1875rem;
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
border-radius: 999px;
background: color-mix(in srgb, currentColor 8%, transparent);
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
font-family: system-ui, sans-serif;
}
.vtr-12__themelegend {
position: absolute;
inline-size: 1px;
block-size: 1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
}
.vtr-12__themeinput {
position: absolute;
inline-size: 1px;
block-size: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
}
.vtr-12__themeopt {
display: inline-flex;
align-items: center;
justify-content: center;
min-block-size: 2.25rem;
min-inline-size: 2.75rem;
padding: 0 0.6875rem;
border-radius: 999px;
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: inherit;
opacity: 0.55;
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.vtr-12__themeopt:hover {
opacity: 0.85;
}
.vtr-12__themeinput:checked + .vtr-12__themeopt {
opacity: 1;
background: color-mix(in srgb, currentColor 15%, transparent);
}
.vtr-12__themeinput:focus-visible + .vtr-12__themeopt {
outline: 2px solid currentColor;
outline-offset: 2px;
}
/* Explicit pins beat both the OS query and the site stamp. */
.vtr-12:has(#vtr-12-theme-light:checked) {
--bg: #f5f1eb;
--surface: #fbf8f4;
--line: #ddd2c4;
--ink: #2b2118;
--muted: #6f6053;
--accent: #8a6a4b;
--accent-soft: #ece1d4;
}
.vtr-12:has(#vtr-12-theme-dark:checked) {
--bg: #161210;
--surface: #1e1815;
--line: #322821;
--ink: #f2e8de;
--muted: #a89684;
--accent: #c9a37a;
--accent-soft: #2a211a;
}(() => {
const root = document.querySelector('.vtr-12');
if (!root || root.dataset.vtr12) return;
root.dataset.vtr12 = 'on';
const items = [...root.querySelectorAll('.vtr-12__item')];
const reduce = matchMedia('(prefers-reduced-motion: reduce)');
let busy = false;
const set = (item) => {
const opening = item.dataset.open !== 'true';
items.forEach(i => {
const on = i === item && opening;
i.dataset.open = String(on);
i.querySelector('.vtr-12__trigger').setAttribute('aria-expanded', String(on));
});
};
items.forEach(item => item.querySelector('[data-vtr12-toggle]').addEventListener('click', async () => {
if (busy) return;
if (!document.startViewTransition || reduce.matches) { set(item); return; }
busy = true;
try { await document.startViewTransition(() => set(item)).finished; } finally { busy = false; }
}));
})();(() => {
const root = document.querySelector('.vtr-12');
if (!root || root.dataset.vtr12) return;
root.dataset.vtr12 = 'on';
const items = [...root.querySelectorAll('.vtr-12__item')];
const reduce = matchMedia('(prefers-reduced-motion: reduce)');
let busy = false;
const set = (item) => {
const opening = item.dataset.open !== 'true';
items.forEach(i => {
const on = i === item && opening;
i.dataset.open = String(on);
i.querySelector('.vtr-12__trigger').setAttribute('aria-expanded', String(on));
});
};
items.forEach(item => item.querySelector('[data-vtr12-toggle]').addEventListener('click', async () => {
if (busy) return;
if (!document.startViewTransition || reduce.matches) { set(item); return; }
busy = true;
try { await document.startViewTransition(() => set(item)).finished; } finally { busy = false; }
}));
})();Here's a working CSS View Transition from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Accordion Expand Smooth Height
Source: https://codefronts.com/motion/css-view-transitions/accordion-expand-smooth-height/
Animating to intrinsic height without max-height guesswork or JavaScript measurement: name the panel, toggle it open inside the callback, and the browser interpolates the box. Also weighs the two honest alternatives — grid-template-rows: 0fr to 1fr and interpolate-size: allow-keywords — because the API is not always right for a disclosure.
## HTML
```html
<section class="vtr-12" aria-labelledby="vtr-12-heading">
<fieldset class="vtr-12__theme">
<legend class="vtr-12__themelegend">Theme</legend>
<input class="vtr-12__themeinput" type="radio" name="vtr-12-theme" id="vtr-12-theme-auto" checked>
<label class="vtr-12__themeopt" for="vtr-12-theme-auto">Auto</label>
<input class="vtr-12__themeinput" type="radio" name="vtr-12-theme" id="vtr-12-theme-light">
<label class="vtr-12__themeopt" for="vtr-12-theme-light">Light</label>
<input class="vtr-12__themeinput" type="radio" name="vtr-12-theme" id="vtr-12-theme-dark">
<label class="vtr-12__themeopt" for="vtr-12-theme-dark">Dark</label>
</fieldset>
<div class="vtr-12__stage">
<header class="vtr-12__head">
<p class="vtr-12__kicker">Sable Support</p>
<h2 class="vtr-12__heading" id="vtr-12-heading">Height, without a measurement</h2>
<p class="vtr-12__sub">The panel is named. The open state is toggled. Nothing reads scrollHeight.</p>
</header>
<div class="vtr-12__acc" id="vtr-12-acc">
<div class="vtr-12__item" data-open="true">
<button class="vtr-12__trigger" type="button" aria-expanded="true" data-vtr12-toggle>
<span>How do I move a workspace to another region</span>
<span class="vtr-12__chev" aria-hidden="true"></span>
</button>
<div class="vtr-12__panel">
<p>Open the workspace settings, choose the destination region and confirm. Sable copies object storage first, then cuts over the database in a single write-locked window of about ninety seconds.</p>
<p>Existing API keys keep working. Region-pinned endpoints do not, so update any hardcoded hostnames before the cutover.</p>
</div>
</div>
<div class="vtr-12__item">
<button class="vtr-12__trigger" type="button" aria-expanded="false" data-vtr12-toggle>
<span>What happens to scheduled jobs during a plan change</span>
<span class="vtr-12__chev" aria-hidden="true"></span>
</button>
<div class="vtr-12__panel">
<p>Jobs already running finish on the old plan's limits. Anything queued starts under the new limits, which can mean a shorter timeout — check the concurrency ceiling before downgrading.</p>
</div>
</div>
<div class="vtr-12__item">
<button class="vtr-12__trigger" type="button" aria-expanded="false" data-vtr12-toggle>
<span>Can I export audit logs on the Team plan</span>
<span class="vtr-12__chev" aria-hidden="true"></span>
</button>
<div class="vtr-12__panel">
<p>Yes, as newline-delimited JSON for the last ninety days. Longer retention and streaming export to a bucket are Scale features.</p>
</div>
</div>
</div>
<div class="vtr-12__alts">
<p class="vtr-12__altstitle">When not to use this</p>
<ul class="vtr-12__altlist">
<li><code>grid-template-rows: 0fr to 1fr</code><span>Pure CSS, no API, works everywhere. Better for a lone disclosure.</span></li>
<li><code>interpolate-size: allow-keywords</code><span>Cleanest syntax, transitions height: auto directly. Chrome only today.</span></li>
<li><code>view-transition-name</code><span>Worth it when the open state also moves siblings, badges or icons.</span></li>
</ul>
</div>
</div>
</section>
```
## CSS
```css
.vtr-12 {
--bg: #f5f1eb;
--surface: #fbf8f4;
--line: #ddd2c4;
--ink: #2b2118;
--muted: #6f6053;
--accent: #8a6a4b;
--accent-soft: #ece1d4;
--font-display: "Bookman Old Style", Rockwell, Georgia, serif;
width: 100%;
min-height: 100vh;
display: block;
box-sizing: border-box;
background: var(--bg);
color: var(--ink);
font-family: system-ui, sans-serif;
color-scheme: light dark;
}
.vtr-12 *,
.vtr-12 *::before,
.vtr-12 *::after {
box-sizing: border-box;
}
@supports (color: oklch(0% 0 0)) {
.vtr-12 {
--bg: oklch(95.5% 0.014 75);
--surface: oklch(97.5% 0.01 75);
--line: oklch(86% 0.024 72);
--ink: oklch(25% 0.025 60);
--muted: oklch(50% 0.028 62);
--accent: oklch(55% 0.06 62);
--accent-soft: oklch(90% 0.024 70);
}
}
@media (prefers-color-scheme: dark) {
.vtr-12 {
--bg: #161210;
--surface: #1e1815;
--line: #322821;
--ink: #f2e8de;
--muted: #a89684;
--accent: #c9a37a;
--accent-soft: #2a211a;
}
}
.vtr-12[data-theme="light"] {
--bg: #f5f1eb;
--surface: #fbf8f4;
--line: #ddd2c4;
--ink: #2b2118;
--muted: #6f6053;
--accent: #8a6a4b;
--accent-soft: #ece1d4;
}
.vtr-12[data-theme="dark"] {
--bg: #161210;
--surface: #1e1815;
--line: #322821;
--ink: #f2e8de;
--muted: #a89684;
--accent: #c9a37a;
--accent-soft: #2a211a;
}
.vtr-12__stage {
max-width: 48rem;
margin: 0 auto;
padding: clamp(1.25rem, 4vw, 2.75rem) clamp(0.9rem, 4vw, 2rem);
display: grid;
gap: 1.1rem;
}
.vtr-12__kicker {
margin: 0 0 0.3rem;
font-size: 0.68rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--accent);
}
.vtr-12__heading {
margin: 0;
font-family: var(--font-display);
font-size: clamp(1.3rem, 4.2vw, 1.8rem);
font-weight: 700;
letter-spacing: -0.01em;
}
.vtr-12__sub {
margin: 0.3rem 0 0;
font-size: 0.86rem;
color: var(--muted);
}
.vtr-12__acc {
border-top: 1px solid var(--line);
}
.vtr-12__item {
border-bottom: 1px solid var(--line);
}
.vtr-12__trigger {
width: 100%;
min-height: 44px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.7rem 0.15rem;
border: 0;
background: none;
color: var(--ink);
font-family: var(--font-display);
font-size: 0.92rem;
font-weight: 700;
text-align: left;
cursor: pointer;
}
.vtr-12__trigger:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.vtr-12__trigger[aria-expanded="true"] {
color: var(--accent);
}
.vtr-12__chev {
flex: none;
width: 0.6rem;
height: 0.6rem;
border-right: 2px solid currentColor;
border-bottom: 2px solid currentColor;
transform: rotate(45deg);
}
.vtr-12__trigger[aria-expanded="true"] .vtr-12__chev {
transform: rotate(-135deg);
}
.vtr-12__panel {
height: 0;
overflow: hidden;
}
.vtr-12__item[data-open="true"] .vtr-12__panel {
height: auto;
padding-bottom: 0.8rem;
view-transition-name: vtr-12-panel;
}
.vtr-12__panel p {
margin: 0 0 0.6rem;
padding-right: 1rem;
font-size: 0.85rem;
line-height: 1.6;
color: var(--muted);
max-width: 38rem;
}
.vtr-12__panel p:last-child {
margin-bottom: 0;
}
.vtr-12__alts {
padding: 0.85rem 0.9rem;
background: var(--surface);
border: 1px solid var(--line);
}
.vtr-12__altstitle {
margin: 0 0 0.5rem;
font-size: 0.68rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent);
}
.vtr-12__altlist {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 0.5rem;
}
.vtr-12__altlist li {
display: grid;
gap: 0.1rem;
min-inline-size: 0;
}
.vtr-12__altlist code {
font-family: ui-monospace, Menlo, monospace;
font-size: 0.72rem;
word-break: break-word;
}
.vtr-12__altlist span {
font-size: 0.76rem;
color: var(--muted);
}
::view-transition-group(vtr-12-panel) {
animation-duration: 380ms;
animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
}
@media (prefers-reduced-motion: reduce) {
::view-transition-group(vtr-12-panel) {
animation-duration: 1ms;
}
}
/* Theme switch (added 2026-08-24). Pure CSS: three radios + :has(). Auto
follows the OS and the site's [data-theme] stamp; Light and Dark pin the
palette. Values are the demo's own [data-theme] palettes reused verbatim,
so the toggle can never drift from the site-theme rendering. */
.vtr-12 {
position: relative;
}
.vtr-12__theme {
position: absolute;
inset-block-start: 0.875rem;
inset-inline-end: 0.875rem;
z-index: 9;
display: flex;
align-items: center;
gap: 0.125rem;
margin: 0;
padding: 0.1875rem;
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
border-radius: 999px;
background: color-mix(in srgb, currentColor 8%, transparent);
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
font-family: system-ui, sans-serif;
}
.vtr-12__themelegend {
position: absolute;
inline-size: 1px;
block-size: 1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
}
.vtr-12__themeinput {
position: absolute;
inline-size: 1px;
block-size: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
}
.vtr-12__themeopt {
display: inline-flex;
align-items: center;
justify-content: center;
min-block-size: 2.25rem;
min-inline-size: 2.75rem;
padding: 0 0.6875rem;
border-radius: 999px;
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: inherit;
opacity: 0.55;
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.vtr-12__themeopt:hover {
opacity: 0.85;
}
.vtr-12__themeinput:checked + .vtr-12__themeopt {
opacity: 1;
background: color-mix(in srgb, currentColor 15%, transparent);
}
.vtr-12__themeinput:focus-visible + .vtr-12__themeopt {
outline: 2px solid currentColor;
outline-offset: 2px;
}
/* Explicit pins beat both the OS query and the site stamp. */
.vtr-12:has(#vtr-12-theme-light:checked) {
--bg: #f5f1eb;
--surface: #fbf8f4;
--line: #ddd2c4;
--ink: #2b2118;
--muted: #6f6053;
--accent: #8a6a4b;
--accent-soft: #ece1d4;
}
.vtr-12:has(#vtr-12-theme-dark:checked) {
--bg: #161210;
--surface: #1e1815;
--line: #322821;
--ink: #f2e8de;
--muted: #a89684;
--accent: #c9a37a;
--accent-soft: #2a211a;
}
```
## JavaScript
```js
(() => {
const root = document.querySelector('.vtr-12');
if (!root || root.dataset.vtr12) return;
root.dataset.vtr12 = 'on';
const items = [...root.querySelectorAll('.vtr-12__item')];
const reduce = matchMedia('(prefers-reduced-motion: reduce)');
let busy = false;
const set = (item) => {
const opening = item.dataset.open !== 'true';
items.forEach(i => {
const on = i === item && opening;
i.dataset.open = String(on);
i.querySelector('.vtr-12__trigger').setAttribute('aria-expanded', String(on));
});
};
items.forEach(item => item.querySelector('[data-vtr12-toggle]').addEventListener('click', async () => {
if (busy) return;
if (!document.startViewTransition || reduce.matches) { set(item); return; }
busy = true;
try { await document.startViewTransition(() => set(item)).finished; } finally { busy = false; }
}));
})();
```Here's a working CSS View Transition from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Accordion Expand Smooth Height
Source: https://codefronts.com/motion/css-view-transitions/accordion-expand-smooth-height/
Animating to intrinsic height without max-height guesswork or JavaScript measurement: name the panel, toggle it open inside the callback, and the browser interpolates the box. Also weighs the two honest alternatives — grid-template-rows: 0fr to 1fr and interpolate-size: allow-keywords — because the API is not always right for a disclosure.
## HTML
```html
<section class="vtr-12" aria-labelledby="vtr-12-heading">
<fieldset class="vtr-12__theme">
<legend class="vtr-12__themelegend">Theme</legend>
<input class="vtr-12__themeinput" type="radio" name="vtr-12-theme" id="vtr-12-theme-auto" checked>
<label class="vtr-12__themeopt" for="vtr-12-theme-auto">Auto</label>
<input class="vtr-12__themeinput" type="radio" name="vtr-12-theme" id="vtr-12-theme-light">
<label class="vtr-12__themeopt" for="vtr-12-theme-light">Light</label>
<input class="vtr-12__themeinput" type="radio" name="vtr-12-theme" id="vtr-12-theme-dark">
<label class="vtr-12__themeopt" for="vtr-12-theme-dark">Dark</label>
</fieldset>
<div class="vtr-12__stage">
<header class="vtr-12__head">
<p class="vtr-12__kicker">Sable Support</p>
<h2 class="vtr-12__heading" id="vtr-12-heading">Height, without a measurement</h2>
<p class="vtr-12__sub">The panel is named. The open state is toggled. Nothing reads scrollHeight.</p>
</header>
<div class="vtr-12__acc" id="vtr-12-acc">
<div class="vtr-12__item" data-open="true">
<button class="vtr-12__trigger" type="button" aria-expanded="true" data-vtr12-toggle>
<span>How do I move a workspace to another region</span>
<span class="vtr-12__chev" aria-hidden="true"></span>
</button>
<div class="vtr-12__panel">
<p>Open the workspace settings, choose the destination region and confirm. Sable copies object storage first, then cuts over the database in a single write-locked window of about ninety seconds.</p>
<p>Existing API keys keep working. Region-pinned endpoints do not, so update any hardcoded hostnames before the cutover.</p>
</div>
</div>
<div class="vtr-12__item">
<button class="vtr-12__trigger" type="button" aria-expanded="false" data-vtr12-toggle>
<span>What happens to scheduled jobs during a plan change</span>
<span class="vtr-12__chev" aria-hidden="true"></span>
</button>
<div class="vtr-12__panel">
<p>Jobs already running finish on the old plan's limits. Anything queued starts under the new limits, which can mean a shorter timeout — check the concurrency ceiling before downgrading.</p>
</div>
</div>
<div class="vtr-12__item">
<button class="vtr-12__trigger" type="button" aria-expanded="false" data-vtr12-toggle>
<span>Can I export audit logs on the Team plan</span>
<span class="vtr-12__chev" aria-hidden="true"></span>
</button>
<div class="vtr-12__panel">
<p>Yes, as newline-delimited JSON for the last ninety days. Longer retention and streaming export to a bucket are Scale features.</p>
</div>
</div>
</div>
<div class="vtr-12__alts">
<p class="vtr-12__altstitle">When not to use this</p>
<ul class="vtr-12__altlist">
<li><code>grid-template-rows: 0fr to 1fr</code><span>Pure CSS, no API, works everywhere. Better for a lone disclosure.</span></li>
<li><code>interpolate-size: allow-keywords</code><span>Cleanest syntax, transitions height: auto directly. Chrome only today.</span></li>
<li><code>view-transition-name</code><span>Worth it when the open state also moves siblings, badges or icons.</span></li>
</ul>
</div>
</div>
</section>
```
## CSS
```css
.vtr-12 {
--bg: #f5f1eb;
--surface: #fbf8f4;
--line: #ddd2c4;
--ink: #2b2118;
--muted: #6f6053;
--accent: #8a6a4b;
--accent-soft: #ece1d4;
--font-display: "Bookman Old Style", Rockwell, Georgia, serif;
width: 100%;
min-height: 100vh;
display: block;
box-sizing: border-box;
background: var(--bg);
color: var(--ink);
font-family: system-ui, sans-serif;
color-scheme: light dark;
}
.vtr-12 *,
.vtr-12 *::before,
.vtr-12 *::after {
box-sizing: border-box;
}
@supports (color: oklch(0% 0 0)) {
.vtr-12 {
--bg: oklch(95.5% 0.014 75);
--surface: oklch(97.5% 0.01 75);
--line: oklch(86% 0.024 72);
--ink: oklch(25% 0.025 60);
--muted: oklch(50% 0.028 62);
--accent: oklch(55% 0.06 62);
--accent-soft: oklch(90% 0.024 70);
}
}
@media (prefers-color-scheme: dark) {
.vtr-12 {
--bg: #161210;
--surface: #1e1815;
--line: #322821;
--ink: #f2e8de;
--muted: #a89684;
--accent: #c9a37a;
--accent-soft: #2a211a;
}
}
.vtr-12[data-theme="light"] {
--bg: #f5f1eb;
--surface: #fbf8f4;
--line: #ddd2c4;
--ink: #2b2118;
--muted: #6f6053;
--accent: #8a6a4b;
--accent-soft: #ece1d4;
}
.vtr-12[data-theme="dark"] {
--bg: #161210;
--surface: #1e1815;
--line: #322821;
--ink: #f2e8de;
--muted: #a89684;
--accent: #c9a37a;
--accent-soft: #2a211a;
}
.vtr-12__stage {
max-width: 48rem;
margin: 0 auto;
padding: clamp(1.25rem, 4vw, 2.75rem) clamp(0.9rem, 4vw, 2rem);
display: grid;
gap: 1.1rem;
}
.vtr-12__kicker {
margin: 0 0 0.3rem;
font-size: 0.68rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--accent);
}
.vtr-12__heading {
margin: 0;
font-family: var(--font-display);
font-size: clamp(1.3rem, 4.2vw, 1.8rem);
font-weight: 700;
letter-spacing: -0.01em;
}
.vtr-12__sub {
margin: 0.3rem 0 0;
font-size: 0.86rem;
color: var(--muted);
}
.vtr-12__acc {
border-top: 1px solid var(--line);
}
.vtr-12__item {
border-bottom: 1px solid var(--line);
}
.vtr-12__trigger {
width: 100%;
min-height: 44px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.7rem 0.15rem;
border: 0;
background: none;
color: var(--ink);
font-family: var(--font-display);
font-size: 0.92rem;
font-weight: 700;
text-align: left;
cursor: pointer;
}
.vtr-12__trigger:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.vtr-12__trigger[aria-expanded="true"] {
color: var(--accent);
}
.vtr-12__chev {
flex: none;
width: 0.6rem;
height: 0.6rem;
border-right: 2px solid currentColor;
border-bottom: 2px solid currentColor;
transform: rotate(45deg);
}
.vtr-12__trigger[aria-expanded="true"] .vtr-12__chev {
transform: rotate(-135deg);
}
.vtr-12__panel {
height: 0;
overflow: hidden;
}
.vtr-12__item[data-open="true"] .vtr-12__panel {
height: auto;
padding-bottom: 0.8rem;
view-transition-name: vtr-12-panel;
}
.vtr-12__panel p {
margin: 0 0 0.6rem;
padding-right: 1rem;
font-size: 0.85rem;
line-height: 1.6;
color: var(--muted);
max-width: 38rem;
}
.vtr-12__panel p:last-child {
margin-bottom: 0;
}
.vtr-12__alts {
padding: 0.85rem 0.9rem;
background: var(--surface);
border: 1px solid var(--line);
}
.vtr-12__altstitle {
margin: 0 0 0.5rem;
font-size: 0.68rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent);
}
.vtr-12__altlist {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 0.5rem;
}
.vtr-12__altlist li {
display: grid;
gap: 0.1rem;
min-inline-size: 0;
}
.vtr-12__altlist code {
font-family: ui-monospace, Menlo, monospace;
font-size: 0.72rem;
word-break: break-word;
}
.vtr-12__altlist span {
font-size: 0.76rem;
color: var(--muted);
}
::view-transition-group(vtr-12-panel) {
animation-duration: 380ms;
animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
}
@media (prefers-reduced-motion: reduce) {
::view-transition-group(vtr-12-panel) {
animation-duration: 1ms;
}
}
/* Theme switch (added 2026-08-24). Pure CSS: three radios + :has(). Auto
follows the OS and the site's [data-theme] stamp; Light and Dark pin the
palette. Values are the demo's own [data-theme] palettes reused verbatim,
so the toggle can never drift from the site-theme rendering. */
.vtr-12 {
position: relative;
}
.vtr-12__theme {
position: absolute;
inset-block-start: 0.875rem;
inset-inline-end: 0.875rem;
z-index: 9;
display: flex;
align-items: center;
gap: 0.125rem;
margin: 0;
padding: 0.1875rem;
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
border-radius: 999px;
background: color-mix(in srgb, currentColor 8%, transparent);
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
font-family: system-ui, sans-serif;
}
.vtr-12__themelegend {
position: absolute;
inline-size: 1px;
block-size: 1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
}
.vtr-12__themeinput {
position: absolute;
inline-size: 1px;
block-size: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
}
.vtr-12__themeopt {
display: inline-flex;
align-items: center;
justify-content: center;
min-block-size: 2.25rem;
min-inline-size: 2.75rem;
padding: 0 0.6875rem;
border-radius: 999px;
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: inherit;
opacity: 0.55;
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.vtr-12__themeopt:hover {
opacity: 0.85;
}
.vtr-12__themeinput:checked + .vtr-12__themeopt {
opacity: 1;
background: color-mix(in srgb, currentColor 15%, transparent);
}
.vtr-12__themeinput:focus-visible + .vtr-12__themeopt {
outline: 2px solid currentColor;
outline-offset: 2px;
}
/* Explicit pins beat both the OS query and the site stamp. */
.vtr-12:has(#vtr-12-theme-light:checked) {
--bg: #f5f1eb;
--surface: #fbf8f4;
--line: #ddd2c4;
--ink: #2b2118;
--muted: #6f6053;
--accent: #8a6a4b;
--accent-soft: #ece1d4;
}
.vtr-12:has(#vtr-12-theme-dark:checked) {
--bg: #161210;
--surface: #1e1815;
--line: #322821;
--ink: #f2e8de;
--muted: #a89684;
--accent: #c9a37a;
--accent-soft: #2a211a;
}
```
## JavaScript
```js
(() => {
const root = document.querySelector('.vtr-12');
if (!root || root.dataset.vtr12) return;
root.dataset.vtr12 = 'on';
const items = [...root.querySelectorAll('.vtr-12__item')];
const reduce = matchMedia('(prefers-reduced-motion: reduce)');
let busy = false;
const set = (item) => {
const opening = item.dataset.open !== 'true';
items.forEach(i => {
const on = i === item && opening;
i.dataset.open = String(on);
i.querySelector('.vtr-12__trigger').setAttribute('aria-expanded', String(on));
});
};
items.forEach(item => item.querySelector('[data-vtr12-toggle]').addEventListener('click', async () => {
if (busy) return;
if (!document.startViewTransition || reduce.matches) { set(item); return; }
busy = true;
try { await document.startViewTransition(() => set(item)).finished; } finally { busy = false; }
}));
})();
```How this works
The panel's height is never written down. Add view-transition-name to the panel, flip its open state inside the callback, and the browser captures the collapsed rectangle and the expanded one and interpolates between them. There is no scrollHeight read, no max-height: 1000px approximation and nothing to correct when the content changes length.
The alternatives are real and sometimes better. grid-template-rows: 0fr transitioning to 1fr animates a genuine intrinsic height with pure CSS and no script at all — for a simple disclosure it is the better choice, because it needs no API and works in every current engine. interpolate-size: allow-keywords makes height: auto itself transitionable, which is the cleanest syntax of the three but is currently Chrome-only.
Reach for the API when more than the height is moving. The advantage here is coordination: if opening a panel also moves a badge, reorders siblings or swaps a chevron, one transition animates all of it consistently. If the only thing changing is one panel's height, the pure-CSS grid technique is less machinery for the same result.
The trap is naming a panel that is display: none. An element with no box is not captured, so the collapsed state has no old snapshot and the panel fades in at full height instead of growing. Collapse with zero height and overflow: hidden, or with the grid-row technique, so both states have a rectangle to capture.
Make it yours
- Allow several panels open at once by removing the single-open constraint in the toggle handler.
- Swap the collapse mechanism from zero height to
grid-template-rows: 0frand compare the two directly. - Add a rotating chevron with its own name so the icon animates as its own group.
- Retune the expansion with
::view-transition-group(vtr-12-panel)and a slower easing. - Change
--accentto recolour the open row, the chevron and the focus ring. - Deep-link an open panel by reading a hash on load and applying the state without a transition.
Gotchas — read before shipping
- A panel collapsed with
display: nonehas no box to capture, so it fades in at full size instead of growing. - Naming every panel rather than only the one changing creates duplicate-name aborts once two are open.
- Reduced-motion users still need the panel to open — degrade to the finished state, not to nothing.
interpolate-size: allow-keywordsis Chrome-only today, so the comparison is worth discussing but not depending on.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 129+ | not yet | not yet | 129+ |
Floor set by :has(), oklch(), color-mix(), backdrop-filter, interpolate-size as this demo is written. The core technique itself goes back further — Chrome 111+.
The floor is document.startViewTransition() at Chrome 111 and Safari 18, with Firefox not yet shipping View Transitions — panels there open and close instantly with correct aria-expanded state. The interpolate-size: allow-keywords alternative discussed in the copy is Chrome 129+ and unshipped in Safari and Firefox, but it is compared rather than used, so it does not gate this demo. The grid-template-rows: 0fr to 1fr alternative is Baseline and works everywhere.