Pill button in twelve styles and five sizes with a spring press, leading and trailing icons, inline links whose underline draws in, a Spinner loading state, an async state machine and a magnetic hover.
buttonctalinkdestructiveloadingasyncmagneticspring
Installation
$npx shadcn@latest add @geturui/button
Also installs: Spinner
Usage
usage.tsx
1import { Plus, Trash2 } from "lucide-react";
2import { Button, StatefulButton, MagneticButton } from "@/components/ui/button";
3
4export function Actions() {
5 return (
6 <div className="flex flex-wrap items-center gap-3">
7 <Button trailing="arrow">Get started</Button>
8 <Button variant="secondary" iconLeading={Plus}>New project</Button>
9 <Button variant="destructive" iconLeading={Trash2}>Delete project</Button>
10 <Button variant="link" href="/changelog">See what shipped</Button>
11 <StatefulButton
12 variant="secondary"
13 successLabel="Saved"
14 onAction={() => fetch("/api/save", { method: "POST" })}
15 >
16 Save changes
17 </StatefulButton>
18 <MagneticButton variant="inverse">Hover me</MagneticButton>
19 </div>
20 );
21}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "primary" | "secondary" | "outline" | "ghost" | "inverse" | "pro" | "danger" | "destructive" | "destructive-ghost" | "link" | "link-muted" | "link-destructive" | "primary" | Visual style. danger is the outlined destructive, destructive the solid one, destructive-ghost the quiet one; link variants drop the pill and draw an underline on hover. |
size | "xs" | "sm" | "md" | "lg" | "xl" | "md" | Height and padding: 28, 32, 40, 48 or 56px. Link variants keep only the type size. |
iconLeading | ComponentType | ReactNode | — | Icon before the label: a component (Plus) or a node (<Plus />). Sized to the button. |
iconTrailing | ComponentType | ReactNode | — | Icon after the label; nudges right on hover. |
trailing | "arrow" | "external" | ReactNode | — | Glyph after the label. The built-in arrows nudge on hover. |
href | string | — | Renders an <a> with the same look (or use ButtonLink). Disabled links drop the href. |
loading | boolean | false | Shows a Spinner, keeps the width and blocks presses while keeping focus on the button. |
showTextWhileLoading | boolean | false | Keep the label visible with the spinner in place of the leading icon. |
iconOnly | boolean | false | Square button sized for a single icon. Pass aria-label. |
block | boolean | false | Stretch to the container width. |
onAction* | () => Promise<unknown> | — | StatefulButton only. Resolve to show the check, reject to shake. |
successLabel | ReactNode | "Done" | StatefulButton only. Label shown for 1.8s after the action resolves. |
strength | number | 0.35 | MagneticButton only. How far the button follows the cursor (0–1). |
buttonClasses() | ({ variant, size, iconOnly, block, className }) => string | — | Server-safe class string for anything that should look like a button. |
...props | HTMLMotionProps<"button"> | — | Any button or motion prop, forwarded to the element. |
Accessibility
- Renders a native <button> with type="button" by default, so Enter and Space work and it never submits forms by accident; with href it renders a real <a>.
- loading sets aria-busy and aria-disabled instead of disabled, so keyboard focus is not lost; presses and implicit form submits are blocked until it finishes.
- Disabled links render without href and with aria-disabled, so they are skipped by Tab and can't be followed.
- Icons and the spinner are aria-hidden; icon-only buttons need an aria-label. Visible focus ring on keyboard focus.