Skip to content

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/motion

import { MorphLabel } from '@youj/motion'

For 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>
  )
}
  • value keys 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.
  • contentClassName styles each state's row. The default row is inline-flex items-center gap-2 whitespace-nowrap. Pass the gap your control uses, such as gap-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.

Reference

Propsvalue, children, contentClassName… (8)

Props

MorphLabel

MorphLabel props
PropType
valueRequiredKey

Identifies the content. A new value crossfades to the new children.

–
childrenRequiredReactNode–
classNamestring

Classes for the positioned wrapper.

–
contentClassNamestring

Classes for each state's content row. Default row: inline-flex items-center gap-2 whitespace-nowrap.

–
refRef<HTMLSpanElement>

The inner span, stable across values: animate it (a shake, a nudge) without fighting the layout projection.

–
riseboolean

How a new state arrives. false (default): it crossfades in place with a touch of scale and blur. true: it rises out of a blur while the old one lifts away, for labels that read as a step forward ("Send" → "Sent").

default false

false
mode'label' | 'letters'

'label' (default) swaps the whole content. 'letters', for plain-text children: letters the two labels share at the start or end (two or more) stay put and glide to their new place, and only the rest leave or arrive, one after another ("Copy" → "Copied", "Save" → "Saved").

default 'label'

'label'

useMorphRoot

useMorphRoot(radius?: number | string) returns these props. Spread all of them on the box.

MorphRootProps props
PropType
layoutRequiredtrue–
transitionRequired{ layout: Transition }–
styleCSSProperties–
MotionCrossfade, width morph · reduced motion

Motion

Motion tokens
MomentTokens
Crossfadeopacity, scale from 0.9 and a 2px blur, both ways
duration.fastease.standard

140ms · cubic-bezier(0.2, 0, 0, 1)

140mscubic-bezier(0.2, 0, 0, 1)
Width morphthe box and the label, as a transform
spring.snappy

0.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-hidden while 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, leave aria-pressed off.
  • Youj icons inside the label are decorative by default, so the text carries the meaning.