Skip to content

Pending button

A button that keeps its width while a spinner replaces its label.

Status
Beta
Category
Actions
Adoption
Not used yet
import { PendingButton } from "@oration/canon/components/pending-button";
packages/canon/src/components/pending-button.tsx

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

Pending doesn't change the variant. The filled button stays the one filled button while it works, and the buttons beside it keep their weight.

Hold the width

The label stays in the layout at zero opacity, so the button keeps the width of its resting label. Don't change the label while pending.

Anatomy#

  1. Button. A regular Button in any variant and size, with cursor-progress while pending.
  2. Label. The children, wrapped in a span that fades to 0 over 150ms on the house ease-out but keeps its space.
  3. 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.

Sent 0 times
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#

RestPendingDisabled
Primary
outline
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>    );}
States
StateTreatment
RestExactly a Button of the same variant.
Hover, focus, pressedFrom Button: the variant's hover fill, the indigo focus ring, the 0.96 press scale.
PendingLabel 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.
DisabledFrom Button: 50% opacity and no pointer events. Use it for the other buttons in the footer while one is pending.

Behavior#

  • pending is controlled by you. Set it when the request starts and clear it when it settles, success or failure.
  • While pending, clicks call preventDefault() and skip onClick, which also stops a submit button from submitting the form again.
  • It sets aria-disabled rather than disabled, 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#

Do. Keep the label and let the spinner replace it in place, so the width holds.
Don't. Swap the label for a longer pending message. The button grows, and everything beside it moves.

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.
Keyboard interactions
KeysAction
EnterActivates the button. Ignored while pending.
SpaceActivates the button. Ignored while pending.

Design tokens#

Design tokens
TokenUsed for
--primaryFilled background, inherited from Button
--primary-foregroundSpinner color on the filled variant
--ringFocus ring, inherited from Button
--radius-lg10px corners, inherited from Button

API reference#

PendingButton

A Button with a pending state.

Other props spread onto Button (all its props).

Props of PendingButton
PropTypeDefaultDescription
pendingbooleanfalseHides the label, shows the spinner and ignores clicks.
onClick(event: React.MouseEvent<HTMLButtonElement>) => voidNo defaultCalled 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.