Components

Getting started

Components121

More

Asynchronous button with promise-driven state management, in-place character glyph morphing, self-drawing checkmark, and error shake physics.

Used forForm submissionsDeployments & buildsDestructive confirmations

Install with the shadcn CLI

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

Where it’s used

  • Form submissions

    Primary save/update actions that communicate network progress without modal overlays.

  • Deployments & builds

    Triggering long-running workflows with clear pending feedback and auto-reset.

  • Destructive confirmations

    Critical actions like purging data or deleting workspaces with affirmative feedback.

What it handles for you

Async buttons often trigger awkward layout shifts, freeze UI interactivity, or rely on disconnected alert dialogs. This component handles async states in place with continuous kinetic transitions and zero runtime bloat.

Instead of wiring uploading buttons with layout jumpsmanual useState spinner logicunresponsive submit triggers
  1. Promise-driven state machine

    Pass an async action callback; the button manages idle, pending, success, and error states automatically.

  2. In-place character morphing

    Labels transition using TextMorph, animating shared glyphs with spring physics.

  3. Self-drawing checkmark

    On success, a crisp hairline checkmark draws itself with SVG strokeDashoffset easing.

  4. Tactile error shake

    On failure, the button executes an organic horizontal shake via WAAPI to catch peripheral attention.

Overview

A stateful execution button that manages the full asynchronous lifecycle: idle, pending, success, and error. Instead of jarring layout jumps or disconnected spinners, the icon slot opens organically, text morphs smoothly through TextMorph, and a drawn SVG checkmark confirms completion before gracefully reverting.

States

idle
Resting state with optional leading icon and label.
pending
Interaction disabled; spinner opens and label morphs to pending state.
success
Self-drawing checkmark and affirmative feedback before auto-resetting.
error
Error badge and horizontal WAAPI shake alerting the user.

Usage

usage.tsx
import { ActionButton } from "@/components/ui/action-button";import { CloudUpload } from "lucide-react";<ActionButton  icon={<CloudUpload size={14} />}  labels={{    idle: "Save changes",    pending: "Saving...",    success: "Saved!",    error: "Failed",  }}  action={async () => {    await saveSettings();  }}/>

Works with

Props

PropTypeDescription
action() => Promise<unknown> | voidAsync or sync callback executed on click. Drives status automatically.
status"idle" | "pending" | "success" | "error"Controlled status override.
labelsPartial<Record<ActionStatus, string>>Custom labels per status state.
variant"primary" | "secondary" | "destructive" | "outline" | "ghost"default "primary"Visual style.
size"sm" | "md" | "lg"default "md"Button scale and typography.
resetAfternumberdefault 2000Duration in ms before auto-reverting from success/error to idle.

Accessibility and motion

  • Uses WAAPI for zero-bundle error shake and native SVG stroke animation.
  • Aria-live polite announcement keeps screen readers updated on status changes.

Requirements

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

The CLI also installs Text morph, ui-interaction-motion.

Source

components/action-button.tsx
"use client";import * as React from "react";import { TextMorph } from "./text-morph";import {  EASE_OUT,  INTERACTION_FOCUS,  useInteractionReducedMotion,} from "./ui-interaction-motion";export type ActionStatus = "idle" | "pending" | "success" | "error";export interface ActionButtonProps  extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onClick"> {  /** Async or sync action to execute on click. Automatically handles idle -> pending -> success/error. */  action?: () => Promise<unknown> | void;  /** Explicit click handler if action is not used. */  onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;  /** Controlled status state. If passed, overrides internal action state. */  status?: ActionStatus;  /** Custom labels for each status. */  labels?: Partial<Record<ActionStatus, string>>;  /** Default label in idle state if children is not provided. */  label?: string;  /** Leading icon shown in idle state. */  icon?: React.ReactNode;  /** Visual variant. Defaults to primary. */  variant?: "primary" | "secondary" | "destructive" | "outline" | "ghost";  /** Button sizing. Defaults to md (36px). */  size?: "sm" | "md" | "lg";  /** Duration in ms to show success or error state before auto-reverting to idle. Defaults to 2000ms. */  resetAfter?: number;  /** Callback fired when auto-reset triggers. */  onReset?: () => void;}const DEFAULT_LABELS: Record<ActionStatus, string> = {  idle: "Save changes",  pending: "Saving...",  success: "Saved!",  error: "Failed to save",};const EASE = "cubic-bezier(0.16, 1, 0.3, 1)";function SelfDrawingCheck({ drawn, reduced, size }: { drawn: boolean; reduced: boolean; size: number }) {  return (    <svg width={size} height={size} viewBox="0 0 16 16" fill="none" className="shrink-0">      <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 StatusAlert({ size }: { size: number }) {  return (    <svg width={size} height={size} viewBox="0 0 16 16" fill="none" className="shrink-0">      <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>  );}const variantStyles = {  primary:    "bg-primary text-primary-foreground hover:bg-primary/90 shadow-[0_1px_2px_rgba(48,93,222,0.18)] border-transparent",  secondary:    "bg-card text-foreground shadow-[inset_0_0_0_1px_var(--border)] hover:bg-accent border-transparent",  outline:    "bg-transparent text-foreground shadow-[inset_0_0_0_1px_var(--border)] hover:bg-accent border-transparent",  destructive:    "bg-red-500/10 text-red-600 dark:text-red-400 shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--color-red-500)_30%,transparent)] hover:bg-red-500/15 border-transparent",  ghost:    "bg-transparent text-muted-foreground hover:bg-accent hover:text-foreground border-transparent",};const sizeStyles = {  sm: {    height: "h-7 text-[12px] px-3",    iconSize: 13,    spinnerSize: "size-3",  },  md: {    height: "h-9 text-[13px] px-4",    iconSize: 14,    spinnerSize: "size-3.5",  },  lg: {    height: "h-10 text-[14px] px-5",    iconSize: 16,    spinnerSize: "size-4",  },};export function ActionButton({  action,  onClick,  status: controlledStatus,  labels,  label: defaultLabel,  icon,  variant = "primary",  size = "md",  resetAfter = 2000,  onReset,  children,  disabled = false,  className = "",  style,  ...props}: ActionButtonProps) {  const reduced = useInteractionReducedMotion();  const [internalStatus, setInternalStatus] = React.useState<ActionStatus>("idle");  const [pressed, setPressed] = React.useState(false);  const btnRef = React.useRef<HTMLButtonElement | null>(null);  const resetTimer = React.useRef<number>(0);  const status = controlledStatus ?? internalStatus;  const busy = status === "pending";  const s = sizeStyles[size];  // Resolve current label text  const initialText = typeof children === "string" ? children : defaultLabel;  const statusLabels = {    ...DEFAULT_LABELS,    idle: initialText || DEFAULT_LABELS.idle,    ...labels,  };  const currentLabel = statusLabels[status];  // Auto reset timer on success or error  React.useEffect(() => {    if (status !== "success" && status !== "error") return;    if (resetAfter <= 0) return;    window.clearTimeout(resetTimer.current);    resetTimer.current = window.setTimeout(() => {      if (controlledStatus === undefined) {        setInternalStatus("idle");      }      onReset?.();    }, resetAfter);    return () => window.clearTimeout(resetTimer.current);  }, [status, resetAfter, controlledStatus, onReset]);  // Shake animation on error  React.useEffect(() => {    if (status !== "error" || reduced || !btnRef.current) return;    btnRef.current.animate(      [        { transform: "none" },        { transform: "translateX(-4px)" },        { transform: "translateX(4px)" },        { transform: "translateX(-2px)" },        { transform: "translateX(2px)" },        { transform: "none" },      ],      { duration: 380, easing: "ease-out" }    );  }, [status, reduced]);  // Click / execution handler  const handleClick = async (e: React.MouseEvent<HTMLButtonElement>) => {    if (busy || disabled) {      e.preventDefault();      return;    }    if (onClick) {      onClick(e);    }    if (action) {      e.preventDefault();      setInternalStatus("pending");      try {        await action();        setInternalStatus("success");      } catch (err) {        console.error("ActionButton action error:", err);        setInternalStatus("error");      }    }  };  // Tone resolution for error/success overrides  const toneClass =    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"      : status === "success" && variant === "destructive"        ? "bg-emerald-500/10 text-emerald-600 shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--color-emerald-500)_30%,transparent)] dark:text-emerald-400"        : variantStyles[variant];  // Current status icon  const showLeading = status !== "idle" || Boolean(icon);  return (    <button      ref={btnRef}      type="button"      disabled={disabled || busy}      aria-busy={busy || undefined}      aria-live="polite"      onClick={handleClick}      onPointerDown={() => !busy && !disabled && setPressed(true)}      onPointerUp={() => setPressed(false)}      onPointerLeave={() => setPressed(false)}      style={{        transform: pressed ? "scale(0.97)" : undefined,        transition: reduced ? "none" : `transform 140ms ${EASE}`,        ...style,      }}      className={`relative inline-flex items-center justify-center font-medium rounded-full cursor-pointer select-none transition-colors duration-200 disabled:cursor-not-allowed disabled:opacity-45 ${s.height} ${toneClass} ${INTERACTION_FOCUS} ${className}`}      {...props}    >      {/* Dynamic Leading Icon Slot */}      <span        aria-hidden="true"        className="relative inline-flex shrink-0 items-center justify-center overflow-hidden"        style={{          width: showLeading ? s.iconSize : 0,          marginRight: showLeading ? 8 : 0,          transition: reduced ? "none" : `width 280ms ${EASE}, margin-right 280ms ${EASE}`,        }}      >        {status === "pending" && (          <span            className={`inline-flex animate-spin rounded-full border-[1.5px] border-current border-t-transparent ${s.spinnerSize}`}          />        )}        {status === "success" && (          <SelfDrawingCheck drawn={true} reduced={reduced} size={s.iconSize} />        )}        {status === "error" && <StatusAlert size={s.iconSize} />}        {status === "idle" && icon && (          <span className="inline-flex size-full items-center justify-center">{icon}</span>        )}      </span>      {/* Morphing Label */}      <TextMorph className="whitespace-nowrap">        {currentLabel}      </TextMorph>    </button>  );}export default ActionButton;

Registry item: /r/action-button.json