Buttons & Toggles

Stateful Button

A state-machine button running idle → loading → success / error, switching automatically from an async onClick, with the content crossfading and the width morphing along with it.

Bring this into your project

Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.

npx webberui@latest add stateful-button.en

New project or installation trouble? Start here →

Stateful Button is a React loading button component built with Motion and Tailwind CSS. When onClick returns a Promise it switches to loading, success, and error states on its own, which suits async actions such as saving or submitting a form.

Playground

Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.

Active edited usageDefault props
<StatefulButton/>

The complete example includes the preview layout and data. Install the component first, then paste this file into your project.

When to use

  • A "Save changes" button on a settings page
  • Submitting a contact or registration form
  • Single API actions such as "Send verification email" or "Resync"
  • Row actions like publish or archive in an admin list
  • Not a good fit: irreversible actions such as payments or deletions that need a deliberate confirmation; pair them with Slide to Unlock or Hold to Confirm first

Installation

npx webberui@latest add https://webberui.com/r/stateful-button.en.json

Or, once registries are configured in components.json, install it as @webberui/stateful-button.

Usage

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

async function save() {
  await fetch("/api/save", { method: "POST" });
}

<StatefulButton onClick={save} successChildren="Saved" errorChildren="Failed">
  Save
</StatefulButton>;

As long as onClick returns a Promise, clicking moves the button into loading automatically; once the Promise resolves it shows success, once it rejects it shows error, and after 1.5 seconds it returns to idle on its own.

Controlled mode

Passing state switches it to controlled mode, which turns off the built-in automatic transitions and reset — the state is entirely driven from outside:

const [state, setState] = React.useState<"idle" | "loading" | "success" | "error">("idle");

<StatefulButton state={state} onClick={() => setState("loading")}>
  Submit
</StatefulButton>;

Measured width mode

Pass morph="measure" for a different transition: the component measures the next state's content with a hidden probe span and a ResizeObserver (adding the button's padding and border, then rounding up), and animates width with a spring (bounce: 0, duration: 0.26); the label and icon swap vertically — the new content slides in 8px from below while the old content fades out 8px upward, over 0.18s. When the success and error labels differ a lot in length, the width change is smoother than the default layout animation, and the content is never scaled mid-morph.

<StatefulButton
  morph="measure"
  onClick={send}
  successChildren="Verification email delivered"
  errorChildren="Failed"
>
  Send verification email
</StatefulButton>

In measure mode the button width is set by the component as an inline style, so width classes such as w-full have no effect; children is rendered once more as a hidden copy for measuring, so avoid putting stateful elements or elements with an id in it. The default is morph="layout", which behaves exactly as before.

Examples

Show the error state for HTTP errors

fetch doesn't reject on 4xx or 5xx responses, so check res.ok and throw yourself to make the button switch to error.

async function saveProfile() {
  const res = await fetch("/api/profile", { method: "PUT" });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
}

<StatefulButton
  onClick={saveProfile}
  successChildren="Saved"
  errorChildren="Save failed, try again"
>
  Save changes
</StatefulButton>

With a Next.js Server Action

Calling a Server Action returns a Promise, so returning it from onClick drives the state machine.

"use client";

import { StatefulButton } from "@/components/ui/stateful-button";
import { publishPost } from "./actions"; // a "use server" file

export function PublishButton({ postId }: { postId: string }) {
  return (
    <StatefulButton onClick={() => publishPost(postId)} successChildren="Published">
      Publish post
    </StatefulButton>
  );
}

Controlled: stay on success

In uncontrolled mode, success and error return to idle after 1.5 seconds. To keep the success state, for example so a verification email isn't sent twice, switch to controlled mode and combine it with disabled.

"use client";

import * as React from "react";
import { StatefulButton } from "@/components/ui/stateful-button";

type State = "idle" | "loading" | "success" | "error";

export function SendVerification() {
  const [state, setState] = React.useState<State>("idle");

  async function send() {
    setState("loading");
    try {
      const res = await fetch("/api/verify-email", { method: "POST" });
      setState(res.ok ? "success" : "error");
    } catch {
      setState("error");
    }
  }

  return (
    <StatefulButton
      state={state}
      onClick={send}
      disabled={state === "loading" || state === "success"}
      successChildren="Verification email sent"
      errorChildren="Couldn't send, try again"
    >
      Send verification email
    </StatefulButton>
  );
}

Props

PropTypeDefaultDescription
state"idle" | "loading" | "success" | "error"—Controlled state; when provided, it is driven from outside and the built-in automatic transitions and reset are turned off
onClick(e) => void | Promise<unknown>—Click callback; when uncontrolled, returning a Promise runs the state machine automatically
childrenReact.ReactNode—Content shown in idle / loading
successChildrenReact.ReactNodechildrenContent for the success state
errorChildrenReact.ReactNodechildrenContent for the error state
disabledbooleanfalseDisables the button; when uncontrolled it is disabled automatically while loading
morph"layout" | "measure""layout"Width morph style: layout keeps the layout animation with a scale crossfade; measure measures the width and springs width, swapping content vertically

All other native <button> attributes are forwarded (except onDrag / onDragStart / onDragEnd / onAnimationStart / onClick).

State machine

  • idle: the default state, clickable, with slight scale feedback on press
  • loading: the spinner keeps rotating, and when uncontrolled the button is locked to prevent double submission
  • success: a green background, with the checkmark drawing itself in via pathLength
  • error: a red background, with the cross drawn in stroke by stroke and the whole button shaking from side to side once
  • Content switches crossfade with AnimatePresence (popLayout), and the button width morphs along with layout
  • With morph="measure", a hidden probe span measures the width, width animates with a spring, and the content swaps vertically

Accessibility

  • aria-busy marks the busy state while loading
  • A built-in aria-live="polite" status region announces "Processing…/Success/Error" as the state changes
  • When the user has "reduce motion" enabled at the system level, the rotation, shake, and offsets are disabled and the checkmark / cross appear instantly, while the content switch is kept so the meaning is preserved; with morph="measure" the width switches in 0s and the content only fades, without vertical movement

FAQ

Does it work with the Next.js App Router?

Yes. The file already starts with "use client", so you can import it from a Server Component page as usual; onClick is a function and needs to be passed from a client component.

Why doesn't it submit my form?

The button defaults to type="button". To submit a form with it, pass type="submit" explicitly; other native button attributes are passed through as well.

Can I change how long the success state stays?

Uncontrolled mode always returns to idle after 1.5 seconds, and there is no prop for it. For a different duration, or to stay on a state, use controlled mode and switch state yourself.

Can it be double-clicked?

In uncontrolled mode the button disables itself while loading to prevent duplicate submissions. Controlled mode doesn't disable automatically, so set disabled based on state yourself.

Do screen readers hear the state change?

Yes. The button carries aria-busy while loading, and a built-in aria-live="polite" region announces the loading, success, and error states as they change.