Motion UIComponentsComponent

Multi-state button

A button whose width, glyph and label morph as it moves between states.

npx shadcn@latest add @motion/multi-state-button

API reference

Components

<MultiStateButton />

A pill button whose content crossfades and whose width morphs on the compositor as its consumer-owned state changes, with an optional shake/pop when the state settles. Composes the per-state glyph + label the consumer supplies; times everything from the Motion UI theme.

state

string

The current state key - the consumer owns and advances the state machine. Each change keys the content crossfade, morphs the pill width and (if feedback is set) fires the settle feedback. Any string works: an "idle" / "loading" / "success" submit flow, a four-step cycle, etc.

children

ReactNode

The visible label for the current state - the text that morphs.

icon?

ReactNode

An optional leading glyph for the current state, rendered before the label (decorative, wrapped aria-hidden). Size and colour it from the glyph itself - typically an inlined spinner/check/cross per integration. Omit for a text-only state (e.g. an idle "Subscribe").

surfaceClassName?

string = "bg-primary text-primary-foreground"

Surface-colour classes for the CURRENT state, merged onto the morphing pill (e.g. "bg-primary text-primary-foreground" for a neutral state, "bg-success text-success-foreground" for a completed one). Changing this with state cross-fades the colour on the theme's snap curve. Default "bg-primary text-primary-foreground".

pillClassName?

string = "rounded-md px-5 py-3 text-sm font-medium"

Constant pill styling applied in EVERY state - shape, padding and text size - kept separate from surfaceClassName so the per-state colour is the only thing that changes. Default "rounded-md px-5 py-3 text-sm font-medium".

feedback?

MultiStateFeedback = "none"

Imperative feedback fired when state settles: "shake" for a failed action, "pop" for a completed one, "none" (default) for neither. Derive it from the current state (e.g. state === "failed" ? "shake" : state === "ready" ? "pop" : "none"). Never plays under reduced motion.

widthMorph?

boolean = true

Whether the pill animates its width on the compositor (via Motion layout projection) as the content changes. Default true; set false for a fixed-width button whose content swaps in place.

announce?

string

Accessible status text for the current state, announced through an internal aria-live="polite" region rendered alongside the button. Omit to supply your own live region (a form's shared status line, say). When set, also pass aria-label so the announcement does not leak into the button's accessible name.

"aria-label"?

string

Accessible name for the button. Set this when the visible label alone does not describe the action in every state (e.g. a submit whose label is just a glyph), so a screen-reader user hears the current action.

type?

"button" | "submit" = "button"

Button type. "button" (default) for a standalone action; "submit" for a form-embedded one whose form owns the submit.

disabled?

boolean

Disables the button (e.g. while a request is in flight). Adds pointer-events-none; add your own dimming via className if wanted.

onClick?

() => void

Click handler - advance your state machine, or leave undefined and let a type="submit" button's form handle it.

className?

string

Merged onto the button root (the focus target).