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.
Step-by-step resolution
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.
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-sizesnap 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.
Related
- Discrete & Intrinsic-Size Animation — the parent topic
- Animating height: auto with interpolate-size and calc-size() — the block-axis version
- Handling animationend and transitionend Reliably — sequencing the label swap safely