# Button

> A reusable button in four sizes and five hierarchies, with icons, a loading spinner, a shortcut chip and destructive versions.

The everyday button, built properly: four sizes, primary to link, destructive versions, icons, a loading spinner, a disabled state and a keyboard-shortcut chip. Its demo puts it to work: Deploy ⌘D runs with a spinner beside its words, and the other hierarchies sit in a row under it. Its anatomy is numbered part by part, and Fig. 1 takes it apart into its six layers in 3D, from the drop shadow to the focus ring, with every dimension read from the real button.

A React component by Bruno Delorence: https://brunodelorence.com/components/button. MIT License, Copyright (c) 2026 Bruno Delorence.

Category: Inputs.

## Anatomy

1. **Container**: The fill, a 1 px ring at 18% black over it, a 2 px bevel at the bottom and one soft shadow. Its height is the size: 36, 40, 44 or 48.
2. **Leading icon**: 20 px at every size, in the icon colour: 60% white on the primary, grey on the rest. While loading with words, the spinner takes its place.
3. **Label**: A verb and an object, three words at most. Its own 2 px of padding sets it 6 px from the icon.
4. **Shortcut chip**: A label, not a binding: hidden from screen readers and given to the button as `aria-keyshortcuts` (`Meta+K`).
5. **Trailing icon**: Says where the action leads: an arrow for the next step, a chevron for a menu.
6. **Icon-only**: Square at every size, so a row of them lines up. It needs an `aria-label`: the icon names nothing to a screen reader.

## When to use

- The next thing a person does on a screen or in a form: `Save changes`, `Send invite`, `Export`.
- One primary per view, with secondary and tertiary buttons for the alternatives beside it.
- Commands with a keyboard shortcut worth learning, shown in the `shortcut` chip.
- Actions that wait on a server: `isLoading` keeps the width and the name while it works.
- Deleting or anything that can’t be undone, drawn red with `destructive` before it is pressed.

## When not to use

- Going to another page from running text. Use a link; give a button an `href` only among other buttons.
- Turning a setting on or off. A switch or a checkbox shows its state; a button doesn’t.
- Choosing one of a few options. Use a segmented control or radios.
- Two primaries side by side. When everything is loud, nothing is.
- An icon alone that isn’t universally known, without a tooltip and an `aria-label`.

## Installation

Install it with the shadcn CLI, into components/button of any React project:

```sh
npx shadcn@latest add https://brunodelorence.com/r/button.json
```

Or copy all the source files below into one folder of your project. Either way it needs React 19.2 and a bundler with CSS Modules (Next.js, Vite).

It has a dark colour scheme. Its colours are CSS light-dark() pairs, kept inside @supports so that builds which rewrite light-dark() for older browsers leave them alone: it follows your page’s color-scheme (light unless the page sets one) or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

## Usage

```tsx
import { Button } from './button';
import { ArrowUpIcon } from './icons';

export function DeployButton({ onDeploy }: { onDeploy: () => Promise<void> }) {
  const [busy, setBusy] = useState(false);
  return (
    <Button
      iconLeading={ArrowUpIcon}
      shortcut="⌘D"
      isLoading={busy}
      showTextWhileLoading
      onClick={async () => {
        setBusy(true);
        await onDeploy();
        setBusy(false);
      }}
    >
      Deploy
    </Button>
  );
}
```

## States

- **Rest**: Fill, ring, bevel, highlight and shadow. Nothing moves.
- **Hover**: One step darker on the primary, a grey wash on the others. The words keep their colour.
- **Pressed**: The bevel moves to the top edge and the shadow goes: the button sits down.
- **Focus**: A 2 px ring, 2 px out, in the accent. Only from the keyboard (`:focus-visible`).
- **Loading**: The spinner sits over words made transparent, so the width and the name stay. `aria-busy`, still focusable.
- **Loading, with words**: With `showTextWhileLoading` the spinner takes the icon’s place and the words stay.
- **Disabled**: Half opacity, natively disabled. Say why nearby: a disabled button explains nothing.
- **Destructive**: The same states in red, for actions that delete or can’t be undone.

## Examples

### Sizes

sm, md, lg and xl: 36 to 48 px tall, with the padding and type that go with them.

```tsx
import { type CSSProperties, type ReactNode } from 'react';
import { Button } from './button';

const buttonStage: CSSProperties = {
  display: 'flex',
  flexWrap: 'wrap',
  alignItems: 'center',
  justifyContent: 'center',
  gap: 12,
  minHeight: 220,
  padding: '32px 20px',
  borderRadius: 16,
  background: 'light-dark(#fff, #0e0e11)',
  boxShadow: 'inset 0 0 0 1px light-dark(rgb(0 0 0 / 0.07), rgb(255 255 255 / 0.07))',
};

function ButtonRow({ children }: { children: ReactNode }) {
  return <div style={buttonStage}>{children}</div>;
}

export function Sizes() {
  return (
    <ButtonRow>
      <Button size="sm">Button sm</Button>
      <Button size="md">Button md</Button>
      <Button size="lg">Button lg</Button>
      <Button size="xl">Button xl</Button>
    </ButtonRow>
  );
}
```

### Hierarchies

Primary, secondary, tertiary, and the two links (which render an <a> with their href).

```tsx
import { type CSSProperties, type ReactNode } from 'react';
import { Button } from './button';

const buttonStage: CSSProperties = {
  display: 'flex',
  flexWrap: 'wrap',
  alignItems: 'center',
  justifyContent: 'center',
  gap: 12,
  minHeight: 220,
  padding: '32px 20px',
  borderRadius: 16,
  background: 'light-dark(#fff, #0e0e11)',
  boxShadow: 'inset 0 0 0 1px light-dark(rgb(0 0 0 / 0.07), rgb(255 255 255 / 0.07))',
};

function ButtonRow({ children }: { children: ReactNode }) {
  return <div style={buttonStage}>{children}</div>;
}

export function Hierarchies() {
  return (
    <ButtonRow>
      <Button hierarchy="primary">Primary</Button>
      <Button hierarchy="secondary">Secondary</Button>
      <Button hierarchy="tertiary">Tertiary</Button>
      <Button hierarchy="link-color" href="#hierarchies">
        Link color
      </Button>
      <Button hierarchy="link-gray" href="#hierarchies">
        Link gray
      </Button>
    </ButtonRow>
  );
}
```

### With icons

Leading and trailing icons, icon-only squares with an aria-label, and both at once.

```tsx
import { type CSSProperties, type ReactNode } from 'react';
import { Button } from './button';
import { ArrowRightIcon, CopyIcon, DownloadIcon, PlusIcon, SparkIcon } from './icons';

const buttonStage: CSSProperties = {
  display: 'flex',
  flexWrap: 'wrap',
  alignItems: 'center',
  justifyContent: 'center',
  gap: 12,
  minHeight: 220,
  padding: '32px 20px',
  borderRadius: 16,
  background: 'light-dark(#fff, #0e0e11)',
  boxShadow: 'inset 0 0 0 1px light-dark(rgb(0 0 0 / 0.07), rgb(255 255 255 / 0.07))',
};

function ButtonRow({ children }: { children: ReactNode }) {
  return <div style={buttonStage}>{children}</div>;
}

export function WithIcons() {
  return (
    <ButtonRow>
      <Button iconLeading={PlusIcon}>New project</Button>
      <Button hierarchy="secondary" iconTrailing={ArrowRightIcon}>
        Continue
      </Button>
      <Button hierarchy="secondary" iconLeading={DownloadIcon} aria-label="Download" />
      <Button hierarchy="tertiary" iconLeading={CopyIcon} aria-label="Copy" />
      <Button size="lg" iconLeading={SparkIcon} iconTrailing={ArrowRightIcon}>
        Generate
      </Button>
    </ButtonRow>
  );
}
```

### Destructive

The red version of each hierarchy, set in one word with color="primary-destructive" and its siblings.

```tsx
import { type CSSProperties, type ReactNode } from 'react';
import { Button } from './button';
import { TrashIcon } from './icons';

const buttonStage: CSSProperties = {
  display: 'flex',
  flexWrap: 'wrap',
  alignItems: 'center',
  justifyContent: 'center',
  gap: 12,
  minHeight: 220,
  padding: '32px 20px',
  borderRadius: 16,
  background: 'light-dark(#fff, #0e0e11)',
  boxShadow: 'inset 0 0 0 1px light-dark(rgb(0 0 0 / 0.07), rgb(255 255 255 / 0.07))',
};

function ButtonRow({ children }: { children: ReactNode }) {
  return <div style={buttonStage}>{children}</div>;
}

export function Destructive() {
  return (
    <ButtonRow>
      <Button color="primary-destructive" iconLeading={TrashIcon}>
        Delete project
      </Button>
      <Button color="secondary-destructive">Remove member</Button>
      <Button color="tertiary-destructive">Discard</Button>
      <Button color="link-destructive" href="#destructive">
        Leave workspace
      </Button>
    </ButtonRow>
  );
}
```

### Loading & disabled

Press Save changes: the spinner takes the icon’s place and the words stay. Without showTextWhileLoading the spinner sits in the middle; disabled draws at half opacity.

```tsx
'use client';

import { useState, type CSSProperties, type ReactNode } from 'react';
import { Button } from './button';
import { PlusIcon } from './icons';

const buttonStage: CSSProperties = {
  display: 'flex',
  flexWrap: 'wrap',
  alignItems: 'center',
  justifyContent: 'center',
  gap: 12,
  minHeight: 220,
  padding: '32px 20px',
  borderRadius: 16,
  background: 'light-dark(#fff, #0e0e11)',
  boxShadow: 'inset 0 0 0 1px light-dark(rgb(0 0 0 / 0.07), rgb(255 255 255 / 0.07))',
};

function ButtonRow({ children }: { children: ReactNode }) {
  return <div style={buttonStage}>{children}</div>;
}

export function LoadingAndDisabled() {
  const [saving, setSaving] = useState(false);
  const save = () => {
    setSaving(true);
    window.setTimeout(() => setSaving(false), 2200);
  };
  return (
    <ButtonRow>
      <Button isLoading={saving} showTextWhileLoading onClick={save}>
        {saving ? 'Saving…' : 'Save changes'}
      </Button>
      <Button hierarchy="secondary" isLoading>
        Publishing
      </Button>
      <Button isDisabled>Disabled</Button>
      <Button hierarchy="secondary" isDisabled iconLeading={PlusIcon}>
        Disabled
      </Button>
    </ButtonRow>
  );
}
```

### Shortcut

A shortcut chip at the end of each hierarchy, given to assistive technology as aria-keyshortcuts.

```tsx
import { type CSSProperties, type ReactNode } from 'react';
import { Button } from './button';
import { ArrowUpIcon } from './icons';

const buttonStage: CSSProperties = {
  display: 'flex',
  flexWrap: 'wrap',
  alignItems: 'center',
  justifyContent: 'center',
  gap: 12,
  minHeight: 220,
  padding: '32px 20px',
  borderRadius: 16,
  background: 'light-dark(#fff, #0e0e11)',
  boxShadow: 'inset 0 0 0 1px light-dark(rgb(0 0 0 / 0.07), rgb(255 255 255 / 0.07))',
};

function ButtonRow({ children }: { children: ReactNode }) {
  return <div style={buttonStage}>{children}</div>;
}

export function Shortcut() {
  return (
    <ButtonRow>
      <Button iconLeading={ArrowUpIcon} shortcut="⌘D">
        Deploy
      </Button>
      <Button hierarchy="secondary" shortcut="⌘K">
        Search
      </Button>
      <Button hierarchy="tertiary" shortcut="⇧N">
        New file
      </Button>
      <Button size="lg" color="primary-destructive" shortcut="⌘⌫">
        Delete
      </Button>
    </ButtonRow>
  );
}
```

## How it works

- The button is a <button>, or an <a> when it has an href, and passes its ref and every native prop through. Sizes are measured steps: 36, 40, 44 and 48 px tall, 12 to 18 px of side padding, 4 or 6 px between parts, 14/20 or 16/24 type at 600, 20 px icons, 8 px corners. The words carry 2 px of padding of their own.
- Its look is a handful of private custom properties (--_bg, --_fg, --_edge, --_ring, --_bevel, --_highlight, --_shadow, --_chip-bg, --_focus). A hierarchy sets them from light-dark() palette tokens, the states change them, and the paint reads only them, so a new hierarchy is a list of tokens rather than a list of rules.
- The surface is skeuomorphic: a 1 px inset ring at 18 % black over the fill, a 2 px bevel along the bottom, a 1 px highlight inside the top edge that fades downwards (a masked ::before) and one soft drop shadow. Pressed moves the bevel to the top and drops the shadow.
- Loading keeps the width and the name: the spinner takes the leading icon’s place, or sits in the middle over words made transparent (never removed), with aria-busy set and clicks ignored.
- The demo is the real Button at work: Deploy (or ⌘D / Ctrl+D while the demo has focus) loads for 1.6 s with showTextWhileLoading, then says it is done in a status line. While on screen and untouched, it points at and presses each button in turn through data-state, the same forced states the docs use; any input hands it to the reader.
- Fig. 1, under Anatomy, draws every layer with the real Button: each is the whole button with the parts it doesn’t own switched off by an inline override of those custom properties. So the figure is the component’s own CSS in every state and both schemes.
- Its captions and red dimensions are read back from a hidden specimen of the same button with getComputedStyle and offsets: size, padding, radius, colours, shadows, type, chip and focus ring. Tokens mode names each colour by matching it against the palette tokens, resolved through a probe in the same color-scheme.
- The view is CSS 3D with no perspective, an orthographic camera 60° down and 34° round. Taking it apart lifts the layers 14 px apart on a 900 ms ease-out, 25 ms after one another, and lowers the stack by half so it stays centred. The scene is drawn at real size and magnified with zoom, so type and hairlines stay sharp.
- Picking a layer dims the others to 16 % in 280 ms and fades in its outline, corner dots, the vertical guides and its dimensions. Dragging turns the camera and it springs back on release.

## API reference

- **size** (`'sm' | 'md' | 'lg' | 'xl'`, default `'md'`): Button: 36, 40, 44 or 48 px tall.
- **hierarchy** (`'primary' | 'secondary' | 'tertiary' | 'link-color' | 'link-gray'`, default `'primary'`): Button: how loud it is, from a filled primary to a bare link.
- **color** (`ButtonHierarchy | 'primary-destructive' | 'secondary-destructive' | 'tertiary-destructive' | 'link-destructive'`): Button: the hierarchy and its destructive version in one word; wins over hierarchy.
- **destructive** (`boolean`, default `false`): Button: red, for actions that delete or can’t be undone.
- **iconLeading** (`ReactNode | ComponentType`): Button: an icon before the words, as an element or a component. Alone, it makes a square icon-only button (give it an aria-label).
- **iconTrailing** (`ReactNode | ComponentType`): Button: an icon after the words.
- **isLoading** (`boolean`, default `false`): Button: shows a spinner, sets aria-busy and ignores clicks, keeping its width and name.
- **showTextWhileLoading** (`boolean`, default `false`): Button: while loading, keeps the words visible with the spinner before them.
- **isDisabled** (`boolean`, default `false`): Button: disables it (natively for a <button>; a link loses its href). disabled works too.
- **shortcut** (`string`): Button: a shortcut drawn in a chip at the end, like ‘⌘D’, and given as aria-keyshortcuts. Binding the key is left to the page.
- **href** (`string`): Button: makes it an <a>.
- **ref, …native props** (`Ref<HTMLButtonElement | HTMLAnchorElement>`): Button: passed to the element, like type, onClick, aria-label or form.
- **autoplay** (`boolean`, default `true`): ButtonShowcase (the demo): while on screen and untouched, points at and presses each button in turn.
- **colorScheme** (`'light' | 'dark' | 'system'`): ButtonShowcase: its colours: light, dark, or the reader’s system setting. Without it, it follows the page’s color-scheme.
- **className** (`string`): Both: extra class for the root.

## Accessibility

- Button is a native <button> (type="button" unless set) or <a>; an icon-only button needs an aria-label. Its focus ring is a 2 px outline 2 px out, drawn on :focus-visible only.
- Loading sets aria-busy and aria-disabled but keeps the button focusable and its words in the accessible name; the spinner is hidden from screen readers.
- The shortcut chip is hidden from screen readers and exposed as aria-keyshortcuts (⌘D is Meta+D).
- The demo’s status line is a polite live region for the reader’s own presses and silent while it plays; ⌘D / Ctrl+D deploys only while the demo has focus.
- Fig. 1, under Anatomy, is a focusable group with a description of its keys: ↑ ↓ pick a layer, ← → turn it, 1–5 pick a state, T token names, Enter takes it apart, Escape puts it together. The toolbar under it does the same with buttons that say whether they are pressed.
- The figure’s caption is a polite live region while the reader drives it, and silent during its walk-through. The 3D scene is hidden from screen readers.
- Under reduced motion neither the demo nor the figure plays, the layers move without travel and the spinner turns slowly.

## Source

### button/button.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import {
  createElement,
  isValidElement,
  type AnchorHTMLAttributes,
  type ButtonHTMLAttributes,
  type ComponentType,
  type MouseEvent,
  type ReactNode,
  type Ref,
} from 'react';
import styles from './button.module.css';

/*
 * Button: the everyday action. A <button> by default, an <a> when it has an href; four sizes,
 * five hierarchies (each with a destructive version), icons before or after the words or alone,
 * a loading spinner, a disabled state and an optional keyboard-shortcut chip.
 *
 * - Every look is a handful of private custom properties on the root (--_bg, --_fg, --_edge,
 *   --_ring, --_bevel, --_highlight, --_shadow, --_chip-bg, --_focus…) that the hierarchy sets and
 *   the states (hover, pressed, focus) change. The paint reads only those, so a hierarchy is a
 *   list of tokens, not a list of rules, and the anatomy figure can switch any part off with an
 *   inline override and draw the real button one layer at a time.
 * - The surface is skeuomorphic: a 1 px inset ring at 18 % black over the fill, a 2 px bevel at
 *   the bottom edge, a 1 px highlight inside the top edge that fades out downwards (a masked
 *   ::before) and one soft drop shadow.
 * - Sizes are measured: sm 36 / md 40 / lg 44 / xl 48 px tall, 12 / 14 / 16 / 18 px of side
 *   padding, 4 or 6 px between the parts, 14/20 or 16/24 type, 20 px icons. The words carry 2 px
 *   of padding of their own, so an icon sits 6 px from them. Icon-only buttons are square.
 * - Loading keeps the button's width and its accessible name: the spinner takes the leading
 *   icon's place (showTextWhileLoading) or sits in the middle over words made transparent, never
 *   removed. The button reports aria-busy and ignores clicks, and stays focusable.
 * - A disabled <button> is disabled natively; a disabled link loses its href and says
 *   aria-disabled. Both draw at half opacity.
 * - The shortcut chip is a label: it is hidden from screen readers and given to the button as
 *   aria-keyshortcuts (⌘D becomes Meta+D). Binding the key is left to the page.
 * - Light or dark: the palette is light-dark() tokens on the root, so it follows the page's
 *   color-scheme.
 */

export type ButtonSize = 'sm' | 'md' | 'lg' | 'xl';
export type ButtonHierarchy = 'primary' | 'secondary' | 'tertiary' | 'link-color' | 'link-gray';
/** A hierarchy, or one of the destructive versions, as one word. */
export type ButtonColor =
  ButtonHierarchy | `${'primary' | 'secondary' | 'tertiary' | 'link'}-destructive`;
/** An icon: an element (<PlusIcon />) or a component that takes a className (PlusIcon). */
export type ButtonIcon = ReactNode | ComponentType<{ className?: string }>;

type CommonProps = {
  /** sm 36, md 40, lg 44 or xl 48 px tall. */
  size?: ButtonSize;
  /** How loud it is: a filled primary, an outlined secondary, a bare tertiary, or a link. */
  hierarchy?: ButtonHierarchy;
  /** The same as hierarchy, plus the destructive versions in one word ('primary-destructive'). */
  color?: ButtonColor;
  /** Red, for actions that delete or can't be undone. */
  destructive?: boolean;
  iconLeading?: ButtonIcon;
  iconTrailing?: ButtonIcon;
  /** Shows a spinner and ignores clicks, keeping the button's width and name. */
  isLoading?: boolean;
  /** While loading, keep the words visible with the spinner before them. */
  showTextWhileLoading?: boolean;
  isDisabled?: boolean;
  /** A keyboard shortcut drawn in a chip at the end, like '⌘D'. */
  shortcut?: string;
  children?: ReactNode;
  className?: string;
};

type AsButton = CommonProps &
  Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'color' | 'children'> & {
    href?: undefined;
    ref?: Ref<HTMLButtonElement>;
  };
type AsLink = CommonProps &
  Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'color' | 'children'> & {
    href: string;
    ref?: Ref<HTMLAnchorElement>;
  };
export type ButtonProps = AsButton | AsLink;

const MODIFIERS: Record<string, string> = {
  '⌘': 'Meta',
  '⇧': 'Shift',
  '⌥': 'Alt',
  '⌃': 'Control',
  '↵': 'Enter',
  '⌫': 'Backspace',
};

/** '⌘⇧K' → 'Meta+Shift+K'; a shortcut already written with '+' is kept. */
export function keyShortcuts(shortcut: string) {
  if (shortcut.includes('+')) return shortcut.replace(/\s+/g, '');
  const keys: string[] = [];
  let rest = '';
  for (const char of shortcut.replace(/\s+/g, '')) {
    if (MODIFIERS[char]) keys.push(MODIFIERS[char]);
    else rest += char;
  }
  if (rest) keys.push(rest.length === 1 ? rest.toUpperCase() : rest);
  return keys.join('+');
}

/** An element is drawn as it is; a component (a function, or a memo or forwardRef object) is made. */
function renderIcon(icon: ButtonIcon) {
  const component =
    typeof icon === 'function' ||
    (typeof icon === 'object' && icon !== null && '$$typeof' in icon && !isValidElement(icon));
  return component ? createElement(icon as ComponentType) : (icon as ReactNode);
}

function Spinner() {
  return (
    <svg className={styles.spinner} viewBox="0 0 20 20" fill="none" aria-hidden="true">
      <circle className={styles.spinnerTrack} cx="10" cy="10" r="8" />
      <circle className={styles.spinnerArc} cx="10" cy="10" r="8" />
    </svg>
  );
}

function split(color: ButtonColor | undefined, hierarchy: ButtonHierarchy, destructive: boolean) {
  if (!color) return { hierarchy, destructive };
  if (color.endsWith('-destructive')) {
    const base = color.replace('-destructive', '');
    return {
      hierarchy: (base === 'link' ? 'link-color' : base) as ButtonHierarchy,
      destructive: true,
    };
  }
  return { hierarchy: color as ButtonHierarchy, destructive };
}

export function Button(props: ButtonProps) {
  const {
    size = 'md',
    hierarchy: hierarchyProp = 'primary',
    color,
    destructive: destructiveProp = false,
    iconLeading,
    iconTrailing,
    isLoading = false,
    showTextWhileLoading = false,
    isDisabled: isDisabledProp,
    shortcut,
    children,
    className,
    ...rest
  } = props;
  const { hierarchy, destructive } = split(color, hierarchyProp, destructiveProp);
  const nativeDisabled = 'disabled' in rest ? Boolean((rest as AsButton).disabled) : false;
  const isDisabled = Boolean(isDisabledProp) || nativeDisabled;
  const hasLabel = children != null && children !== false && children !== '';
  const iconOnly = !hasLabel && !shortcut && Boolean(iconLeading || iconTrailing);
  const spinnerInline = isLoading && showTextWhileLoading && hasLabel;

  const content = (
    <>
      {isLoading && !spinnerInline && (
        <span className={styles.spinnerCenter} data-part="spinner">
          <Spinner />
        </span>
      )}
      {spinnerInline ? (
        <span className={styles.icon} data-part="spinner">
          <Spinner />
        </span>
      ) : (
        iconLeading && (
          <span className={styles.icon} data-part="icon-leading">
            {renderIcon(iconLeading)}
          </span>
        )
      )}
      {hasLabel && (
        <span className={styles.label} data-part="label">
          {children}
        </span>
      )}
      {iconTrailing && (
        <span className={styles.icon} data-part="icon-trailing">
          {renderIcon(iconTrailing)}
        </span>
      )}
      {shortcut && (
        <kbd className={styles.shortcut} data-part="shortcut" aria-hidden="true">
          <span>{shortcut}</span>
        </kbd>
      )}
    </>
  );

  const shared = {
    className: [styles.button, className].filter(Boolean).join(' '),
    'data-size': size,
    'data-hierarchy': hierarchy,
    'data-destructive': destructive ? '' : undefined,
    'data-icon-only': iconOnly ? '' : undefined,
    'data-shortcut': shortcut ? '' : undefined,
    'data-loading': isLoading ? (spinnerInline ? 'inline' : 'center') : undefined,
    'aria-busy': isLoading || undefined,
    'aria-keyshortcuts': shortcut ? keyShortcuts(shortcut) : undefined,
  };

  if (rest.href !== undefined) {
    const { href, onClick, ref, ...anchor } = rest as AsLink;
    const inactive = isDisabled || isLoading;
    return (
      <a
        {...anchor}
        {...shared}
        ref={ref}
        href={isDisabled ? undefined : href}
        role={isDisabled ? 'link' : anchor.role}
        aria-disabled={inactive || undefined}
        data-disabled={isDisabled ? '' : undefined}
        onClick={(event: MouseEvent<HTMLAnchorElement>) => {
          if (inactive) {
            event.preventDefault();
            return;
          }
          onClick?.(event);
        }}
      >
        {content}
      </a>
    );
  }

  const { onClick, ref, type = 'button', ...button } = rest as AsButton;
  return (
    <button
      {...button}
      {...shared}
      ref={ref}
      type={type}
      disabled={isDisabled}
      aria-disabled={isLoading || undefined}
      data-disabled={isDisabled ? '' : undefined}
      onClick={(event: MouseEvent<HTMLButtonElement>) => {
        if (isLoading) {
          event.preventDefault();
          return;
        }
        onClick?.(event);
      }}
    >
      {content}
    </button>
  );
}
```

### button/button.module.css

```css
/* Button (button.tsx). The palette is light-dark(light, dark) tokens on the root, so a button
   follows the page's color-scheme. A hierarchy sets the private properties below (--_bg, --_fg,
   --_edge…); the states change them; the paint reads only them. The anatomy figure overrides
   them inline to draw one layer at a time, so every rule here must paint through them.

   Measured: 36 / 40 / 44 / 48 px tall; 12 / 14 / 16 / 18 px of side padding; 4 px between parts
   (6 px at lg and xl); 14/20 or 16/24 type at 600; 20 px icons; 8 px corners; 2 px of padding
   around the words; focus ring 2 px, 2 px out; disabled at half opacity; 100 ms colour fades. */

.button {
  /* The palette. */
  --color-white: #fff;
  --color-accent-200: #c9cafa;
  --color-accent-300: #a8a9f6;
  --color-accent-400: #8789ef;
  --color-accent-500: #6a6ce5;
  --color-accent-600: #5557d9;
  --color-accent-700: #4547bd;
  --color-accent-800: #393a98;
  --color-accent-950: #1d1e4a;
  --color-error-50: #fef3f2;
  --color-error-300: #fca19b;
  --color-error-400: #f8726a;
  --color-error-500: #ef4a3f;
  --color-error-600: #d93a2f;
  --color-error-700: #b42d24;
  --color-error-800: #91261f;
  --color-error-950: #3d1310;
  --color-gray-50: #fafafa;
  --color-gray-100: #f4f4f5;
  --color-gray-200: #e9e9eb;
  --color-gray-300: #d6d6da;
  --color-gray-400: #a3a3ab;
  --color-gray-500: #74747d;
  --color-gray-600: #56565f;
  --color-gray-700: #42424a;
  --color-gray-800: #2a2a30;
  --color-gray-900: #1b1b20;
  --color-gray-950: #0e0e11;
  /* The shared skeuomorphic parts: the ring over the fill, the bevel at the bottom, the
     highlight inside the top edge, and the drop shadow. */
  --color-skeuo-ring: rgb(10 10 12 / 0.18);
  --color-skeuo-bevel: rgb(10 10 12 / 0.05);
  --color-on-accent-icon: rgb(255 255 255 / 0.6);
  --color-on-accent-highlight: rgb(255 255 255 / 0.12);
  --color-on-accent-chip: rgb(255 255 255 / 0.16);
  --shadow-xs: 0 1px 2px rgb(10 10 12 / 0.05);
  --spacing: 4px;
  --radius-xs: 4px;
  --radius-md: 8px;

  /* What a hierarchy sets (primary here, the default). */
  --_bg: var(--color-accent-600);
  --_bg-hover: var(--color-accent-700);
  --_fg: var(--color-white);
  --_fg-hover: var(--color-white);
  --_icon: var(--color-on-accent-icon);
  --_icon-hover: var(--color-on-accent-icon);
  --_edge: transparent;
  --_ring: var(--color-skeuo-ring);
  --_bevel: var(--color-skeuo-bevel);
  --_bevel-y: -2px;
  --_highlight: var(--color-on-accent-highlight);
  --_shadow: var(--shadow-xs);
  --_focus: var(--color-accent-400);
  --_chip-bg: var(--color-on-accent-chip);
  --_chip-ring: transparent;
  --_chip-fg: var(--color-white);
  /* 1 shows the icons and words; the anatomy sets 0 to draw the other layers alone. */
  --_ink: 1;

  /* What a size sets (md here, the default). */
  --_h: 40px;
  --_px: calc(var(--spacing) * 3.5);
  --_gap: var(--spacing);
  --_fs: 14px;
  --_lh: 20px;
  --_icon-size: 20px;
  --_text-px: 2px;
  --_chip-h: 20px;
  --_chip-px: calc(var(--spacing) * 1.5);

  position: relative;
  display: inline-flex;
  flex-shrink: 0;
  align-items: center;
  justify-content: center;
  gap: var(--_gap);
  box-sizing: border-box;
  height: var(--_h);
  margin: 0;
  padding: 0 var(--_px);
  border: 0;
  border-radius: var(--radius-md);
  outline: 2px solid transparent;
  outline-offset: 2px;
  background-color: var(--_bg);
  box-shadow:
    inset 0 0 0 1px var(--_edge),
    inset 0 0 0 1px var(--_ring),
    inset 0 var(--_bevel-y) 0 0 var(--_bevel),
    var(--_shadow);
  color: var(--_fg);
  font: inherit;
  font-size: var(--_fs);
  font-weight: 600;
  line-height: var(--_lh);
  text-decoration: none;
  white-space: nowrap;
  cursor: pointer;
  user-select: none;
  -webkit-tap-highlight-color: transparent;
  transition:
    color 100ms linear,
    background-color 100ms linear,
    box-shadow 100ms linear,
    outline-color 100ms linear,
    opacity 100ms linear;
}
/* The highlight: a 1 px line inside the edge, fading out from the top. */
.button::before {
  content: '';
  position: absolute;
  inset: 1px;
  border: 1px solid var(--_highlight);
  border-radius: calc(var(--radius-md) - 1px);
  mask-image: linear-gradient(#000, transparent);
  pointer-events: none;
}
@supports (color: light-dark(#fff, #000)) {
  .button {
    --_focus: light-dark(var(--color-accent-400), var(--color-accent-300));
  }
}

/* Sizes. */
.button[data-size='sm'] {
  --_h: 36px;
  --_px: calc(var(--spacing) * 3);
}
.button[data-size='lg'] {
  --_h: 44px;
  --_px: calc(var(--spacing) * 4);
  --_gap: calc(var(--spacing) * 1.5);
  --_fs: 16px;
  --_lh: 24px;
  --_chip-h: 22px;
}
.button[data-size='xl'] {
  --_h: 48px;
  --_px: calc(var(--spacing) * 4.5);
  --_gap: calc(var(--spacing) * 1.5);
  --_fs: 16px;
  --_lh: 24px;
  --_chip-h: 22px;
}
/* The chip sits closer to the end than words would. */
.button[data-shortcut] {
  padding-right: calc(var(--_px) - var(--spacing));
}
.button[data-icon-only] {
  width: var(--_h);
  padding: 0;
}

/* Hierarchies. Primary is the default above. */
.button[data-hierarchy='secondary'],
.button[data-hierarchy='tertiary'],
.button[data-hierarchy^='link'] {
  --_highlight: transparent;
  --_chip-bg: var(--color-gray-100);
  --_chip-ring: var(--color-gray-200);
  --_chip-fg: var(--color-gray-500);
  --_focus: var(--color-accent-400);
}
.button[data-hierarchy='secondary'] {
  --_bg: var(--color-white);
  --_bg-hover: var(--color-gray-50);
  --_fg: var(--color-gray-700);
  --_fg-hover: var(--color-gray-800);
  --_icon: var(--color-gray-400);
  --_icon-hover: var(--color-gray-500);
  --_edge: var(--color-gray-300);
}
.button[data-hierarchy='tertiary'],
.button[data-hierarchy^='link'] {
  --_bg: transparent;
  --_bg-hover: var(--color-gray-50);
  --_fg: var(--color-gray-600);
  --_fg-hover: var(--color-gray-700);
  --_icon: var(--color-gray-400);
  --_icon-hover: var(--color-gray-500);
  --_ring: transparent;
  --_bevel: transparent;
  --_shadow: 0 0 #0000;
}
.button[data-hierarchy^='link'] {
  --_bg-hover: transparent;
  --_text-px: 0px;
  height: auto;
  padding: 0;
  border-radius: var(--radius-xs);
}
.button[data-hierarchy='link-color'] {
  --_fg: var(--color-accent-700);
  --_fg-hover: var(--color-accent-800);
  --_icon: var(--color-accent-500);
  --_icon-hover: var(--color-accent-600);
}
@supports (color: light-dark(#fff, #000)) {
  .button[data-hierarchy='secondary'],
  .button[data-hierarchy='tertiary'],
  .button[data-hierarchy^='link'] {
    --_chip-bg: light-dark(var(--color-gray-100), var(--color-gray-800));
    --_chip-ring: light-dark(var(--color-gray-200), var(--color-gray-700));
    --_chip-fg: light-dark(var(--color-gray-500), var(--color-gray-400));
    --_focus: light-dark(var(--color-accent-400), var(--color-accent-300));
  }
  .button:not([data-hierarchy]),
  .button[data-hierarchy='primary'] {
    --_bg-hover: light-dark(var(--color-accent-700), var(--color-accent-500));
  }
  .button[data-hierarchy='secondary'] {
    --_bg: light-dark(var(--color-white), var(--color-gray-950));
    --_bg-hover: light-dark(var(--color-gray-50), var(--color-gray-900));
    --_fg: light-dark(var(--color-gray-700), var(--color-gray-300));
    --_fg-hover: light-dark(var(--color-gray-800), var(--color-gray-200));
    --_icon: light-dark(var(--color-gray-400), var(--color-gray-500));
    --_icon-hover: light-dark(var(--color-gray-500), var(--color-gray-400));
    --_edge: light-dark(var(--color-gray-300), var(--color-gray-700));
  }
  .button[data-hierarchy='tertiary'],
  .button[data-hierarchy^='link'] {
    --_bg-hover: light-dark(var(--color-gray-50), var(--color-gray-800));
    --_fg: light-dark(var(--color-gray-600), var(--color-gray-400));
    --_fg-hover: light-dark(var(--color-gray-700), var(--color-gray-300));
    --_icon: light-dark(var(--color-gray-400), var(--color-gray-500));
    --_icon-hover: light-dark(var(--color-gray-500), var(--color-gray-400));
  }
  .button[data-hierarchy^='link'] {
    --_bg-hover: transparent;
  }
  .button[data-hierarchy='link-color'] {
    --_fg: light-dark(var(--color-accent-700), var(--color-accent-300));
    --_fg-hover: light-dark(var(--color-accent-800), var(--color-accent-200));
    --_icon: light-dark(var(--color-accent-500), var(--color-accent-400));
    --_icon-hover: light-dark(var(--color-accent-600), var(--color-accent-300));
  }
}

/* Destructive versions: the same parts in red. */
.button[data-destructive] {
  --_bg: var(--color-error-600);
  --_bg-hover: var(--color-error-700);
  --_focus: var(--color-error-500);
}
.button[data-destructive]:not([data-hierarchy='primary']) {
  --_fg: var(--color-error-700);
  --_fg-hover: var(--color-error-800);
  --_icon: var(--color-error-500);
  --_icon-hover: var(--color-error-600);
}
.button[data-destructive][data-hierarchy='secondary'] {
  --_bg: var(--color-white);
  --_bg-hover: var(--color-error-50);
  --_edge: var(--color-error-300);
}
.button[data-destructive][data-hierarchy='tertiary'] {
  --_bg: transparent;
  --_bg-hover: var(--color-error-50);
}
.button[data-destructive][data-hierarchy^='link'] {
  --_bg: transparent;
  --_bg-hover: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .button[data-destructive] {
    --_focus: light-dark(var(--color-error-500), var(--color-error-400));
  }
  .button[data-destructive]:not([data-hierarchy='primary']) {
    --_fg: light-dark(var(--color-error-700), var(--color-error-400));
    --_fg-hover: light-dark(var(--color-error-800), var(--color-error-300));
    --_icon: light-dark(var(--color-error-500), var(--color-error-400));
    --_icon-hover: light-dark(var(--color-error-600), var(--color-error-300));
  }
  .button[data-destructive][data-hierarchy='secondary'] {
    --_bg: light-dark(var(--color-white), var(--color-gray-950));
    --_bg-hover: light-dark(var(--color-error-50), var(--color-error-950));
    --_edge: light-dark(var(--color-error-300), var(--color-error-800));
  }
  .button[data-destructive][data-hierarchy='tertiary'] {
    --_bg-hover: light-dark(var(--color-error-50), var(--color-error-950));
  }
}

/* States. data-state forces one, for the anatomy figure and for documentation. */
.button:is(:hover, :active, [data-state='hover'], [data-state='pressed']):not(
    :disabled,
    [aria-disabled='true'],
    [data-state='rest'],
    [data-state='focus']
  ) {
  --_bg: var(--_bg-hover);
  --_fg: var(--_fg-hover);
  --_icon: var(--_icon-hover);
}
.button[data-hierarchy^='link']:is(:hover, [data-state='hover']):not(
    :disabled,
    [aria-disabled='true']
  )
  .label {
  text-decoration: underline;
  text-decoration-thickness: 1px;
  text-underline-offset: 3px;
}
/* Pressed: the bevel moves to the top edge and the button no longer lifts. */
.button:is(:active, [data-state='pressed']):not(:disabled, [aria-disabled='true']) {
  --_bevel-y: 1px;
  --_shadow: 0 0 #0000;
}
.button:is(:focus-visible, [data-state='focus']) {
  outline-color: var(--_focus);
}
.button:disabled,
.button[data-disabled] {
  opacity: 0.5;
  cursor: not-allowed;
}
.button[aria-busy='true'] {
  cursor: progress;
}

/* The parts. */
.icon {
  display: inline-flex;
  flex-shrink: 0;
  width: var(--_icon-size);
  height: var(--_icon-size);
  color: var(--_icon);
  opacity: var(--_ink);
  transition: color 100ms linear;
}
.icon > svg {
  width: 100%;
  height: 100%;
}
.label {
  padding: 0 var(--_text-px);
  opacity: var(--_ink);
  transition: opacity 100ms linear;
}
.shortcut {
  display: inline-flex;
  align-items: center;
  box-sizing: border-box;
  height: var(--_chip-h);
  padding: 0 var(--_chip-px);
  border-radius: var(--radius-xs);
  background: var(--_chip-bg);
  box-shadow: inset 0 0 0 1px var(--_chip-ring);
  color: var(--_chip-fg);
  font: inherit;
  font-size: 12px;
  font-weight: 500;
  line-height: 18px;
  letter-spacing: 0.02em;
}
.shortcut > span {
  opacity: var(--_ink);
}

/* Loading. In the middle, the spinner sits over parts made transparent (their width and the
   button's name stay); inline, it takes the leading icon's place. */
.spinnerCenter {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  color: var(--_icon);
  opacity: var(--_ink);
}
.spinnerCenter > .spinner {
  width: var(--_icon-size);
  height: var(--_icon-size);
}
.button[data-loading='center'] > :not(.spinnerCenter) {
  opacity: 0;
}
.spinner {
  width: 100%;
  height: 100%;
  overflow: visible;
}
.spinnerTrack,
.spinnerArc {
  stroke: currentColor;
  stroke-width: 2;
}
.spinnerTrack {
  opacity: 0.3;
}
.spinnerArc {
  stroke-dasharray: 12.5 50;
  stroke-linecap: round;
  transform-origin: 10px 10px;
  animation: spin 1s linear infinite;
}
@keyframes spin {
  to {
    transform: rotate(360deg);
  }
}
@media (prefers-reduced-motion: reduce) {
  .spinnerArc {
    animation-duration: 2.4s;
  }
  .button {
    transition: none;
  }
}
```

### button/icons.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

import type { SVGProps } from 'react';

/*
 * A few line icons for the examples and the figure, drawn on a 20-unit grid with a 1.67 stroke,
 * round ends and joins, in currentColor, so a button tints them like its words. They carry no
 * size: the button sizes its icons in CSS.
 */

type IconProps = SVGProps<SVGSVGElement>;

function Icon({ children, ...props }: IconProps) {
  return (
    <svg
      viewBox="0 0 20 20"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.67}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      focusable="false"
      {...props}
    >
      {children}
    </svg>
  );
}

export function ArrowUpIcon(props: IconProps) {
  return (
    <Icon {...props}>
      <path d="M10 16V4M4.5 9.5 10 4l5.5 5.5" />
    </Icon>
  );
}

export function ArrowRightIcon(props: IconProps) {
  return (
    <Icon {...props}>
      <path d="M4 10h12M10.5 4.5 16 10l-5.5 5.5" />
    </Icon>
  );
}

export function PlusIcon(props: IconProps) {
  return (
    <Icon {...props}>
      <path d="M10 4v12M4 10h12" />
    </Icon>
  );
}

export function TrashIcon(props: IconProps) {
  return (
    <Icon {...props}>
      <path d="M3.5 5.5h13M8 5.5V4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v1.5M5 5.5l.7 10a1.5 1.5 0 0 0 1.5 1.5h5.6a1.5 1.5 0 0 0 1.5-1.5l.7-10M8.5 9v4.5M11.5 9v4.5" />
    </Icon>
  );
}

export function DownloadIcon(props: IconProps) {
  return (
    <Icon {...props}>
      <path d="M10 3.5v9M6 8.5l4 4 4-4M4 16.5h12" />
    </Icon>
  );
}

export function CopyIcon(props: IconProps) {
  return (
    <Icon {...props}>
      <rect x="7" y="7" width="9.5" height="9.5" rx="2" />
      <path d="M13 7V5.5a2 2 0 0 0-2-2H5.5a2 2 0 0 0-2 2V11a2 2 0 0 0 2 2H7" />
    </Icon>
  );
}

export function SparkIcon(props: IconProps) {
  return (
    <Icon {...props}>
      <path d="M10 3c.4 3.5 1.9 5.6 6 7-4.1 1.4-5.6 3.5-6 7-.4-3.5-1.9-5.6-6-7 4.1-1.4 5.6-3.5 6-7Z" />
    </Icon>
  );
}
```

### button/showcase.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import { useEffect, useRef, useState, type KeyboardEvent } from 'react';
import { Button } from './button';
import { ArrowRightIcon, ArrowUpIcon, CopyIcon, DownloadIcon, TrashIcon } from './icons';
import { useAutoplay } from './use-autoplay';
import styles from './showcase.module.css';

/*
 * Button showcase: the button at work. A primary "Deploy ⌘D" that really deploys (a loading
 * spinner beside its words, then a line saying it is done), and under it a row of the other
 * hierarchies: secondary, tertiary, a link, an icon-only square and a destructive one.
 *
 * - Every button is the real Button. Pressing Deploy, or ⌘D / Ctrl+D while the showcase has
 *   focus, runs the deploy: isLoading with showTextWhileLoading for 1.6 s, then "Deployed" for
 *   a moment. The other buttons say what they would do in the same status line.
 * - While on screen and untouched, it plays a slow walk-through: it points at each button in
 *   turn (data-state="hover"), presses it (data-state="pressed"), and ends on a deploy. The
 *   states are the button's own, forced through data-state, so nothing here paints a button.
 *   Any real input hands it to the reader, and it takes back over after six idle seconds.
 * - The status line is a polite live region for the reader's own presses and silent during the
 *   walk-through. Nothing plays under reduced motion or while the showcase is off screen.
 * - Light or dark: the stage's colours are light-dark() tokens, following the page's
 *   color-scheme or the colorScheme prop.
 */

type Id = 'deploy' | 'preview' | 'copy' | 'logs' | 'download' | 'delete';

const SAY: Record<Id, string> = {
  deploy: 'Deployed to production',
  preview: 'Preview opened',
  copy: 'Link copied',
  logs: 'Logs opened',
  download: 'Download started',
  delete: 'Deleted, with a way to undo',
};

/** The walk-through: who is pointed at, then pressed, one beat each (ms). */
const WALK: { id: Id; state: 'hover' | 'pressed' | 'rest'; ms: number }[] = [
  ...(['preview', 'copy', 'logs', 'download', 'delete'] as const).flatMap((id) => [
    { id, state: 'hover' as const, ms: 900 },
    { id, state: 'pressed' as const, ms: 160 },
    { id, state: 'hover' as const, ms: 500 },
  ]),
  { id: 'deploy', state: 'hover', ms: 1000 },
  { id: 'deploy', state: 'pressed', ms: 180 },
  { id: 'deploy', state: 'rest', ms: 4200 },
];

const DEPLOY_MS = 1600;
const DONE_MS = 2200;

export type ButtonShowcaseProps = {
  /** While on screen and untouched, points at and presses each button in turn. */
  autoplay?: boolean;
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
};

export function ButtonShowcase({ autoplay = true, colorScheme, className }: ButtonShowcaseProps) {
  const { ref, take, playing: canPlay } = useAutoplay<HTMLDivElement>();
  const playing = autoplay && canPlay;
  const [beat, setBeat] = useState(-1);
  const [deploying, setDeploying] = useState(false);
  const [status, setStatus] = useState<{ text: string; live: boolean } | null>(null);
  const deployTimer = useRef(0);
  const statusTimer = useRef(0);
  useEffect(
    () => () => {
      window.clearTimeout(deployTimer.current);
      window.clearTimeout(statusTimer.current);
    },
    [],
  );

  /** Says something in the status line for a moment (or until the next thing, when ms is 0). */
  const say = (text: string, live: boolean, ms = DONE_MS) => {
    window.clearTimeout(statusTimer.current);
    setStatus({ text, live });
    if (ms) statusTimer.current = window.setTimeout(() => setStatus(null), ms);
  };

  /** Runs what a button does. */
  const act = (id: Id, live: boolean) => {
    if (id !== 'deploy') return say(SAY[id], live);
    if (deploying) return;
    setDeploying(true);
    say('Deploying…', live, 0);
    deployTimer.current = window.setTimeout(() => {
      setDeploying(false);
      say(SAY.deploy, live);
    }, DEPLOY_MS);
  };

  // The walk-through: one beat at a time; a press does what the button does, silently.
  useEffect(() => {
    if (!playing) {
      setBeat(-1);
      return;
    }
    const step = WALK[Math.max(beat, 0)];
    const timer = window.setTimeout(() => setBeat((beat + 1) % WALK.length), step.ms);
    if (beat >= 0 && step.state === 'pressed') act(step.id, false);
    return () => window.clearTimeout(timer);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [playing, beat]);

  const step = playing && beat >= 0 ? WALK[beat] : null;
  const forced = (id: Id) => (step?.id === id && step.state !== 'rest' ? step.state : undefined);
  const press = (id: Id) => () => {
    take();
    act(id, true);
  };

  const onKeyDown = (event: KeyboardEvent) => {
    take();
    if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'd') {
      event.preventDefault();
      act('deploy', true);
    }
  };

  return (
    <div
      ref={ref}
      className={[styles.root, className].filter(Boolean).join(' ')}
      data-color-scheme={colorScheme}
      onPointerDown={take}
      onKeyDown={onKeyDown}
    >
      <div className={styles.stage}>
        <Button
          size="lg"
          iconLeading={ArrowUpIcon}
          shortcut="⌘D"
          isLoading={deploying}
          showTextWhileLoading
          data-state={forced('deploy')}
          onClick={press('deploy')}
        >
          Deploy
        </Button>
        <p
          className={styles.status}
          role="status"
          aria-live={status?.live ? 'polite' : 'off'}
          data-shown={status ? '' : undefined}
        >
          {status?.text ?? ' '}
        </p>
        <div className={styles.row}>
          <Button
            hierarchy="secondary"
            iconTrailing={ArrowRightIcon}
            data-state={forced('preview')}
            onClick={press('preview')}
          >
            Preview
          </Button>
          <Button
            hierarchy="tertiary"
            iconLeading={CopyIcon}
            data-state={forced('copy')}
            onClick={press('copy')}
          >
            Copy link
          </Button>
          <Button hierarchy="link-color" data-state={forced('logs')} onClick={press('logs')}>
            View logs
          </Button>
          <Button
            hierarchy="secondary"
            iconLeading={DownloadIcon}
            aria-label="Download"
            data-state={forced('download')}
            onClick={press('download')}
          />
          <Button
            color="secondary-destructive"
            iconLeading={TrashIcon}
            data-state={forced('delete')}
            onClick={press('delete')}
          >
            Delete
          </Button>
        </div>
      </div>
    </div>
  );
}
```

### button/showcase.module.css

```css
/* Button showcase (showcase.tsx): a quiet stage with the Deploy button in its middle, a status
   line under it and a row of the other hierarchies below. The buttons paint themselves
   (button.module.css); this file only lays them out.

   Colours are light-dark(light, dark) tokens: the stage takes the color-scheme it inherits from
   the page (light unless the page says otherwise), or the one its colorScheme prop sets
   (data-color-scheme). */

.root {
  --surface: #fff;
  --line: rgb(0 0 0 / 0.06);
  --dots: rgb(0 0 0 / 0.07);
  --muted: #74747d;
  position: relative;
  width: 100%;
  min-height: 360px;
  display: grid;
  place-items: center;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background:
    radial-gradient(circle at 1px 1px, var(--dots) 1px, transparent 1.5px) 0 0 / 16px 16px,
    var(--surface);
  box-shadow: inset 0 0 0 1px var(--line);
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --surface: light-dark(#fff, #0e0e11);
    --line: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.07));
    --dots: light-dark(rgb(0 0 0 / 0.07), rgb(255 255 255 / 0.07));
    --muted: light-dark(#74747d, #a3a3ab);
  }
}
/* The colorScheme prop. Without it, nothing is set: color-scheme is inherited from the page. */
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}

.stage {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 14px;
  padding: 40px 20px;
  max-width: 100%;
}

/* The line under Deploy: what the last press did. It keeps its height, so nothing jumps. */
.status {
  margin: 0;
  min-height: 20px;
  color: var(--muted);
  font:
    500 13px/20px system-ui,
    sans-serif;
  opacity: 0;
  transition: opacity 200ms ease;
}
.status[data-shown] {
  opacity: 1;
}

.row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 10px;
  margin-top: 18px;
  max-width: 560px;
}

@media (prefers-reduced-motion: reduce) {
  .status {
    transition: none;
  }
}
```

### button/use-autoplay.ts

```tsx
'use client';

import { useCallback, useEffect, useRef, useState } from 'react';

/*
 * When a demo may play on its own: while part of it is on screen and the tab is visible, never
 * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it
 * takes back over `idle` ms after their last input.
 */
export function useAutoplay<T extends HTMLElement>(idle = 6000) {
  const ref = useRef<T>(null);
  const [visible, setVisible] = useState(false);
  const [reduced, setReduced] = useState(true);
  const [taken, setTaken] = useState(false);
  const timer = useRef(0);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;
    let onScreen = false;
    const update = () => setVisible(onScreen && !document.hidden);
    const observer = new IntersectionObserver(
      ([entry]) => {
        onScreen = entry.isIntersecting;
        update();
      },
      { threshold: 0.3 },
    );
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => setReduced(motion.matches);
    readMotion();
    motion.addEventListener('change', readMotion);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
      motion.removeEventListener('change', readMotion);
    };
  }, []);

  const take = useCallback(() => {
    setTaken(true);
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setTaken(false), idle);
  }, [idle]);
  useEffect(() => () => window.clearTimeout(timer.current), []);

  return { ref, take, playing: visible && !reduced && !taken };
}
```