Expand content with an intrinsic-size fallback
Let expanding content keep its natural height, and treat interpolation as an enhancement to an ordinary disclosure. Use an immediate open-and-close path when interpolation is unavailable or unwanted, and move focus out of the panel before making its contents inactive.
Separate the disclosure from its animation
The example contains packing notes, a destination link, an action that adds another note and a Close button. The trigger identifies the panel and exposes expanded state. Opening and closing update that state whether or not a height transition runs.
The panel’s open size is auto. It can grow when notes are appended or text wraps differently. There is no guessed maximum height and no stored pixel height to revise after every content change. The chosen animation connects zero height to the current intrinsic size; it does not turn that size into an application constant.
The content begins as ordinary visible HTML. Script adds the collapsed state when enhancement starts. That keeps the markup’s meaning distinct from the enhanced interaction. The trigger and checkbox are controls for this demonstration; an application should decide which choices belong in its actual interface.
Opt into intrinsic interpolation locally
The interpolate-size property permits interpolation between a length or percentage and an intrinsic sizing keyword when set to allow-keywords. It does not permit interpolation between two intrinsic sizes.1 Here the endpoints are zero and auto, and the opt-in is scoped to the panel.
A CSS support condition adds the transition only when the declaration is supported. Outside that condition, the same closed and open classes still select zero and automatic height. The immediate fallback has the same trigger, content and closing routes as the animated version.
A support query is a declaration-support check, not an observation of every behavior a component needs.2 Keep it as the branch selector. The question of whether interrupted transitions, focus return and added content behave as intended belongs to the component’s own procedure.
The checkbox explicitly chooses instant changes by removing the transition. It is a useful way to inspect the nonanimated route without changing the browser. It is not a simulation of an older browser’s entire CSS implementation. The media query also removes the transition when the reduced-motion preference matches.3
Make collapsed content inactive immediately
Height zero only describes a box. It does not by itself define whether a descendant can be used. This implementation sets inert while the panel is collapsed. MDN describes inert descendants as unavailable for focus and interaction and excluded from the accessibility tree.4 That documented contract is separate from any particular assistive-technology result.
Before collapsing, setOpen checks whether the active element is inside the panel. If it is, the function requests focus on the trigger. It then marks the panel inert and applies the closed class. Closing from the trigger does not add an unnecessary focus move because focus is already outside the panel.
The CSS delays visibility becoming hidden until the configured closing transition ends. Inert state changes immediately, while the final visibility change handles presentation. A rapid reopening removes the closed class and makes visibility available again; no completion callback is waiting to hide a newly reopened panel.
The complete example
Save this original MIT-licensed HTML as a local file. Its only content-changing action appends a new original packing note. The demo includes the same component and the exact procedure used to inspect its cases.
<!doctype html>
<!-- SPDX-License-Identifier: MIT -->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Expand content with an instant fallback</title>
<style>
#panel { height: auto; overflow: clip; max-width: 32rem; visibility: visible; }
#panel.closed { height: 0; visibility: hidden; }
#inner { padding: 1rem; border: 2px solid #345; }
@supports (interpolate-size: allow-keywords) {
#panel { interpolate-size: allow-keywords; transition: height 400ms ease, visibility 0s; }
#panel.closed { transition-delay: 0s, 400ms; }
}
#panel.instant { transition: none; }
@media (prefers-reduced-motion: reduce) {
#panel { transition: none; }
}
:focus-visible { outline: 3px solid #045ac2; outline-offset: 3px; }
</style>
</head>
<body>
<button id="toggle" aria-expanded="true" aria-controls="panel">Packing notes</button>
<label>
<input id="instant" type="checkbox">
Use instant changes
</label>
<div id="panel">
<div id="inner">
<p>Keep the map and water where you can reach them.</p>
<a id="map" href="#destination">Read the destination note</a>
<button id="add">Add a packing note</button>
<button id="close">Close notes</button>
<div id="notes"></div>
</div>
</div>
<p id="destination">The destination note stays in the ordinary page flow.</p>
<button id="after">Continue planning</button>
<script>
const trigger = document.querySelector('#toggle');
const panel = document.querySelector('#panel');
const instant = document.querySelector('#instant');
const notes = document.querySelector('#notes');
let open = true, count = 0;
function setOpen(value) {
if (!value && panel.contains(document.activeElement)) trigger.focus();
open = value;
trigger.setAttribute('aria-expanded', String(open));
panel.inert = !open;
panel.classList.toggle('closed', !open);
}
trigger.addEventListener('click', () => setOpen(!open));
document.querySelector('#close').addEventListener('click', () => setOpen(false));
panel.addEventListener('keydown', event => {
if (event.key === 'Escape') setOpen(false);
});
instant.addEventListener('change', () => {
panel.classList.toggle('instant', instant.checked);
});
document.querySelector('#add').addEventListener('click', () => {
const note = document.createElement('p');
note.textContent = `Packing note ${++count}: leave room for the return trip.`;
notes.append(note);
});
setOpen(false);
</script>
</body>
</html>
Walk through the disclosure states
- Open from the trigger. Change expanded state to true, remove inert and remove the closed class. The opening code makes no request to focus the panel or its first link. The reader can continue through the controls in document order.
- Add a note while open. Append a paragraph inside the panel’s inner wrapper. Automatic height supplies the new size. This is not a transition between two explicitly measured pixel heights, and the code does not promise to animate every content edit.
- Close from inside. The Close action reaches the same state function as the trigger. Because focus belongs to the panel, request the trigger before setting inert. Escape from a descendant takes that same closing route.
- Reverse a transition. A second activation changes the requested state immediately. There is no delayed JavaScript callback that can overwrite the newer request. CSS owns the transition between the current presentation and the new endpoint.
- Choose instant changes. Remove the transition while keeping the same states and focus rule. The content remains governed by open or closed, rather than a second implementation with different markup.
Keep the inner wrapper responsible for padding and borders. The outer panel owns the changing height and clipping. Mixing a permanently padded outer box with a zero-height endpoint makes it harder to tell whether the collapsed space comes from the content or the container’s own box model.
Avoid a resize loop you do not need
This disclosure needs no ResizeObserver. It does not measure its content and then write that measurement back into the observed element. Leaving the open height intrinsic removes that particular read-and-write cycle from the implementation.
ResizeObserver notifications can cause further layout changes when callback code changes size. MDN explains that the browser’s loop protection defers some notifications; it does not repair a cyclic application dependency.5 Moving an unconditional size write to a later frame merely changes when it happens.
If a different design needs measured heights, separate the content being observed from the box whose height is written. Compare the requested value with the existing target, define who cancels an old transition, and stop observing when the component is removed. Those decisions are additions to this example, not hidden behavior supplied by the observer API.
Keep the content task understandable
The destination note remains outside the disclosure. The temporary panel therefore does not own every part of the planning task. Longer content still needs headings, useful link text and a sensible reading order; an expanding rectangle cannot supply those on its own.
Choose interpolation only when the movement helps explain the state change. This example uses a configured 400-millisecond transition, with no claim that the duration is optimal. The immediate route is equally valid when the reader needs the content rather than the movement.
Not covered on this page
- Animating between two intrinsic keywords or every live content edit.
- Measured-height observer implementations or virtualization.
- Nested modal panels, forms or application data persistence.
- Accessibility certification or a cross-browser performance ranking.
Sources
-
MDN, interpolate-size. Living documentation, opened 24 October 2026. ↩
-
MDN, CSS.supports(). Living documentation, opened 24 October 2026. ↩
-
MDN, prefers-reduced-motion. Living documentation, opened 24 October 2026. ↩
-
MDN, ResizeObserver. Living documentation, opened 24 October 2026. ↩
Code on this page is original and MIT licensed.
How we test: methods. Found a mistake? Corrections.