Action button
A button that morphs through saving, saved and failed, right where you clicked.
$
pnpm dlx shadcn@latest add https://lodeui.com/r/action-button.jsoncomponents/examples/action-button-demo.tsx
"use client";
import * as React from "react";
import { ArrowRight } from "lucide-react";
import { ActionButton } from "@/components/ui/action-button";
export default function ActionButtonDemo() {
const attempts = React.useRef(0);
return (
<div className="flex flex-col items-center gap-5">
<ActionButton onAction={() => new Promise((r) => setTimeout(r, 1100))}>
Save changes <ArrowRight />
</ActionButton>
<ActionButton
variant="secondary"
pendingLabel="Publishing"
successLabel="Published"
errorLabel="Failed, retry"
onAction={() => new Promise((res, rej) => setTimeout(() => (++attempts.current % 2 ? rej(new Error("x")) : res(null)), 1000))}
>
Publish (fails every other time)
</ActionButton>
</div>
);
}When to use
- Save, publish, retry or pay actions where people should see the outcome where they clicked.
When not to use
- Actions that navigate away immediately.
- Long jobs (over ~10s): show progress elsewhere.
Installation
$
pnpm dlx shadcn@latest add https://lodeui.com/r/action-button.json1Install the dependencies
npm install motion lucide-react2Copy the source into your project
components/ui/action-button.tsx"use client"; import * as React from "react"; import { AnimatePresence, motion } from "motion/react"; import { AlertCircle, Check } from "lucide-react"; import { cn } from "@/lib/utils"; type Status = "idle" | "pending" | "success" | "error"; export interface ActionButtonProps { /** Runs on click. Resolve for success, throw for error. */ onAction: () => Promise<unknown>; children: React.ReactNode; pendingLabel?: React.ReactNode; successLabel?: React.ReactNode; errorLabel?: React.ReactNode; /** How long the success or error state stays before returning to idle. */ resetAfter?: number; variant?: "primary" | "secondary"; className?: string; } /** A button that morphs through pending, success and error instead of swapping in a toast. */ export function ActionButton({ onAction, children, pendingLabel = "Saving", successLabel = "Saved", errorLabel = "Try again", resetAfter = 1800, variant = "primary", className, }: ActionButtonProps) { const [status, setStatus] = React.useState<Status>("idle"); const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null); React.useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []); const run = async () => { if (status === "pending") return; setStatus("pending"); try { await onAction(); setStatus("success"); } catch { setStatus("error"); } timer.current = setTimeout(() => setStatus("idle"), resetAfter); }; const content: Record<Status, React.ReactNode> = { idle: children, pending: ( <> <Spinner /> {pendingLabel} </> ), success: ( <> {successLabel} <Check /> </> ), error: ( <> <AlertCircle /> {errorLabel} </> ), }; return ( <motion.button type="button" layout onClick={run} aria-busy={status === "pending" || undefined} aria-live="polite" style={{ borderRadius: 8 }} transition={{ layout: { type: "spring", bounce: 0.2, duration: 0.4 } }} whileTap={{ scale: 0.97 }} className={cn( "relative inline-flex h-11 items-center overflow-hidden px-5 text-sm font-medium outline-none", "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background", "[&_svg]:size-4 [&_svg]:shrink-0", variant === "primary" ? "bg-primary text-primary-foreground shadow-md" : "border border-input text-foreground", status === "error" && "bg-destructive text-white", className, )} > <AnimatePresence mode="popLayout" initial={false}> <motion.span key={status} layout="position" initial={{ opacity: 0, y: 12, filter: "blur(4px)" }} animate={{ opacity: 1, y: 0, filter: "blur(0px)" }} exit={{ opacity: 0, y: -12, filter: "blur(4px)" }} transition={{ type: "spring", bounce: 0, duration: 0.3 }} className="flex items-center gap-2 whitespace-nowrap" > {content[status]} </motion.span> </AnimatePresence> </motion.button> ); } function Spinner() { return ( <svg viewBox="0 0 24 24" fill="none" className="animate-spin"> <circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" /> <path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="3" strokeLinecap="round" /> </svg> ); }
Usage
Import it from @/components/ui/action-button after installing.
example.tsx
import { ActionButton } from "@/components/ui/action-button";
export function Publish({ publish }: { publish: () => Promise<void> }) {
return (
<ActionButton onAction={publish} pendingLabel="Publishing" successLabel="Published" errorLabel="Try again">
Publish
</ActionButton>
);
}API reference
ActionButton
Runs a promise and morphs through pending, success and error, then returns to idle.
| Prop | Type | Default | Description |
|---|---|---|---|
| onAction | () => Promise<unknown> | – | Resolve for success, throw or reject for error. |
| children | ReactNode | – | Idle label. |
| pendingLabel / successLabel / errorLabel | ReactNode | "Saving" / "Saved" / "Try again" | Labels for each state. |
| resetAfter | number | 1800 | ms before returning to idle. |
| variant | "primary" | "secondary" | "primary" | Visual weight. |
Keyboard interactions
| Keys | Action |
|---|---|
| EnterorSpace | Run the action |
Accessibility
- aria-busy while pending and aria-live so state changes are announced.
- Clicks are ignored while pending, so actions never double-fire.
Motion
- Width springs to fit each label; labels swap with a short blur and slide (300ms).
- Presses scale to 97%.
Responsive behavior
- Width follows the label; add w-full on phones.
Performance
- One timer per button, cleared on unmount.
Notes for AI
- Throw from onAction to show the error state; don't catch silently.
This page as Markdown, for assistants and coding agents:
/components/action-button/markdownThe full library index for assistants is at /llms.txt.
Lode222 doc pagesllms.txt