Animated Label
Text that rolls when it changes state: the outgoing label lifts out while the incoming one rises from below, and the box eases between the two widths. The prose sibling of Animated Number: Next to Done, Weak to Strong.
Installation#
Usage#
import { AnimatedLabel } from "@/components/ui/animated-label"
export function Example({ expanded }: { expanded: boolean }) {
// `swapKey` is what says the label now means something else; children alone
// are new elements on every render, so they can't tell a state change apart.
return (
<AnimatedLabel swapKey={expanded ? "less" : "more"}>
{expanded ? "Show less" : "Show more"}
</AnimatedLabel>
)
}Inside a button#
When the whole label swaps, don't reach for this: give Button a swapKey and it wraps its children for you. Reach for AnimatedLabel when only part of the button should roll, the case below. swapKey boxes every child into the swap, so an icon left inside it dissolves and re-appears rather than turning; a caret has to sit outside the roll to rotate. The label still carries the width change, so the button morphs and the caret rides along.
This is the shape used by LoadMoreTrigger, CodeSnippet and the Chat reasoning trigger. The caret takes caret-turn, the shared disclosure beat - see Motion.
Alignment#
The box is briefly a different width than the label it holds (that's the morph), so the label needs to know which edge to hold on to. align defaults to center, which is right inside a button. Left-aligned copy wants start, or the text drifts sideways as the box eases.
How it works#
Two layers: the current label in normal flow, so it alone sizes the box, and a snapshot of the outgoing one taken out of flow on top of it, so it can leave without widening anything. Both run the label-swap-* keyframes at --duration-base, with the opacity front-loaded against the travel (solid by 45%) the same way the number roll does it: the two labels sit on top of each other, so a long half-visible cross-fade would read as one smeared word. The snapshot is aria-hidden and dropped as soon as its roll ends, so the accessible name flips straight to the new label.
The width is the part CSS can't express on its own, so it's driven from a layout effect: pin the old label's width, hand over the new one, release back to auto once it lands. The start value is read off the snapshot rather than remembered, so it can't go stale, and interrupting a morph starts from the width that's on screen rather than the one it was heading for. The first paint never rolls, and under prefers-reduced-motion the label simply swaps.
API reference#
AnimatedLabel
Renders one inline <span> (the layers inside are managed for you). Props: swapKey (identifies the label on screen; a change rolls it), align (center · start · end, which edge the label holds while the box eases), className (styles the label, e.g. size / weight / color), plus all <span> props.