Expand content with an intrinsic-size fallback

How-to Last tested 24 October 2026

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

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

Sources

  1. MDN, interpolate-size. Living documentation, opened 24 October 2026. ↩

  2. MDN, CSS.supports(). Living documentation, opened 24 October 2026. ↩

  3. MDN, prefers-reduced-motion. Living documentation, opened 24 October 2026. ↩

  4. MDN, inert. Living documentation, opened 24 October 2026. ↩

  5. 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.