Morph Label
Change a label with a crossfade while its container adjusts to the new width.
Use it for controls that say something new once they're used: copy feedback, a follow toggle, a status chip, a save button that reports how it went. The label changes in place, so the eye stays where it was. Don't use it for content longer than a line, or for text that changes on a timer while nobody is interacting. It's for a change of state that someone caused.
Installation
npm i @youj/motionFor an app already set up with npx @youj/cli init, which installs the peers (@youj/core, @youj/motion, motion and Base UI) and wires the provider. New app? Start at Installation.
Usage
Key the label with value, and spread useMorphRoot on the box around it.
import { useState } from 'react'
import { m } from 'motion/react'
import { MotionFeatures } from '@youj/core'
import { MorphLabel, useMorphRoot } from '@youj/motion'
import { CheckIcon, CopyIcon } from '@youj/icons'
export function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false)
const morph = useMorphRoot(8) // the box's corner radius, in px
return (
<MotionFeatures features="max">
<m.button
{...morph}
type="button"
onClick={() => {
navigator.clipboard.writeText(text)
setCopied(true)
}}
className="relative inline-flex h-8 items-center overflow-hidden rounded-md border px-3 text-sm"
>
<MorphLabel value={copied ? 'copied' : 'copy'} contentClassName="gap-1.5">
{copied ? <CheckIcon trigger="mount" size={14} /> : <CopyIcon size={14} />}
{copied ? 'Copied' : 'Copy'}
</MorphLabel>
</m.button>
</MotionFeatures>
)
}valuekeys the content. A new value crossfades to the new children. The same value re-renders in place, with no crossfade, so a count inside a label can update quietly.- Give the box
overflow-hidden. The outgoing label leaves the layout at once and fades where it was, so without clipping it would spill past a box that's shrinking. contentClassNamestyles each state's row. The default row isinline-flex items-center gap-2 whitespace-nowrap. Pass the gap your control uses, such asgap-1.5.
Examples
The box
useMorphRoot(radius) returns the props for the element that morphs: layout, a layout transition on the same spring the label uses, and an inline borderRadius. Spread them on an m.button, an m.span or any other Motion element, inside <MotionFeatures features="max"> (layout animation needs the full feature set). The box's width animates as a transform while the label counter-scales, so text never squashes, and the inline radius keeps the corners round while the box scales.
With a Base UI part, render the Motion element through render:
const morph = useMorphRoot(6)
<ButtonPrimitive render={<m.button {...morph} />}>
<MorphLabel value={state}>…</MorphLabel>
</ButtonPrimitive>Without a morph root, the label still crossfades and glides to its new position, but the box around it jumps to its new width.
Async states
Key the label by the state of the work. Hold the outcome for a beat on the token grid, here k.ms('deliberate') * 3, so it scales with Speed. Then return to rest. Announce the outcome from a live region, because a changed label isn't read out on its own.
For the full pattern, with a spinner, a drawn check, an error shake and announcements for each state, use Async Feedback. It's built on MorphLabel.
Effects on the label
ref points at an inner span that stays the same element across values. Animate it for a shake or a nudge. It sits inside the layout projection rather than on it, so the effect doesn't fight the box's morph, and it can start on one value and finish on the next. Submit a wrong code below.
const label = useRef<HTMLSpanElement>(null)
if (label.current && !k.reduced) {
animate(label.current, { x: [0, -4, 4, -3, 3, -2, 0] }, k.tween('base'))
}
<MorphLabel ref={label} value={state}>…</MorphLabel>Reference
Propsvalue, children, contentClassName… (8)
Props
MorphLabel
| Prop | Type | |
|---|---|---|
valueRequired | KeyIdentifies the content. A new value crossfades to the new children. | – |
childrenRequired | ReactNode | – |
className | stringClasses for the positioned wrapper. | – |
contentClassName | stringClasses for each state's content row. Default row: | – |
ref | Ref<HTMLSpanElement>The inner span, stable across values: animate it (a shake, a nudge) without fighting the layout projection. | – |
rise | booleanHow a new state arrives. default false | false |
mode | 'label' | 'letters'
default 'label' | 'label' |
useMorphRoot
useMorphRoot(radius?: number | string) returns these props. Spread all of them on the box.
| Prop | Type | |
|---|---|---|
layoutRequired | true | – |
transitionRequired | { layout: Transition } | – |
style | CSSProperties | – |
MotionCrossfade, width morph · reduced motion
Motion
| Moment | Tokens | ||
|---|---|---|---|
Crossfadeopacity, scale from 0.9 and a 2px blur, both ways | duration.fastease.standard140ms · cubic-bezier(0.2, 0, 0, 1) | 140mscubic-bezier(0.2, 0, 0, 1) | |
| Width morphthe box and the label, as a transform | spring.snappy0.2s, bounce 0 | 0.2s, bounce 0 |
Reduced motion
The new label replaces the old one at once, and the box takes its new width in the same frame.
Both halves share the snappy spring, which is why the box needs useMorphRoot rather than a transition of its own. On different curves, the label would drift against the edge of the box mid-morph.
Reduced motion
Every swap is instant. The new label and the box's new width arrive in the same frame, and any effect on the ref should check k.reduced and skip itself, as the shake above does. What the control says is unchanged.
AccessibilityARIA, announcements
Accessibility
- Only the current label is in the accessibility tree. The outgoing row is
aria-hiddenwhile it fades, so a button's accessible name is never both labels at once. - A changed label isn't announced on its own. When the change is news, such as "Copied" or "Saved", say it from a
role="status"region outside the button. A button flattens its content, so a live region inside it isn't heard. - Don't pair a changing label with
aria-pressed. A toggle button's name should stay the same while its pressed state changes. When the label itself states the state, as Follow and Following do, leavearia-pressedoff. - Youj icons inside the label are decorative by default, so the text carries the meaning.