PixelDosa
core

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.

Overview
View code

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>

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.

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.

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.

Source

The exact file shadcn add writes into your project.

button.tsx
"use client";

import * as React from "react";
import { Slot, Slottable } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";

import { cn } from "@/lib/utils";

const buttonVariants = cva(
  [
    "inline-flex items-center justify-center gap-2 shrink-0 whitespace-nowrap",
    "rounded-md text-sm font-medium",
    "outline-none focus-visible:ring-[3px] focus-visible:ring-ring/40 focus-visible:border-ring",
    "disabled:pointer-events-none disabled:opacity-50",
    "aria-invalid:ring-destructive/20 aria-invalid:border-destructive",
    "[&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
    // Duration and easing come from the generated motion-token custom properties
    // rather than Tailwind's numeric scale, so the press feedback stays in step
    // with every other transition in the system.
    "transition-[color,background-color,border-color,box-shadow,transform]",
    "duration-[var(--pd-duration-instant)] ease-[var(--pd-ease-standard)]",
    "active:scale-[0.98]",
    // Reduced-motion fallback: the scale cue is dropped, but the colour and ring
    // state changes remain, so pressed state is still communicated.
    "motion-reduce:transition-none motion-reduce:active:scale-100",
  ],
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground hover:bg-primary/90",
        secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
        outline:
          "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
        ghost: "hover:bg-accent hover:text-accent-foreground",
        destructive:
          "bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive/40",
        link: "text-primary underline-offset-4 hover:underline active:scale-100",
      },
      size: {
        sm: "h-8 px-3 has-[>svg]:px-2.5",
        default: "h-9 px-4 has-[>svg]:px-3",
        lg: "h-11 px-6 text-base has-[>svg]:px-5",
        icon: "size-9 p-0",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
);

export interface ButtonProps
  extends React.ComponentPropsWithoutRef<"button">,
    VariantProps<typeof buttonVariants> {
  /**
   * Render the child element instead of a `<button>`, forwarding all styling and
   * behaviour onto it. Use for links that should look like buttons.
   */
  asChild?: boolean;
  /**
   * Show a busy indicator and block interaction. The button stays in the tab
   * order and is announced as busy rather than being removed from it.
   */
  loading?: boolean;
  /** Accessible label announced while `loading` is true. */
  loadingLabel?: string;
}

/**
 * The primary action trigger. Renders a native `<button>` by default, or any
 * element via `asChild`, with variant/size styling driven by CVA and colours
 * sourced entirely from PixelDosa theme tokens.
 *
 * Use for actions; use an anchor (optionally `asChild`) for navigation.
 */
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(function Button(
  {
    className,
    variant,
    size,
    asChild = false,
    loading = false,
    loadingLabel = "Loading",
    disabled,
    children,
    ...props
  },
  ref
) {
  const Comp = asChild ? Slot : "button";

  return (
    <Comp
      ref={ref}
      data-slot="button"
      data-loading={loading || undefined}
      className={cn(buttonVariants({ variant, size }), className)}
      disabled={asChild ? undefined : disabled || loading}
      aria-disabled={asChild && (disabled || loading) ? true : undefined}
      aria-busy={loading || undefined}
      {...props}
    >
      {loading ? <Spinner label={loadingLabel} /> : null}
      {/* Slottable keeps `asChild` working while the spinner sits beside the
          children: without it, Slot sees two children and throws. */}
      <Slottable>{children}</Slottable>
    </Comp>
  );
});

function Spinner({ label }: { label: string }) {
  return (
    <>
      <svg
        aria-hidden="true"
        viewBox="0 0 24 24"
        fill="none"
        className="size-4 animate-spin"
      >
        <circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" />
        <path
          d="M21 12a9 9 0 0 0-9-9"
          stroke="currentColor"
          strokeWidth="3"
          strokeLinecap="round"
        />
      </svg>
      <span className="sr-only">{label}</span>
    </>
  );
}

export { Button, buttonVariants };