Skip to content
WANCRAFT

A.33Buttons & Controls

Stateful Submit Button

A submit button that morphs through loading, success and error with a spring-driven width and drawn icons.

NewReactMotionSVGTailwindintermediate

Overview

Stateful Submit Button gives async actions a single, honest home. Pass an async onAction and it shows a spinner while the promise is pending, draws a check or a cross when it settles, announces each step through a polite live region and returns to idle after a delay. Or drive it yourself with the status prop. The width, label and icon morph with a shared layout spring, and the button keeps focus while busy because it uses aria-disabled rather than disabled.

01Installation

Method 01 · Registry

CLI verified

Uses the shadcn CLI in a project initialised with shadcn init. Installs the file to components/ui/stateful-button.tsx and adds its dependencies.

npx shadcn@latest add https://wancraft.my/r/stateful-button.json

Method 02 · Copy and paste

  1. 1. Install the dependencies below.
  2. 2. Copy the source from the Code tab into components/ui/stateful-button.tsx.
  3. 3. Import it and adjust props.
npm install motion
Dependencies
motion
Peer
react ≥ 18, tailwindcss ≥ 4
Added
13 Oct 2026
Updated
—

02Usage

The default configuration. Change properties above to generate a tailored example in the Usage tab.

example.tsx
import { StatefulButton } from "@/components/ui/stateful-button";

export function StatefulButtonExample() {
  return (
    <StatefulButton
      onAction={() => new Promise((resolve) => setTimeout(resolve, 1200))}
    >
      Save changes
    </StatefulButton>
  );
}

03Props

PropTypeDefaultDescription
onAction() => Promise<unknown> | unknown—Uncontrolled mode: called on click. Resolve → success, reject → error; resets to idle after resetDelay.
status"idle" | "loading" | "success" | "error"—Controlled mode: the state to render. You handle timing and resets.
onStatusChange(status) => void—Called on every status change in either mode.
onError(error: unknown) => void—Receives the rejection reason when onAction fails.
childrenReactNode"Save changes"Idle label.
loadingLabelstring"Saving…"Label shown and announced while loading.
successLabelstring"Saved"Label shown and announced on success.
errorLabelstring"Try again"Label shown and announced on error. Clicking again retries.
resetDelaynumber2000Time in ms before success / error return to idle (uncontrolled).
minLoadingTimenumber400Minimum time in ms the spinner is shown so fast actions do not flash.
collapsebooleanfalseShrink to a round spinner while loading (the label stays available to screen readers).
spring{ stiffness: number; damping: number; mass: number }{ stiffness: 420, damping: 32, mass: 0.8 }Spring for the width morph.
accentstring"#FF7448"Fill while idle / loading and the focus ring colour.
successColor / errorColorstring"#B6F3D1" / "#F87171"Fills for the result states.
inkColorstring"#0B0D0F"Label colour on solid fills.
variant"solid" | "outline""solid"Solid fill, or outline with tinted result states.
iconReactNode—Optional icon before the idle label.
...propsButtonHTMLAttributes—Forwarded to the <button> (type, form, onClick, disabled…). The ref points to the <button>.

04Features

  • 01Controlled (status) or fully automatic (async onAction) modes
  • 02Width morphs between labels with a motion layout spring
  • 03Spinner, then a check or cross that draws itself in; a small shake on error
  • 04Minimum loading time prevents flicker on fast responses
  • 05Stale results are ignored if the user clicks again or the button unmounts
motion · layoutmotion · springmotion · tweeninput · clickinput · keyboard

05Accessibility

  • Native <button>; while loading it is aria-disabled and aria-busy instead of disabled, so keyboard focus is never lost.
  • Clicks during loading are ignored (and prevented, so a submit button cannot double-submit).
  • A polite role="status" region announces the loading, success and error labels.
  • When collapsed, the loading label remains in the button as screen-reader text.
  • With prefers-reduced-motion the width changes instantly, labels crossfade, the spinner stops rotating and icons appear without drawing.

06Limitations

  • In controlled mode the button does not reset itself — set status back to "idle" when you are ready.