Status Button
A full-width submit button that shows idle, sending, sent and failed.
$npx shadcn@latest add https://framebrick.co/r/status-button.jsonFrom the Ciketo template, see it in context.
"use client";
import { useRef, useState } from "react";
import { StatusButton, type StatusButtonStatus } from "@/components/ui/status-button";
/* A made-up send: it takes a second and then fails every other time, so all four states can be seen. */
export default function StatusButtonDemo() {
const [status, setStatus] = useState<StatusButtonStatus>("idle");
const sends = useRef(0);
function send(event: React.FormEvent) {
event.preventDefault();
setStatus("pending");
window.setTimeout(() => setStatus(++sends.current % 2 === 1 ? "success" : "error"), 1000);
}
return (
<form onSubmit={send} className="flex w-full max-w-xs flex-col gap-3">
<input
aria-label="Your message"
placeholder="Your message"
className="h-10 rounded-md border bg-background px-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
/>
<StatusButton status={status} />
<p className="text-center text-xs text-muted-foreground">Press it: it succeeds, then fails, then succeeds again.</p>
</form>
);
}Installation
$ npx shadcn@latest add https://framebrick.co/r/status-button.json1. Install the dependencies.
npm install motion clsx tailwind-merge2. Add
cn()tolib/utils.ts(skip if shadcn already created it).import { clsx, type ClassValue } from "clsx"; import { extendTailwindMerge } from "tailwind-merge"; /* tailwind-merge must know the site's type scale (globals.css) is a font size, not a text color — otherwise cn("text-h6 text-muted-foreground") drops text-h6. */ const twMerge = extendTailwindMerge({ extend: { classGroups: { "font-size": [{ text: ["display", "h1", "h2", "h3", "h4", "h5", "h6"] }] } }, }); export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }3. Copy the component into
components/ui/status-button.tsx.import * as React from "react"; import { cn } from "@/lib/utils"; /* * Status Button — a full-width submit button that shows how the form went: idle, sending (a spinner), sent, failed * (from the submit buttons of Kalme, Titen and Ciketo, which share one design). The form owns the state and the button * only shows it: the result stays until the form is sent again, as in Framer. * * The colours change over 0.2s on cubic-bezier(0.44, 0, 0.56, 1), Framer's own curve for it. Framer fades the fill to * 85% on hover and 70% on press; that lightens it under the label, and in a themed project with a mid-tone primary the * label falls under 4.5:1 (the Blocks sample themes, light: hover 3.9 and press 3.0 in "soft"; measured twice, 2026-10-06). * So hover fades only to 95% (4.7:1 or better in all four sample themes, light and dark — shadcn's own 90% is 4.3 in * "soft"), and press mixes the fill 20% toward the foreground colour, which darkens it in a light theme and lightens * it in a dark one, away from the label (6.6:1 or better). In the default theme the press then reads against the hover. * Failed is a red label on a 15% red fill; in light mode the label is the destructive colour darkened by a quarter, * because destructive on its own 15% tint is only 3.65:1 (dark mode already has 5.8:1). Sending is a 20px spinner at * one turn a second, made of a masked conic gradient — it keeps turning with reduced motion, because it is the one sign * that something is happening. Plain CSS: no animation library. * * The result is announced to screen readers from a polite live region next to the button, not inside it (a button's * children are presentational, so a live region there may never be read). While sending the button is disabled, * aria-busy, and named by its sending label. */ export type StatusButtonStatus = "idle" | "pending" | "success" | "error"; const defaultLabels: Record<StatusButtonStatus, string> = { idle: "Send message", pending: "Sending", success: "Thank you", error: "Something went wrong — try again", }; export type StatusButtonProps = Omit<React.ComponentProps<"button">, "children"> & { status?: StatusButtonStatus; /** Replace any of the four texts. */ labels?: Partial<Record<StatusButtonStatus, string>>; }; export function StatusButton({ status = "idle", labels, type = "submit", disabled, className, ...props }: StatusButtonProps) { const text = { ...defaultLabels, ...labels }; return ( <> <button type={type} disabled={disabled || status === "pending"} aria-busy={status === "pending"} className={cn( "inline-flex h-10 w-full cursor-pointer items-center justify-center gap-2 rounded-md px-4 text-sm font-medium", "transition-colors duration-200 ease-[cubic-bezier(0.44,0,0.56,1)] outline-none", "focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-default", status === "error" ? "bg-destructive/15 text-[color-mix(in_oklab,var(--destructive),black_25%)] dark:text-destructive" : "bg-primary text-primary-foreground", status === "idle" && "hover:bg-primary/95 active:bg-[color-mix(in_oklab,var(--primary),var(--foreground)_20%)]", className, )} {...props} > {status === "pending" ? ( <> <span aria-hidden className="size-5 animate-spin rounded-full [background:conic-gradient(from_0deg,transparent_7deg,currentColor_342deg)] [mask:radial-gradient(farthest-side,transparent_calc(100%-2px),black_calc(100%-2px))]" /> <span className="sr-only">{text.pending}</span> </> ) : ( <> {status === "success" && ( <svg aria-hidden viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round" className="size-4"> <path d="M3.5 8.5l3 3 6-6.5" /> </svg> )} {text[status]} </> )} </button> <span role="status" className="sr-only"> {status === "success" || status === "error" ? text[status] : ""} </span> </> ); }
Usage
import { StatusButton } from "@/components/ui/status-button";
const [state, action, pending] = useActionState(send, { status: "idle" });
<form action={action}>
…fields…
<StatusButton status={pending ? "pending" : state.status} labels={{ idle: "Request a quote" }} />
</form>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| status | "idle" | "pending" | "success" | "error" | "idle" | What the button shows. The form owns it; the button only displays it. |
| labels | Partial<Record<status, string>> | — | Replace any of the four texts: "Send message", "Sending", "Thank you", "Something went wrong — try again". |
| type | string | "submit" | Button type. |
| …props | button props | — | Passed to the button (className sets the width — it is full width by default). |
- The result stays until the form is sent again, as in Framer: the button follows `status` and never resets it itself.
- From the submit buttons of Kalme, Titen and Ciketo, which share one design. It is not `subscribe-input`: that one is an e-mail field with a round button, this is a button for any form.
- The colours change over 0.2 s on cubic-bezier(0.44, 0, 0.56, 1). Failed is a red label on a 15% red fill.
- Hover fades the fill to 95% and press mixes it 20% toward the foreground colour. Framer fades to 85% and 70%, which lightens the fill under the label: in a themed project with a mid-tone primary that is 3.9:1 on hover and 3.0:1 pressed (the Blocks "soft" sample theme, light). At 95% the label keeps 4.7:1 or better in all four sample themes, light and dark; the press keeps 6.6:1 or better because it moves away from the label (darker in a light theme, lighter in a dark one).
- In light mode the red label is the destructive colour darkened by a quarter (6.6:1): destructive on its own 15% tint is only 3.65:1. Dark mode uses the destructive colour as it is (5.8:1).
- The spinner keeps turning with reduced motion on purpose — it is the only sign that something is happening.
- The result is announced from a polite live region next to the button, not inside it: a button's children are presentational, so a live region there may never be read. While sending it is disabled, aria-busy, and named by its sending label.
- Plain CSS: no animation library.
AI prompt
Paste into v0, Lovable, Bolt or Claude to rebuild this component in your own project.
Create a React + Tailwind CSS component called StatusButton — plain CSS, no animation library.
- A button, type submit by default, h-10 w-full rounded-md px-4 text-sm font-medium, inline-flex centred, with a visible
focus ring (ring-[3px] ring-ring/50). Prop status: idle | pending | success | error (default idle) and a labels prop to
replace the four texts ("Send message", "Sending", "Thank you", "Something went wrong — try again").
- Colours change with transition-colors, 200ms, cubic-bezier(0.44,0,0.56,1). idle, pending and success: bg-primary
text-primary-foreground; idle hovers to bg-primary/95 and presses to bg-[color-mix(in_oklab,var(--primary),var(--foreground)_20%)]
(darker in a light theme, lighter in a dark one — a fade toward the page would put the label under 4.5:1 in a themed
project with a mid-tone primary). error: bg-destructive/15 with the
label in text-[color-mix(in_oklab,var(--destructive),black_25%)] and dark:text-destructive — the plain destructive
colour on its own 15% tint is only 3.65:1 in light mode.
- pending: the button is disabled with aria-busy, shows a 20px spinner (a conic-gradient from transparent to currentColor
with a radial-gradient mask that leaves a 2px ring, animate-spin) and an sr-only "Sending". success: a small check icon
then the label. error: the label.
- Render a polite live region (role status, sr-only) NEXT TO the button, not inside it — a button's children are
presentational — that holds the success or error text and is empty otherwise.
- The form owns the state: the button never resets itself, so the result stays until the form is sent again.










