Animating width: auto for Buttons and Labels

Part of Discrete & Intrinsic-Size Animation in Modern View Transitions & Scroll APIs.

The problem

A button reads “Save”. When clicked it reads “Saving…”, then “Saved”. Each label is a different width, so the button snaps between three sizes, and anything beside it in the toolbar jumps too. A tag chip shows an icon and expands to show its full name on hover, which also snaps. Hard-coding widths breaks translations; measuring text in script adds layout reads to every state change.

Width animation has the same root problem as height: auto is a keyword, not a number, and it only interpolates with an opt-in.

Root cause analysis: inline sizes are content-derived

inline-size: auto resolves in layout. A button’s width comes from its content. Changing the label changes that width, but there is no transition because auto to auto is not a change in computed value — the computed value stays auto while the used value changes. A transition only fires when the computed value changes.

So the start value has to be made explicit. interpolate-size: allow-keywords lets a transition run to or from auto, but something must change in the computed value first. Where the states genuinely differ — a chip at 2rem collapsed and auto expanded — that happens naturally. Where only the content changes, pin the current width as a pixel value, change the label, and release inline-size back to auto on the next frame. The transition then runs from the pinned pixel width to the new content width.

Text wrapping ruins mid-animation frames. While the button is narrower than its new label, the text wraps onto two lines unless white-space: nowrap and clipping are set, making the button briefly taller.

Inline layout means neighbours move. A button in a toolbar pushes its siblings each frame. That is a per-frame layout cost, small for a toolbar and larger if the button sits in a long line of text, the trade-off covered in expand and collapse techniques compared.

Animating a label change from width to widthPin the current width, change content, release to auto.Animating a label change from width to widthRead currentwidthone layout readPininline-size inpxSwap labeltextNext frame:inline-sizeautoTransition pxto autointerpolate-size
Pin the current width, change content, release to auto.

Step-by-step resolution

A button that grows and shrinks with its labelOne measurement per change, CSS does the rest.A button that grows and shrinks with its label1Set interpolate-size: allow-keywords on :root.auto becomes a transition target2Give the button nowrap text and overflow clip.No wrapping mid-animation3On state change, read the button's current width and pin it inline.A numeric start value4Swap the label, then release inline-size to auto on the next frame.Transition to the new content width5Fade the label text with a short opacity transition.No abrupt text swap6Announce the new label through the button's accessible name or a live region.
One measurement per change, CSS does the rest.

Production code pattern

:root { interpolate-size: allow-keywords; }

.save-button {
  white-space: nowrap;
  overflow: clip;
  inline-size: auto;
  transition: inline-size var(--duration-200) var(--ease-standard);
}
.save-button__label {
  display: inline-block;
  transition: opacity var(--duration-100) linear;
}
.save-button[data-swapping] .save-button__label { opacity: 0; }

/* Hover-expanding chip: auto on both sides of a real change in computed value. */
.chip {
  inline-size: 2rem;                           /* collapsed: icon only */
  overflow: clip;
  white-space: nowrap;
  transition: inline-size var(--duration-200) var(--ease-decelerate);
}
.chip:is(:hover, :focus-visible) {
  inline-size: auto;                            /* 2rem to auto: computed value changes */
}

@media (prefers-reduced-motion: reduce) {
  .save-button, .save-button__label, .chip { transition: none; }
}
async function setLabel(button, text) {
  const label = button.querySelector('.save-button__label');
  button.style.inlineSize = `${button.getBoundingClientRect().width}px`;   // pin current width
  button.toggleAttribute('data-swapping', true);
  await new Promise((r) => setTimeout(r, 100));                            // label faded out
  label.textContent = text;
  button.toggleAttribute('data-swapping', false);
  requestAnimationFrame(() => { button.style.inlineSize = ''; });          // release to auto
}

Rendering Impact: layout per frame for the button and its line; composite for the label fade. One getBoundingClientRect() per label change is a single forced layout, taken before any writes.

The chip does not need script because its states differ in computed value: 2rem collapsed and auto expanded. The button does, because both of its states are auto and only the content differs — pinning a pixel width creates a computed-value change for the transition to animate.

Layout cost per frame while the button resizesMid-range phone; cost depends on what shares the line.Layout cost per frame while the button resizesButton alone in a toolbar0.4 msButton in a 12-item toolbar1.3 msInline button inside a paragraph3.8 ms
Mid-range phone; cost depends on what shares the line.

Accessibility of changing labels

A button whose visible label changes should also change its accessible name, which happens automatically when the text content is the name. Screen readers do not always announce name changes on a focused button, so a status such as “Saved” is best also sent to a polite live region. Avoid animating the width of a focused button so dramatically that its focus ring jumps; with clipping on the button, draw the ring with an outline, which is not clipped by overflow: clip on the element itself, as accessible focus-ring patterns describe.

Verification checklist

Constraints and trade-offs

  • Browsers without interpolate-size snap to the new width; the label change still works.
  • Pinning widths inline temporarily overrides responsive styles; release promptly.
  • Width animation costs layout per frame for everything on the same line.
  • Very large width changes can push toolbar items onto a new line mid-animation.
  • Translations change widths dramatically; test with the longest locale.

Frequently asked questions

Why doesn’t my button animate when its text changes?

The computed width is auto before and after, so there is no change for a transition to animate. Pin the current width in pixels, change the text, then release to auto.

Do I need JavaScript to animate width to auto?

Not when the states differ in computed value, such as a fixed collapsed width and auto. Label swaps within auto need one measurement.

How do I stop text wrapping while the width animates?

Set white-space: nowrap and overflow: clip on the element so text stays on one line and excess is hidden.

Is animating width expensive?

It triggers layout every frame for the element and its line. That is fine for a button in a toolbar and costly inside long runs of text.