Components

Button

An action, pressed: one that can stay in reach while unavailable and say why, show its work, or toggle.

Markdown

A button does something when pressed: submits a form, opens a dialog, starts a task. A button that toggles is on or off, and says which.

A toolbar and a form

Bold, Italic and Underline are toggles: each says whether it is on. Save is unavailable until the title changes, but stays in reach and says why — Tab to it, or clear the title. Save, and it works for a moment: presses are ignored, and focus stays on it.

Code
"use client";

import { Button } from "@publictheta/ui/components/button/button.tsx";
import { useEffect, useRef, useState } from "react";

const MARKS = [
  { name: "Bold", d: "M5 3h4a2.5 2.5 0 0 1 0 5H5zm0 5h4.5a2.5 2.5 0 0 1 0 5H5z" },
  { name: "Italic", d: "M7 3h5M4 13h5M9.5 3l-3 10" },
  { name: "Underline", d: "M4.5 3v4.5a3.5 3.5 0 0 0 7 0V3M3.5 14h9" },
] as const;

export default function ButtonEditor() {
  const [saved, setSaved] = useState("Quarterly notes");
  const [draft, setDraft] = useState(saved);
  const [saving, setSaving] = useState(false);
  const [marks, setMarks] = useState<ReadonlySet<string>>(new Set(["Bold"]));
  const timer = useRef<number>(undefined);
  useEffect(() => () => window.clearTimeout(timer.current), []);

  const changed = draft.trim() !== saved;
  const empty = draft.trim() === "";

  return (
    <form
      className="flex w-full max-w-sm flex-col gap-4"
      onSubmit={(event) => {
        event.preventDefault();
        setSaving(true);
        // As a request to a server would take.
        timer.current = window.setTimeout(() => {
          setSaved(draft.trim());
          setSaving(false);
        }, 1500);
      }}
    >
      <div role="toolbar" aria-label="Text style" className="flex gap-1">
        {MARKS.map(({ name, d }) => (
          <Button
            key={name}
            size="icon"
            variant="ghost"
            aria-label={name}
            pressed={marks.has(name)}
            onPressedChange={(on) =>
              setMarks((current) => {
                const next = new Set(current);
                if (on) next.add(name);
                else next.delete(name);
                return next;
              })
            }
          >
            <svg
              aria-hidden
              viewBox="0 0 16 16"
              className="size-4"
              fill="none"
              stroke="currentColor"
              strokeWidth="1.5"
              strokeLinecap="round"
              strokeLinejoin="round"
            >
              <path d={d} />
            </svg>
          </Button>
        ))}
      </div>
      <label className="flex flex-col gap-1.5 text-sm font-medium text-fg-strong">
        Title
        <input
          value={draft}
          readOnly={saving}
          onChange={(event) => setDraft(event.target.value)}
          className="h-9 rounded-md border border-border bg-surface px-2.5 text-sm font-normal outline-none focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-focus-ring pointer-coarse:h-11 pointer-coarse:text-base"
          style={{
            fontWeight: marks.has("Bold") ? 600 : undefined,
            fontStyle: marks.has("Italic") ? "italic" : undefined,
            textDecoration: marks.has("Underline") ? "underline" : undefined,
          }}
        />
      </label>
      <div className="flex justify-end gap-2">
        <Button variant="ghost" disabled={!changed || saving} onClick={() => setDraft(saved)}>
          Revert
        </Button>
        <Button
          type="submit"
          variant="primary"
          pending={saving}
          disabled={!changed || empty}
          disabledReason={empty ? "A title cannot be empty." : "Nothing has changed since it was saved."}
        >
          Save
        </Button>
      </div>
    </form>
  );
}

When to use

Use a button for an action. To go somewhere, use a link; to choose one of a few values, use a radio group; for a setting that is on or off as part of a form, a checkbox.

Why it is a component

A button's looks could be a class on the platform's own <button>, and for a long time that is all this library had. What a class cannot give is behaviour a plain button lacks, and every interface needs:

  • Disabled, but in reach. A disabled button leaves the Tab order and cannot say why. One that is merely unavailable for now — Save before anything has changed — can stay reachable, be announced as unavailable, and carry its reason.
  • Pending. A button whose work is under way ignores further presses, so the work is not started twice, while keeping focus and its name. Disabling it instead would throw focus to the page.
  • On and off. A toggle states whether it is pressed, and turns on and off with each press, held by its owner or by itself.
  • A name. A button that shows only a mark is reported while developing when it has no name.

These hold for every button, which is why they live in one place.

Best practices

Say what it does. A verb, or a verb and its object: Save, Remove file. A button that opens a dialog ends with an ellipsis, Remove…, because it asks before it acts.

One primary button per task. The button a task ends with is primary; another that destroys something is a danger button, and is never the default.

Keep in reach what will become available. A button that is unavailable for a reason the person can change — fill in a field, make a change — stays focusable and says the reason. One that does not apply at all is disabled, or not shown.

Show work in progress on the button that started it. It keeps its width and its label for assistive technology, shows that it is busy, and ignores presses until the work is done.

Name a button that shows only a mark. In words, in the language of the interface.

Platform considerations

Keyboard. Enter and Space press it. A button that is unavailable or busy hears them and does nothing. WebKit on macOS leaves buttons out of the Tab order unless the person has asked for every control; that is the person's setting, and a button keeps it.

Pointer. It shows the pointer over it, and the press while it is held. A button that cannot be pressed shows neither.

Touch. A finger cannot hover, so a tap leaves no hover behind: the button is drawn as at rest once the finger lifts. Every size can be hit 44 CSS pixels across; a small one by a target that reaches past its drawn edge.

Forced colours. A system that forces its own colours draws a disabled button in its colour for what is unavailable, and a toggle that is on in its highlight. A button that is only marked unavailable is drawn the same way, since the system does not know of it.

Assistive technology. It is a button, named by its label; a toggle states whether it is pressed; one in reach but unavailable is announced as unavailable, with its reason as its description; one at work is busy.

Behaviour

Semantics

Is a button named by its label
const screen = await render(<Button>Save</Button>);
await expect.element(button(screen)).toBeVisible();
expect(element(screen).tagName).toBe("BUTTON");
Does not submit a form unless it is asked to
const onSubmit = vi.fn((event: SubmitEvent) => event.preventDefault());
const screen = await render(
  <form onSubmit={(event) => onSubmit(event.nativeEvent as SubmitEvent)}>
    <Button>Preview</Button>
    <Button type="submit">Save</Button>
  </form>,
);
await userEvent.click(button(screen, "Preview"));
expect(onSubmit).not.toHaveBeenCalled();
await userEvent.click(button(screen));
expect(onSubmit).toHaveBeenCalledOnce();
Has no accessibility violations in any variant, size or state
// Still, so that the settled interface is the one looked at: a pending button turns for as long as it waits.
await commands.reduceMotion(true);
const screen = await render(
  <div className="flex flex-wrap gap-2 p-2">
    <Button>Secondary</Button>
    <Button variant="primary">Primary</Button>
    <Button variant="danger">Delete</Button>
    <Button variant="ghost">Ghost</Button>
    <Button size="small">Small</Button>
    <Button size="large">Large</Button>
    <Button size="icon" aria-label="Remove">
      {icon}
    </Button>
    <Button defaultPressed>Bold</Button>
    <Button disabled>Disabled</Button>
    <Button disabled disabledReason="Nothing has changed">
      Reachable
    </Button>
    <Button pending>Pending</Button>
  </div>,
);
try {
  await expectAccessible(screen.container);
} finally {
  await commands.reduceMotion(false);
}

Toggle

A button that toggles states whether it is on, and a press turns it on and off
const screen = await render(<Button defaultPressed={false}>Bold</Button>);
await expect.element(button(screen, "Bold")).toHaveAttribute("aria-pressed", "false");
await userEvent.click(button(screen, "Bold"));
await expect.element(button(screen, "Bold")).toHaveAttribute("aria-pressed", "true");
await userEvent.click(button(screen, "Bold"));
await expect.element(button(screen, "Bold")).toHaveAttribute("aria-pressed", "false");
One that does not toggle says nothing of being on
const screen = await render(<Button>Save</Button>);
await userEvent.click(button(screen));
expect(element(screen).hasAttribute("aria-pressed")).toBe(false);
One that is on looks it, in any variant, also where the system forces its colours
const screen = await render(
  <>
    <Button defaultPressed>Bold</Button>
    <Button defaultPressed={false}>Italic</Button>
    <Button variant="ghost" defaultPressed>
      Underline
    </Button>
    <Button variant="ghost" defaultPressed={false}>
      Strike
    </Button>
  </>,
);
expect(background(screen, "Bold")).not.toBe(background(screen, "Italic"));
expect(background(screen, "Underline")).not.toBe(background(screen, "Strike"));
// Under the pointer too, where the pointer can be.
if (!hasTouch) await userEvent.hover(button(screen, "Bold"));
await commands.forceColors(true);
try {
  expect(background(screen, "Bold")).toBe(resolve("Highlight"));
  // Its label in the highlight's own text colour, with no plate of the system's behind it.
  expect(getComputedStyle(element(screen, "Bold")).color).toBe(resolve("HighlightText"));
  // WebKit has no such plate, and no way to ask for none.
  if (CSS.supports("forced-color-adjust", "none")) {
    expect(getComputedStyle(element(screen, "Bold")).forcedColorAdjust).toBe("none");
  }
} finally {
  await commands.forceColors(false);
}

Disabled

Disabled, it cannot be pressed or focused
const onClick = vi.fn();
const screen = await render(
  <Button disabled onClick={onClick}>
    Save
  </Button>,
);
await expect.element(button(screen)).toBeDisabled();
await userEvent.click(button(screen), { force: true });
element(screen).focus();
expect(onClick).not.toHaveBeenCalled();
await expect.element(button(screen)).not.toHaveFocus();
Kept in reach, it can be focused and is announced as disabled, but does nothing when pressed
const onClick = vi.fn();
const screen = await render(
  <Button disabled focusableWhenDisabled onClick={onClick}>
    Save
  </Button>,
);
await expect.element(button(screen)).toHaveAttribute("aria-disabled", "true");
// Forced: the driver, like assistive technology, takes `aria-disabled` for unavailable and would not press.
await userEvent.click(button(screen), { force: true });
element(screen).focus();
await expect.element(button(screen)).toHaveFocus();
await userEvent.keyboard("{Enter}");
await userEvent.keyboard(" ");
expect(onClick).not.toHaveBeenCalled();
Kept in reach, it does not submit its form, by a press or by Return in a field
const onSubmit = vi.fn((event: SubmitEvent) => event.preventDefault());
const screen = await render(
  <form onSubmit={(event) => onSubmit(event.nativeEvent as SubmitEvent)}>
    <input aria-label="Title" />
    <Button type="submit" disabled focusableWhenDisabled>
      Save
    </Button>
  </form>,
);
await userEvent.click(button(screen), { force: true });
await userEvent.click(screen.getByRole("textbox", { name: "Title" }));
await userEvent.keyboard("{Enter}");
expect(onSubmit).not.toHaveBeenCalled();
Given a reason, it stays in reach and the reason is its description
const screen = await render(
  <Button disabled disabledReason="Nothing has changed yet">
    Save
  </Button>,
);
await expect.element(button(screen)).toHaveAccessibleDescription("Nothing has changed yet");
element(screen).focus();
await expect.element(button(screen)).toHaveFocus();
A reason is not said once it is no longer disabled
const screen = await render(
  <Button disabled={false} disabledReason="Nothing has changed yet">
    Save
  </Button>,
);
await expect.element(button(screen)).toHaveAccessibleDescription("");
await expect.element(button(screen)).not.toHaveAttribute("aria-disabled");
Kept in reach, becoming disabled leaves focus where it was
function Form() {
  const [saved, setSaved] = useState(false);
  return (
    <Button disabled={saved} focusableWhenDisabled onClick={() => setSaved(true)}>
      Save
    </Button>
  );
}
const screen = await render(
  <>
    <input aria-label="Before" />
    <Form />
  </>,
);
// By the keyboard: a press does not focus a button in WebKit on macOS.
await userEvent.click(screen.getByRole("textbox", { name: "Before" }));
element(screen).focus();
await userEvent.keyboard("{Enter}");
await expect.element(button(screen)).toHaveAttribute("aria-disabled", "true");
await expect.element(button(screen)).toHaveFocus();
Kept in reach, it does not answer the pointer as one that can be pressedChromium, Firefox, WebKit

Not yet written.

Unavailable, it looks unavailable whatever its variant
const screen = await render(
  <>
    <Button variant="primary">Save</Button>
    <Button variant="primary" disabled focusableWhenDisabled>
      Publish
    </Button>
    <Button variant="danger" disabled>
      Remove
    </Button>
  </>,
);
expect(background(screen, "Publish")).not.toBe(background(screen));
expect(background(screen, "Remove")).toBe(background(screen, "Publish"));
expect(getComputedStyle(element(screen, "Publish")).color).toBe(resolve("var(--ui-fg-disabled)"));
Kept in reach, it is drawn in the system's colour for what is unavailable where colours are forced
const screen = await render(
  <Button disabled focusableWhenDisabled>
    Save
  </Button>,
);
await commands.forceColors(true);
try {
  expect(getComputedStyle(element(screen)).color).toBe(resolve("GrayText"));
} finally {
  await commands.forceColors(false);
}

Pending

Pending, it says it is busy and ignores presses, keeping its name
const onClick = vi.fn();
const screen = await render(
  <Button pending onClick={onClick}>
    Save
  </Button>,
);
await expect.element(button(screen)).toHaveAttribute("aria-busy", "true");
await userEvent.click(button(screen));
await userEvent.keyboard("{Enter}");
expect(onClick).not.toHaveBeenCalled();
await expect.element(button(screen)).toHaveAccessibleName("Save");
Becoming pending leaves focus on it, and its width as it was
function Upload() {
  const [pending, setPending] = useState(false);
  return (
    <Button pending={pending} onClick={() => setPending(true)}>
      Save
    </Button>
  );
}
const screen = await render(
  <>
    <input aria-label="Before" />
    <Upload />
  </>,
);
const width = element(screen).getBoundingClientRect().width;
await userEvent.click(screen.getByRole("textbox", { name: "Before" }));
element(screen).focus();
await userEvent.keyboard("{Enter}");
await expect.element(button(screen)).toHaveAttribute("aria-busy", "true");
await expect.element(button(screen)).toHaveFocus();
expect(element(screen).getBoundingClientRect().width).toBe(width);
Pending, it does not submit its form
const onSubmit = vi.fn((event: SubmitEvent) => event.preventDefault());
const screen = await render(
  <form onSubmit={(event) => onSubmit(event.nativeEvent as SubmitEvent)}>
    <Button type="submit" pending>
      Save
    </Button>
  </form>,
);
await userEvent.click(button(screen));
expect(onSubmit).not.toHaveBeenCalled();

Pointer

Under a mouse it shows the pointer over it, and the press while it is heldChromium, Firefox, WebKit

Not yet written.

Under a finger a tap leaves no hover behind: it is drawn as at restChromium · touch, WebKit · touch

Not yet written.

Under a finger every size can be hit 44 pixels across, a small one beyond its edgeChromium · touch, WebKit · touch

Not yet written.

Naming

One that shows only a mark and has no name is reported while developing
const errors = vi.spyOn(console, "error").mockImplementation(() => {});
try {
  await render(<Button size="icon">{icon}</Button>);
  expect(errors).toHaveBeenCalledWith(expect.stringContaining("needs a name"));
  errors.mockClear();
  await render(
    <Button size="icon" aria-label="Remove">
      {icon}
    </Button>,
  );
  await render(<Button>Save</Button>);
  expect(errors).not.toHaveBeenCalled();
} finally {
  errors.mockRestore();
}

API

variant and size are stated as data-variant and data-size, which its styles read
const screen = await render(
  <>
    <Button variant="danger" size="small">
      Delete
    </Button>
    <Button>Save</Button>
  </>,
);
await expect.element(button(screen, "Delete")).toHaveAttribute("data-variant", "danger");
await expect.element(button(screen, "Delete")).toHaveAttribute("data-size", "small");
expect(background(screen, "Delete")).not.toBe(background(screen));
onPressedChange hears each change, and a controlled pressed changes only when its owner changes it
const onPressedChange = vi.fn();
function Controlled() {
  const [pressed, setPressed] = useState(false);
  return (
    <>
      <Button pressed={pressed} onPressedChange={onPressedChange}>
        Fixed
      </Button>
      <Button pressed={pressed} onPressedChange={setPressed}>
        Bold
      </Button>
    </>
  );
}
const screen = await render(<Controlled />);
await userEvent.click(button(screen, "Fixed"));
expect(onPressedChange).toHaveBeenCalledWith(true);
await expect.element(button(screen, "Fixed")).toHaveAttribute("aria-pressed", "false");
await userEvent.click(button(screen, "Bold"));
await expect.element(button(screen, "Bold")).toHaveAttribute("aria-pressed", "true");
An onClick that prevents the default keeps a toggle as it was
const screen = await render(
  <Button defaultPressed={false} onClick={(event) => event.preventDefault()}>
    Bold
  </Button>,
);
await userEvent.click(button(screen, "Bold"));
await expect.element(button(screen, "Bold")).toHaveAttribute("aria-pressed", "false");

React

Install

npx shadcn@latest add https://ui.publictheta.com/r/button.json
Source

src/components/button/button.tsx

"use client";

import { useEffect, useId, useRef } from "react";
import type { ComponentProps, MouseEvent, ReactElement, ReactNode } from "react";
import { buttonClassName } from "../../lib/button-class.ts";
import { mergeRefs } from "../../lib/merge-refs.ts";
import { cn } from "../../lib/utils.ts";
import { useControllableState } from "../../primitives/use-controllable-state.ts";

export type ButtonVariant = "secondary" | "primary" | "danger" | "ghost";
export type ButtonSize = "small" | "medium" | "large" | "icon";

export type ButtonProps = Omit<ComponentProps<"button">, "aria-pressed"> & {
  /** What the button does, as its look: `primary` for the one a task ends with, `danger` for one that destroys. */
  variant?: ButtonVariant | undefined;
  /** `icon` is square, for a button that shows only a mark; it must still be named. */
  size?: ButtonSize | undefined;
  /**
   * Unavailable, but still reached by Tab and announced, rather than skipped:
   * for a button whose absence would puzzle, as a form's Save before it can be.
   */
  focusableWhenDisabled?: boolean | undefined;
  /** Why it is disabled, told to assistive technology with the button; it keeps the button focusable. */
  disabledReason?: string | undefined;
  /** At work on what it started: presses are ignored, focus and the label stay. */
  pending?: boolean | undefined;
  /** On or off, for a button that toggles. Giving either makes it one. */
  pressed?: boolean | undefined;
  defaultPressed?: boolean | undefined;
  onPressedChange?: ((pressed: boolean) => void) | undefined;
};

/**
 * A button: the platform's own, so it submits forms, answers Enter and Space
 * and is announced as what it is. What it adds is behaviour a bare button lacks
 * — a disabled state that can stay in reach and say why, a pending state that
 * keeps focus, a toggle that states whether it is on — and a look read from its
 * attributes.
 */
export function Button({
  variant,
  size,
  disabled = false,
  focusableWhenDisabled = false,
  disabledReason,
  pending = false,
  pressed: pressedProp,
  defaultPressed,
  onPressedChange,
  type = "button",
  className,
  onClick,
  children,
  ref,
  ...props
}: ButtonProps): ReactElement {
  const own = useRef<HTMLButtonElement>(null);
  const reasonId = useId();
  const toggles = pressedProp !== undefined || defaultPressed !== undefined;
  const [pressed, setPressed] = useControllableState(pressedProp, defaultPressed ?? false, onPressedChange);
  // Kept in the Tab order, a disabled button must be told disabled in words: the attribute that would say so also removes it.
  const reachable = disabled && (focusableWhenDisabled || disabledReason !== undefined);
  const reason = disabled ? disabledReason : undefined;

  const named = props["aria-label"] !== undefined || props["aria-labelledby"] !== undefined;
  useEffect(() => {
    if (process.env.NODE_ENV === "production" || named) return;
    const element = own.current;
    if (element === null || element.textContent.trim() !== "" || element.title !== "") return;
    console.error(
      "Button: a button that shows only a mark needs a name — give it `aria-label` or `aria-labelledby`; without one, assistive technology announces only that it is a button.",
    );
  });

  const describedBy = [props["aria-describedby"], reason === undefined ? undefined : reasonId]
    .filter(Boolean)
    .join(" ");

  return (
    <>
      <button
        type={type}
        {...props}
        ref={mergeRefs(own, ref)}
        disabled={disabled && !reachable}
        aria-disabled={reachable || undefined}
        aria-busy={pending || undefined}
        aria-pressed={toggles ? pressed : undefined}
        aria-describedby={describedBy === "" ? undefined : describedBy}
        title={props.title ?? reason}
        data-variant={variant}
        data-size={size}
        data-disabled={disabled ? "" : undefined}
        data-pending={pending ? "" : undefined}
        className={cn(buttonClassName, "group/button", className)}
        onClick={(event: MouseEvent<HTMLButtonElement>) => {
          // Neither the owner's handler nor a form hears a press that cannot be acted on.
          if (reachable || pending) {
            event.preventDefault();
            return;
          }
          onClick?.(event);
          if (!event.defaultPrevented && toggles) setPressed(!pressed);
        }}
      >
        {/* Faded rather than hidden, so that it still names the button while the work goes on. */}
        <span className="inline-flex items-center gap-[inherit] group-data-pending/button:opacity-0">
          {children}
        </span>
        {pending && <Spinner />}
      </button>
      {reason !== undefined && (
        <span id={reasonId} hidden>
          {reason}
        </span>
      )}
    </>
  );
}

function Spinner(): ReactNode {
  return (
    <svg
      aria-hidden
      viewBox="0 0 16 16"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      className="absolute inset-0 m-auto size-4 animate-spin motion-reduce:animate-none"
    >
      <path d="M8 2a6 6 0 1 0 6 6" />
    </svg>
  );
}