Installation
npx shadcn@latest add @pixeldosa/buttonRequires `cn` at `@/lib/utils`, which `npx shadcn init` creates. The press-feedback transition reads --pd-duration-instant and --pd-ease-standard; these are installed with this component, and the full token set arrives with the pixeldosa-theme item. When using asChild the rendered child receives aria-disabled rather than the disabled attribute, since non-button elements do not support it — handle click suppression in your own handler if the child is interactive.
Usage
import { Button } from "@/components/ui/button";
export function SaveBar() {
return <Button onClick={save}>Save changes</Button>;
}
Render as a link — the child element receives all styling and behaviour, so use this whenever the trigger navigates rather than acts:
<Button asChild variant="outline">
<Link href="/docs">Read the docs</Link>
</Button>
Async actions should use loading rather than swapping the button for a spinner. The
button stays mounted, keeps focus, and is announced as busy:
<Button loading={isSaving} loadingLabel="Saving changes">
Save
</Button>
Features
- Six variants, four sizes, all resolved from the same semantic tokens — restyling the system restyles every button without touching this file.
asChildpolymorphism: renders aLink, an<a>, or any element with the button's full styling and behaviour, so a navigating trigger never has to fake it.- A real loading state that keeps the button mounted and focused rather than
swapping it for a spinner, and announces itself with
aria-busy. - Press feedback that degrades honestly — the transform drops under
prefers-reduced-motionwhile colour and ring changes still communicate the state.
Variants
default is the single primary action in a view. Everything else steps down from it —
contrast carries the hierarchy, not colour.
Sizes
icon renders a square target for icon-only triggers — always pair it with an
aria-label.
Loading
Disabled
Props
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "default" | "secondary" | "outline" | "ghost" | "destructive" | "link" | "default" | Visual treatment. Use default for the single primary action in a view. |
size | "sm" | "default" | "lg" | "icon" | "default" | icon renders a square button; always pair it with an aria-label. |
asChild | boolean | false | Render the child element instead of a <button>, forwarding styles and behaviour. |
loading | boolean | false | Show a busy indicator and block interaction. Sets aria-busy and keeps the button focusable. |
loadingLabel | string | "Loading" | Screen-reader text announced while loading is true. |
disabled | boolean | false | Standard native disabled state. With asChild, aria-disabled is applied instead. |
All other props are forwarded to the underlying <button> element.
Accessibility
- Renders a native
<button>, so keyboard activation via Enter and Space is browser-native. - The focus ring is a 3px
ringtoken offset from the border colour, visible in both themes. size="icon"has no text content — supplyaria-label. The demo above shows the pattern.loadingsetsaria-busyand renders visually hidden status text rather than removing the label, so the button's accessible name never changes mid-interaction.- Under
prefers-reduced-motionthe press transform is removed; colour and ring changes still communicate the state.
why it's built this way
Engineering Notes
Variants are declared with CVA rather than conditional className strings so that the full variant matrix is a single serialisable object: it can be type-derived (VariantProps), documented from source, and extended by consumers via buttonVariants() without forking the component. The loading state disables the button but deliberately keeps it mounted and in the tab order — swapping a button for a spinner element moves focus to the document body mid-interaction, which is the most common accessibility regression in loading buttons. asChild uses Radix Slot instead of a render-prop so that ref forwarding and event composition are handled by the primitive rather than reimplemented here.
how it moves, and when it doesn't
Motion Notes
The only motion is a 2% active-state scale, driven by CSS transition rather than Motion — a button appears dozens of times per page and forcing a JS animation runtime into it would be a bundle cost with no interaction benefit. Duration (instant, 100ms) and easing come from CSS custom properties generated from the motion tokens, so the press feedback stays in step with the rest of the system. The motion solves one problem: confirming that a press registered before the resulting state change lands. Under prefers-reduced-motion the transform is dropped entirely and the state remains legible through the background and focus-ring changes, which is a fallback rather than a silent removal.