Pending button
A button that keeps its width while a spinner replaces its label.
Schedule payment run
212 invoices to 48 suppliers, Friday, Oct 2 at 2:00 PM CT.
import { Button } from "@oration/canon/components/button";import { PendingButton } from "@oration/canon/components/pending-button";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Hero() { const [pending, setPending] = React.useState(false); return ( <div className="flex w-full max-w-md flex-col overflow-hidden rounded-xl bg-popover text-left shadow-lg"> <div className="flex flex-col gap-1 p-4"> <p className="text-base leading-none font-medium"> Schedule payment run </p> <p className="text-sm text-muted-foreground"> 212 invoices to 48 suppliers, Friday, Oct 2 at 2:00 PM CT. </p> </div> <div className="flex items-center justify-end gap-2 border-t border-border bg-muted/50 px-4 py-3"> <Button type="button" variant="ghost" disabled={pending}> Cancel </Button> <PendingButton type="button" pending={pending} onClick={() => { setPending(true); window.setTimeout(() => { setPending(false); toast.add({ type: "success", title: "Payment run scheduled", description: "Friday, Oct 2 at 2:00 PM CT.", }); }, 1400); }} > Schedule run </PendingButton> </div> </div> );}Usage#
Pending button is a Button that shows work in progress without changing size: while pending, the label fades out in place and a 16px spinner sits on top, so the row never jumps. It also swallows clicks while pending, so a payment run can't be scheduled twice. It takes every Button variant and size. The common mistake is swapping the label for Saving…, which changes the width and moves everything beside it.
When to use
- For any button whose action waits on the network: Schedule run, Send remittance, Sync bank feed.
- For a form's submit button, so double submits are ignored while the request is in flight.
- For sign-in provider buttons that redirect after a short wait.
- In a dialog or sheet footer, where a width change would shift the buttons beside it.
When not to use
- For an action that completes instantly. Show the result instead, such as a toast or the updated row. Use Button
- For a long job such as an import. Close the dialog and show progress where the result will appear. Use Progress
- For a destructive action that needs deliberate confirmation. Use Hold button
- For loading a region of the page. Use Skeleton
The One Filled Button Rule
Hold the width
Anatomy#
- Button. A regular Button in any variant and size, with
cursor-progresswhile pending. - Label. The children, wrapped in a span that fades to 0 over 150ms on the house ease-out but keeps its space.
- Spinner. A 16px spinner centered over the label, in the variant's text color, only while pending.
Examples#
Every variant
It takes every Button variant and size. Press one: the label fades over 150ms, a spinner sits on top and the width holds.
import { PendingButton } from "@oration/canon/components/pending-button";import * as React from "react";export function Variants() { const variants = ["default", "outline", "secondary", "ghost"] as const; const [pending, setPending] = React.useState<string | null>(null); return ( <> {variants.map((variant) => ( <PendingButton key={variant} type="button" variant={variant} pending={pending === variant} onClick={() => { setPending(variant); window.setTimeout(() => setPending(null), 1500); }} > {variant === "default" ? "Approve 12 invoices" : variant === "outline" ? "Run test" : variant === "secondary" ? "Sync bank feed" : "Retry"} </PendingButton> ))} </> );}Submitting a form
As type="submit", a second press while pending is swallowed, so the form can't post twice. Pair it with polite status text for screen readers.
import { Input } from "@oration/canon/components/input";import { Label } from "@oration/canon/components/label";import { PendingButton } from "@oration/canon/components/pending-button";import { toast } from "@oration/canon/components/toast";import { MailIcon } from "lucide-react";import * as React from "react";export function FormSubmit() { const id = React.useId(); const [email, setEmail] = React.useState("ap@northwindfreight.com"); const [pending, setPending] = React.useState(false); const [sent, setSent] = React.useState(0); return ( <form className="flex w-full max-w-sm flex-col gap-3 text-left" onSubmit={(event) => { event.preventDefault(); setPending(true); window.setTimeout(() => { setPending(false); setSent((n) => n + 1); toast.add({ type: "success", title: "Remittance sent", description: `INV-20418 remittance went to ${email}.`, }); }, 1600); }} > <div className="flex flex-col gap-1.5"> <Label htmlFor={id}>Send remittance to</Label> <Input id={id} type="email" value={email} onChange={(event) => setEmail(event.target.value)} readOnly={pending} /> </div> <div className="flex items-center gap-3"> <PendingButton type="submit" pending={pending}> <MailIcon data-icon="inline-start" aria-hidden="true" /> Send remittance </PendingButton> <span className="text-xs text-muted-foreground tabular-nums" aria-live="polite" > {pending ? "Sending…" : `Sent ${sent} ${sent === 1 ? "time" : "times"}`} </span> </div> </form> );}Sign-in providers
The auth screens' provider buttons: large, outline and full width. The pressed provider shows the spinner; the others disable until the redirect.
import { PendingButton } from "@oration/canon/components/pending-button";import { toast } from "@oration/canon/components/toast";import { KeyRoundIcon } from "lucide-react";import * as React from "react";export function SignIn() { const providers = ["Okta", "Microsoft Entra ID", "Google Workspace"]; const [pending, setPending] = React.useState<string | null>(null); return ( <div className="flex w-full max-w-xs flex-col gap-2"> {providers.map((provider) => ( <PendingButton key={provider} type="button" variant="outline" size="lg" className="w-full" pending={pending === provider} disabled={pending !== null && pending !== provider} onClick={() => { setPending(provider); window.setTimeout(() => { setPending(null); toast.add({ title: `Redirecting to ${provider}` }); }, 1500); }} > <KeyRoundIcon data-icon="inline-start" aria-hidden="true" /> Continue with {provider} </PendingButton> ))} </div> );}States#
import { PendingButton } from "@oration/canon/components/pending-button";export function StatesRow() { const states = [ { name: "Rest", pending: false, disabled: false }, { name: "Pending", pending: true, disabled: false }, { name: "Disabled", pending: false, disabled: true }, ]; const variants = ["default", "outline"] as const; return ( <div className="grid w-full grid-cols-[4.5rem_repeat(3,minmax(0,1fr))] items-center gap-x-2 gap-y-3" inert > <span /> {states.map((state) => ( <span key={state.name} className="text-center text-xs text-muted-foreground" > {state.name} </span> ))} {variants.map((variant) => ( <div key={variant} className="contents"> <span className="text-13 text-muted-foreground capitalize"> {variant === "default" ? "Primary" : variant} </span> {states.map((state) => ( <div key={state.name} className="flex justify-center"> <PendingButton type="button" variant={variant} pending={state.pending} disabled={state.disabled} > Save changes </PendingButton> </div> ))} </div> ))} </div> );}| State | Treatment |
|---|---|
| Rest | Exactly a Button of the same variant. |
| Hover, focus, pressed | From Button: the variant's hover fill, the indigo focus ring, the 0.96 press scale. |
| Pending | Label at 0% opacity, spinner centered, aria-disabled="true", data-pending and a progress cursor. The fill doesn't dim, and focus stays on the button. |
| Disabled | From Button: 50% opacity and no pointer events. Use it for the other buttons in the footer while one is pending. |
Behavior#
pendingis controlled by you. Set it when the request starts and clear it when it settles, success or failure.- While pending, clicks call
preventDefault()and skiponClick, which also stops a submit button from submitting the form again. - It sets
aria-disabledrather thandisabled, so the button stays focusable and keyboard focus doesn't jump to the page when the request starts. - Every other prop goes to Button:
variant,size,type,render,className. - Icons inside the children fade with the label, so the spinner is the only thing visible.
Do and don't#
Content#
- Keep the resting label; it's still there for screen readers while pending.
- Report the outcome after, not during: a toast titled Payment run scheduled, or an error that says what to do next.
- If a wait runs past a few seconds, say what is happening near the button, such as Sending to Northwind Freight.
Accessibility#
- While pending, the button has
aria-disabled="true"and stays in the tab order, so focus isn't lost. - The label is hidden with opacity only, so it still names the button. The spinner adds
role="status"with the label Loading. - A status inside a button isn't reliably announced. For waits over a second, add polite status text nearby or confirm with a toast.
- The spinner's rotation is CSS
animate-spin; it isn't paused for reduced motion, but it is small and doesn't move across the screen.
| Keys | Action |
|---|---|
| Enter | Activates the button. Ignored while pending. |
| Space | Activates the button. Ignored while pending. |
Design tokens#
| Token | Used for |
|---|---|
--primary | Filled background, inherited from Button |
--primary-foreground | Spinner color on the filled variant |
--ring | Focus ring, inherited from Button |
--radius-lg | 10px corners, inherited from Button |
API reference#
PendingButton
A Button with a pending state.
Other props spread onto Button (all its props).
| Prop | Type | Default | Description |
|---|---|---|---|
pending | boolean | false | Hides the label, shows the spinner and ignores clicks. |
onClick | (event: React.MouseEvent<HTMLButtonElement>) => void | No default | Called on click unless pending. |
variant | "default" | "outline" | "secondary" | "ghost" | "destructive" | "link" | "default" | Button variant. |
size | "xs" | "sm" | "default" | "lg" | "icon-xs" | "icon-sm" | "icon" | "icon-lg" | "default" | Button size. The spinner is 16px at every size. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
The spinner is 16px at every size, so it crowds xs and icon-xs buttons, where icons are 12px.
The spinner's role="status" inside a button can make the accessible name read as the label followed by Loading, and announcements vary by screen reader.
One product file uses it, the auth screens' provider buttons. Check other pending states before assuming they hold their width.