PixelDosa UI

Button

A clickable control that triggers an action, with six variants, four sizes, an accessible loading state, and asChild support for rendering as a link.

Installation

bash
npx shadcn@latest add @pixeldosa/button

Requires `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.
  • asChild polymorphism: renders a Link, 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-motion while 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

PropTypeDefaultDescription
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.
asChildbooleanfalseRender the child element instead of a <button>, forwarding styles and behaviour.
loadingbooleanfalseShow a busy indicator and block interaction. Sets aria-busy and keeps the button focusable.
loadingLabelstring"Loading"Screen-reader text announced while loading is true.
disabledbooleanfalseStandard 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 ring token offset from the border colour, visible in both themes.
  • size="icon" has no text content — supply aria-label. The demo above shows the pattern.
  • loading sets aria-busy and renders visually hidden status text rather than removing the label, so the button's accessible name never changes mid-interaction.
  • Under prefers-reduced-motion the 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.