Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Button

New

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

Live · interactive

The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.

Fig. 1, taken apart

The same component in 3D, its layers lifted apart. Hover or focus it to take it apart, pick a layer to read its dimensions, and drag to turn it.

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.

You can add this component with the shadcn CLI or by hand:

Into components/button of any React project, with no setup:

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

Either way it needs React 19.2 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: 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

deploy-button.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>  );}

Each state below is a live instance, locked so you can study it.

The pieces it is built from, each with its measurements read live.

Below are variations of this component, each live, with its code.

Sizes

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

Hierarchies

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

With icons

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

Destructive

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

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.

Shortcut

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

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.

  • 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.
PropTypeDefaultDescription
size'sm' | 'md' | 'lg' | 'xl''md'Button: 36, 40, 44 or 48 px tall.
hierarchy'primary' | 'secondary' | 'tertiary' | 'link-color' | 'link-gray''primary'Button: how loud it is, from a filled primary to a bare link.
colorButtonHierarchy | 'primary-destructive' | 'secondary-destructive' | 'tertiary-destructive' | 'link-destructive'—Button: the hierarchy and its destructive version in one word; wins over hierarchy.
destructivebooleanfalseButton: red, for actions that delete or can’t be undone.
iconLeadingReactNode | 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).
iconTrailingReactNode | ComponentType—Button: an icon after the words.
isLoadingbooleanfalseButton: shows a spinner, sets aria-busy and ignores clicks, keeping its width and name.
showTextWhileLoadingbooleanfalseButton: while loading, keeps the words visible with the spinner before them.
isDisabledbooleanfalseButton: disables it (natively for a <button>; a link loses its href). disabled works too.
shortcutstring—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.
hrefstring—Button: makes it an <a>.
ref, …native propsRef<HTMLButtonElement | HTMLAnchorElement>—Button: passed to the element, like type, onClick, aria-label or form.
autoplaybooleantrueButtonShowcase (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.
classNamestring—Both: extra class for the root.

The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

button.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
button.module.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;
icons.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}
showcase.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.
showcase.module.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);
use-autoplay.ts
'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;
  • 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.