Async Button
AsyncButton extends shadcn's Button: when onClick returns a Promise, the component automatically manages the loading state — the button is disabled and a spinner is shown until the promise settles. You can also drive the loading state manually via the loading prop, and decorate the button with startIcon / endIcon slots that the spinner slides into while loading.
Click the button — loading state activates automatically while the promise is pending.
Drive loading externally via the loading prop.
Use startIcon / endIcon. While loading, the icon slot is swapped for the spinner — no overlay needed. When both are present, startIcon takes the spinner.
Icon buttons with size="icon".
Anti-flash: a 50ms task still shows the spinner for at least 200ms (the default minDuration), so the indicator never just flickers past.
Errors are caught and logged — the button recovers gracefully.
Installation
With the @easy-shadcn namespace configured:
pnpm dlx shadcn@latest add @easy-shadcn/async-buttonOr install via the full URL (zero configuration):
pnpm dlx shadcn@latest add https://easy-shadcn.vercel.app/r/async-button.jsonProps
| Prop | Type | Default | Description |
|---|---|---|---|
onClick | (e: MouseEvent<HTMLButtonElement>) => void | Promise<void> | - | Click handler. When it returns a Promise, the button enters the loading state until it settles. |
loading | boolean | false | Controlled loading state, OR-ed with the internal async loading state. |
startIcon | ReactNode | - | Icon rendered before children. Replaced by the spinner while loading. |
endIcon | ReactNode | - | Icon rendered after children. Replaced by the spinner while loading when startIcon is absent. |
disabled | boolean | false | Disables the button. The button is also disabled automatically while loading. |
All other props (variant, size, className, etc.) are inherited from shadcn's Button and forwarded as-is.
Loading indicator placement
The spinner is rendered in the most context-appropriate place, in this priority order:
size="icon"—children(the lone icon) is replaced by the spinner.startIconpresent —startIconis replaced by the spinner;endIcon(if any) stays put.- Only
endIconpresent —endIconis replaced by the spinner. - No icons — a blurred overlay covers the button and the spinner is centered on top.
This means the button never grows or reflows when loading kicks in.
Notes
- Exceptions thrown from
onClickare caught and logged viaconsole.errorso that unhandled promise rejections don't break the page, while still surfacing the error for observability. - The fallback overlay uses
backdrop-blurso it works seamlessly with every button variant when no icon slot is available. - While loading, the button is disabled (no clicks, no hover) but keeps its full opacity — the
disabled:opacity-50style only applies when the button is purely disabled, not when it's loading. - The button sets
aria-busy={loading}so assistive technologies can announce the loading state.