Status button

Save, Saving, Saved: the spinner opens in, the check draws itself, errors shake, and the label morphs between them.

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/status-button.json

Overview

The most common button in any product, and usually the least considered: it greys out, maybe shows a spinner, and snaps back. Here every change is one motion. A spinner slides open beside the label as it morphs to "Saving"; on success a check draws itself and the label becomes "Saved", keeping the letters they share; on failure the button gives a small shake and asks you to try again. Its width eases to each label, so nothing around it jumps.

States

idle
The label and, if you pass one, an icon.
pending
A spinner opens in from zero width; the label morphs ("Save changes" → "Saving"). Clicks are ignored and aria-busy is set.
success
The spinner blurs out as a check draws itself in 420ms; "Saved". onReset fires after resetAfter.
error
A 380ms shake, a soft red tint and an alert icon; "Try again".

Usage

import { StatusButton, type ActionStatus } from "@/components/status-button";

const [status, setStatus] = useState<ActionStatus>("idle");

<StatusButton status={status} onClick={save} onReset={() => setStatus("idle")} />

Works with

With a request

Drive status from your request; the button handles every transition.

"use client";
import { useState } from "react";
import { StatusButton, type ActionStatus } from "@/components/status-button";

export function SaveSettings({ values }: { values: Settings }) {
  const [status, setStatus] = useState<ActionStatus>("idle");

  const save = async () => {
    setStatus("pending");
    try {
      const res = await fetch("/api/settings", { method: "PUT", body: JSON.stringify(values) });
      setStatus(res.ok ? "success" : "error");
    } catch {
      setStatus("error");
    }
  };

  return <StatusButton status={status} labels={{ idle: "Save changes" }} onClick={save} onReset={() => setStatus("idle")} />;
}

Props

PropTypeDescription
status"idle" | "pending" | "success" | "error"default "idle"What the button shows.
labelsPartial<Record<status, string>>default Save, Saving, Saved, Try againWording per state.
iconReactNodeAn icon beside the idle label, e.g. a link icon for Copy link.
variant"primary" | "outline" | "ghost"default "primary"Filled with your brand colour, a hairline outline, or quiet text for toolbars.
size"default" | "sm"default "default"36px, or 28px for headers and toolbars (Copy in a code block, Undo in a receipt).
onReset / resetAfter() => void / numberdefault — / 1800Called that long after success, to go back to idle.

Accessibility and motion

  • State changes are announced from a status region beside the button, so the button’s name stays just its label.
  • While pending it is aria-busy and ignores clicks, without dimming or losing focus.
  • The check is a stroked path that draws itself; the shake uses the Web Animations API. No animation library.
  • Installs Text morph alongside it. With reduced motion, states change in place.

Requirements

React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style). Icons from lucide-react.

The CLI also installs Text morph.

Source

›Show the full source of status-button.tsx (184 lines)
"use client";

import * as React from "react";
import { TextMorph } from "./text-morph";

/* ─────────────────────────────────────────────────────────
 * STATUS BUTTON: a button that tells you what happened
 *
 *   idle     "Save", with an optional icon
 *   pending  a spinner opens in; the label morphs to "Saving"
 *   success  a check draws itself; "Saved"
 *   error    the button gives a small shake; "Try again"
 *
 * Every change is one motion: the icon slot opens and closes,
 * icons swap through a blur, letters the labels share stay put
 * and the button's width eases to fit.
 * ───────────────────────────────────────────────────────── */

export type ActionStatus = "idle" | "pending" | "success" | "error";

export interface StatusButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  status?: ActionStatus;
  /** Labels per state; defaults to Save, Saving, Saved, Try again. */
  labels?: Partial<Record<ActionStatus, string>>;
  /** Shown before the label while idle. */
  icon?: React.ReactNode;
  variant?: "primary" | "outline" | "ghost";
  /** "sm" is 28px tall, for toolbars and headers. */
  size?: "default" | "sm";
  /** Called this long after success (ms), e.g. to go back to idle. */
  onReset?: () => void;
  resetAfter?: number;
}

const LABELS: Record<ActionStatus, string> = { idle: "Save", pending: "Saving", success: "Saved", error: "Try again" };
const EASE = "cubic-bezier(0.16,1,0.3,1)";
const FOCUS = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background";

const reducedQuery = "(prefers-reduced-motion: reduce)";
const subscribeReduced = (onChange: () => void) => {
  const query = window.matchMedia(reducedQuery);
  query.addEventListener("change", onChange);
  return () => query.removeEventListener("change", onChange);
};
const useReducedMotion = () =>
  React.useSyncExternalStore(
    subscribeReduced,
    () => window.matchMedia(reducedQuery).matches,
    () => false,
  );

function Check({ drawn, reduced }: { drawn: boolean; reduced: boolean }) {
  return (
    <svg viewBox="0 0 16 16" fill="none">
      <path
        d="M3.5 8.5 6.5 11.5 12.5 4.5"
        stroke="currentColor"
        strokeWidth="2"
        strokeLinecap="round"
        strokeLinejoin="round"
        pathLength={1}
        strokeDasharray={1}
        style={{ strokeDashoffset: drawn ? 0 : 1, transition: drawn && !reduced ? `stroke-dashoffset 420ms ${EASE} 120ms` : "none" }}
      />
    </svg>
  );
}

function Alert() {
  return (
    <svg viewBox="0 0 16 16" fill="none">
      <circle cx="8" cy="8" r="6.25" stroke="currentColor" strokeWidth="1.5" />
      <path d="M8 4.75v3.75" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" />
      <circle cx="8" cy="11" r="1" fill="currentColor" />
    </svg>
  );
}

export function StatusButton({
  status = "idle",
  labels,
  icon,
  variant = "primary",
  size = "default",
  onReset,
  resetAfter = 1800,
  className = "",
  onClick,
  ...props
}: StatusButtonProps) {
  const reduced = useReducedMotion();
  const ref = React.useRef<HTMLButtonElement>(null);
  const label = { ...LABELS, ...labels }[status];
  const showIcon = status !== "idle" || Boolean(icon);
  const busy = status === "pending";
  const sm = size === "sm";
  const iconSize = sm ? 14 : 16;

  // A short shake when it fails: the eye catches it even if you looked away.
  React.useEffect(() => {
    if (status !== "error" || reduced) return;
    ref.current?.animate(
      [{ transform: "none" }, { transform: "translateX(-4px)" }, { transform: "translateX(4px)" }, { transform: "translateX(-2px)" }, { transform: "none" }],
      { duration: 380, easing: "ease-out" },
    );
  }, [status, reduced]);

  React.useEffect(() => {
    if (status !== "success" || !onReset) return;
    const timer = window.setTimeout(onReset, resetAfter);
    return () => window.clearTimeout(timer);
  }, [status, onReset, resetAfter]);

  const tone =
    status === "error"
      ? "bg-red-500/10 text-red-600 shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--color-red-500)_30%,transparent)] dark:text-red-400"
      : variant === "primary"
        ? "bg-primary text-primary-foreground hover:bg-primary/90"
        : variant === "ghost"
          ? `hover:bg-accent hover:text-foreground ${status === "idle" ? "text-muted-foreground" : "text-foreground"}`
          : "text-foreground shadow-[inset_0_0_0_1px_var(--border)] hover:bg-accent";

  const icons: Record<ActionStatus, React.ReactNode> = {
    idle: icon,
    // Spins only while pending, so a hidden spinner never keeps the page busy.
    pending: (
      <span
        className={`${sm ? "size-3" : "size-3.5"} rounded-full border-[1.5px] border-current border-t-transparent opacity-80 motion-reduce:animate-none ${status === "pending" ? "animate-spin" : ""}`}
      />
    ),
    success: <Check drawn={status === "success"} reduced={reduced} />,
    error: <Alert />,
  };

  return (
    <>
      <button
        ref={ref}
        type="button"
        aria-busy={busy || undefined}
        aria-disabled={busy || undefined}
        onClick={(e) => {
          if (busy) return e.preventDefault();
          onClick?.(e);
        }}
        className={`inline-flex items-center justify-center whitespace-nowrap rounded-full font-medium transition-[background-color,color,box-shadow,transform,padding] duration-300 active:scale-[0.97] ${
          sm ? `h-7 text-[12px] ${variant === "ghost" ? "px-2.5" : "px-3"}` : "h-9 px-4 text-[13px]"
        } ${FOCUS} ${tone} ${className}`}
        {...props}
      >
        <span
          aria-hidden="true"
          className={`relative flex shrink-0 items-center justify-center ${sm ? "h-3.5 [&_svg]:size-3.5" : "h-4 [&_svg]:size-4"}`}
          style={{
            width: showIcon ? iconSize : 0,
            marginRight: showIcon ? (sm ? 5 : 6) : 0,
            transition: reduced ? "none" : `width 380ms ${EASE}, margin 380ms ${EASE}`,
          }}
        >
          {(Object.keys(icons) as ActionStatus[]).map((s) => (
            <span
              key={s}
              className="absolute inset-0 flex items-center justify-center"
              style={{
                opacity: s === status ? 1 : 0,
                transform: s === status ? "none" : "scale(0.6)",
                filter: s === status ? "none" : "blur(3px)",
                transition: reduced ? "none" : `opacity 260ms ${EASE}, transform 380ms ${EASE}, filter 260ms ${EASE}`,
              }}
            >
              {icons[s]}
            </span>
          ))}
        </span>
        <TextMorph>{label}</TextMorph>
      </button>
      {/* Outside the button, so the announcement isn't read as part of its name. */}
      <span className="sr-only" role="status">
        {status === "idle" ? "" : label}
      </span>
    </>
  );
}

Registry item: /r/status-button.json