Loading button
A button with a pending state that shows a spinner and keeps its width.
npx shadcn@latest add https://ui.goodtabs.co/r/loading-button.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/loading-button
Click to load for 1.5 seconds. The width does not change.
With loading text
loadingText replaces the label while pending. The second one is stuck on, as a still state.
In a form footer
A submit button next to a cancel that locks while the form saves.
Usage
import { LoadingButton } from "@/components/ui/loading-button"
<LoadingButton type="submit" loading={pending} loadingText="Saving">
Save
</LoadingButton>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| loading | boolean | false | Shows the spinner, sets aria-busy and disables the button. |
| loadingText | string | Replaces the children while loading. Leave out to show only the spinner. | |
| variant, size, type | Button props | "default", "default", "button" | Every shadcn Button prop passes through. |
Source
loading-button/loading-button.tsx
import * as React from "react";
import { Loader2Icon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type LoadingButtonProps = React.ComponentProps<typeof Button> & {
/** Shows the spinner, sets aria-busy and disables the button. */
loading?: boolean;
/** Replaces the children while loading, for example "Saving". */
loadingText?: string;
};
/**
* A button with a pending state. The children stay in the layout, hidden,
* so the button keeps its width and nothing around it jumps.
*/
export function LoadingButton({
loading = false,
loadingText,
disabled,
type = "button",
className,
children,
...props
}: LoadingButtonProps) {
return (
<Button
type={type}
disabled={disabled || loading}
aria-busy={loading || undefined}
data-loading={loading || undefined}
data-slot="loading-button"
className={cn("inline-grid", className)}
{...props}
>
<span
className={cn(
"col-start-1 row-start-1 inline-flex items-center justify-center gap-[inherit]",
loading && "invisible",
)}
>
{children}
</span>
{loading && (
<span className="col-start-1 row-start-1 inline-flex items-center justify-center gap-[inherit]">
<Loader2Icon className="animate-spin motion-reduce:animate-none" aria-hidden="true" />
{loadingText}
</span>
)}
</Button>
);
}
For AI agents
Hand your agent this page, the markdown at /components/loading-button/prompt.md, or the Copy AI prompt button above.
When to use
- Form submits and any action that takes more than a moment to settle
- When the button sits in a row or footer where a width change would shift its neighbours
- With useActionState or useTransition, passing pending straight into loading
When not to use
- For actions that settle instantly; a spinner that flashes for 50ms reads as a glitch
- To block a whole form: disable the fields too, or use a dialog with a pending state
- For page level progress; use a progress bar or skeleton
Agent notes
- Owns no state: pass loading from a transition, action state or a fetch, and the button handles aria-busy and disabled
- The label stays in the layout with visibility hidden, so the width is at least the label's; a longer loadingText grows it
- The spinner stops under prefers-reduced-motion and still reads as a loader icon
- type defaults to button; pass type="submit" in forms