Skip to content
design
Components

Button

The action the crew takes, in four emphases and three sizes.

On this page
Anchor drag02:11
SV Althea has moved 42 m from the drop position in Baie de Morgat, outside the 35 m swing radius. Wind southwest 24 kn, gusting 31 kn. Start the engine, take the strain off the rode, and re-set the anchor or move to the mooring buoys off Camaret.
An anchor drag alarm and the actions the crew take on it, in every emphasis and size.
Code
import { AlertCard } from "@bosunkit/components/alert-card";
import { Badge } from "@bosunkit/components/badge";
import { Button } from "@bosunkit/components/button";
import { Anchor, BellOff, Check, History, MapPin, Play, Siren } from "lucide-react";
import { useId, useState, type Dispatch, type ReactElement, type SetStateAction } from "react";

export const description: string =
  "An anchor drag alarm card with its actions: every emphasis and size, icons before the words, a disabled action " +
  "with its reason, and live state as the crew acknowledge, start the engine and silence the alarm.";

export default function Showcase(): ReactElement {
  const [acknowledged, setAcknowledged]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(false);
  const [engineRunning, setEngineRunning]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(false);
  const [silenced, setSilenced]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(false);
  const windlassHintId: string = useId();

  return (
    <div style={{ width: "100%", maxWidth: 640 }}>
      <AlertCard
        priority="alarm"
        title="Anchor drag"
        time="02:11"
        acknowledged={acknowledged}
        immediate={!acknowledged}
        message={
          "SV Althea has moved 42 m from the drop position in Baie de Morgat, outside the 35 m swing radius. Wind " +
          "southwest 24 kn, gusting 31 kn. Start the engine, take the strain off the rode, and re-set the anchor or " +
          "move to the mooring buoys off Camaret."
        }
        badges={
          engineRunning || silenced ? (
            <>
              {engineRunning && <Badge variant="green">Engine running</Badge>}
              {silenced && <Badge variant="dim">Silenced until 02:19</Badge>}
            </>
          ) : undefined
        }
        actions={
          <div style={{ display: "flex", flexDirection: "column", gap: "var(--space-md)", flexBasis: "100%" }}>
            <div style={{ display: "flex", flexWrap: "wrap", gap: "var(--space-sm)" }}>
              <Button
                variant="primary"
                size="lg"
                disabled={acknowledged}
                onClick={(): void => {
                  setAcknowledged(true);
                }}
              >
                <Check size={16} aria-hidden="true" />
                Acknowledge
              </Button>
              <Button
                size="lg"
                onClick={(): void => {
                  setEngineRunning((running: boolean): boolean => !running);
                }}
              >
                <Play size={16} aria-hidden="true" />
                {engineRunning ? "Stop engine" : "Start engine"}
              </Button>
              <Button variant="danger" size="lg">
                <Siren size={16} aria-hidden="true" />
                Sound general alarm
              </Button>
            </div>

            <div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: "var(--space-sm)" }}>
              <Button>
                <MapPin size={14} aria-hidden="true" />
                Re-set drop position here
              </Button>
              <Button disabled aria-describedby={windlassHintId}>
                <Anchor size={14} aria-hidden="true" />
                Raise anchor
              </Button>
              <span id={windlassHintId} style={{ fontSize: 12, color: "var(--text-dim)", flexBasis: "100%" }}>
                Raise anchor is off: the windlass breaker on the chart table panel is open. Close it to use the
                windlass.
              </span>
            </div>

            <div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: "var(--space-sm)" }}>
              <Button size="sm">
                <History size={12} aria-hidden="true" />
                Show the last hour's track
              </Button>
              <Button
                size="sm"
                variant="ghost"
                disabled={silenced}
                onClick={(): void => {
                  setSilenced(true);
                }}
              >
                <BellOff size={12} aria-hidden="true" />
                Silence 5 min
              </Button>
            </div>
          </div>
        }
      />
    </div>
  );
}

A button carries the order the crew gives, so its label names what pressing it does, in the imperative. A finished state is a badge, not a button. Buttons use the shared label typography role in IBM Plex Sans. The presentation size class sets the text size; the button's size prop sets padding and its target size. A button is a native button element and takes every attribute of one.

01A button acts, a badge reportsA Button carries the order the crew gives; a Badge states what a thing is. Read that way, a label never looks tappable and an action never reads as a reading.
02One accent, one orderThe primary variant marks the single next action in a view. The rest stay default or ghost, so the accent keeps pointing at one thing.

Examples

Default

The default emphasis carries the ordinary actions.

The default button, for an ordinary action.
Code
import { Button } from "@bosunkit/components/button";
import type { ReactElement } from "react";

export const description: string =
  "The default button, for an ordinary action.";

export default function Default(): ReactElement {
  return <Button>Click</Button>;
}

Primary

primary marks the one action the crew is most likely to take next. Two of them in one view leave the eye without an order to follow, so a view has one.

The main action of a view; the accent points the crew to it.
Code
import { Button } from "@bosunkit/components/button";
import type { ReactElement } from "react";

export const description: string =
  "The main action of a view; the accent points the crew to it.";

export default function Primary(): ReactElement {
  return <Button variant="primary">Go</Button>;
}

Danger

danger marks an action that cannot be undone. Red is an alarm's color, so the button stays neutral, with the brighter label and border a default button shows on hover; its label names the action, and a confirmation carries the weight.

An action that cannot be undone, in the neutral colors with a brighter label and border: red belongs to alarms.
Code
import { Button } from "@bosunkit/components/button";
import type { ReactElement } from "react";

export const description: string =
  "An action that cannot be undone, in the neutral colors with a brighter label and border: red belongs to alarms.";

export default function Danger(): ReactElement {
  return <Button variant="danger">Delete</Button>;
}

Ghost

ghost is the way out of a panel (Cancel, Close, Dismiss). It keeps its border quiet so it does not compete with the action it declines.

A quiet button for the way out of a panel, such as Cancel.
Code
import { Button } from "@bosunkit/components/button";
import type { ReactElement } from "react";

export const description: string =
  "A quiet button for the way out of a panel, such as Cancel.";

export default function Ghost(): ReactElement {
  return <Button variant="ghost">Cancel</Button>;
}

Large

The large size sets its minimum height to --target-touch-size. Use it wherever the crew works with wet or gloved hands.

The large size, which meets the touch target size for wet or gloved hands.
Code
import { Button } from "@bosunkit/components/button";
import type { ReactElement } from "react";

export const description: string =
  "The large size, which meets the touch target size for wet or gloved hands.";

export default function Large(): ReactElement {
  return <Button size="lg">Big</Button>;
}

Small

The small size suits dense tables and the nav station, where a pointer is close at hand.

The small size, for dense tables and the nav station.
Code
import { Button } from "@bosunkit/components/button";
import type { ReactElement } from "react";

export const description: string =
  "The small size, for dense tables and the nav station.";

export default function Small(): ReactElement {
  return <Button size="sm">Small</Button>;
}

Disabled

A disabled button is dimmed and takes no tap. Pair it with the reason it is unavailable when the crew cannot see that for themselves.

A disabled button is dimmed and takes no tap.
Code
import { Button } from "@bosunkit/components/button";
import type { ReactElement } from "react";

export const description: string =
  "A disabled button is dimmed and takes no tap.";

export default function Disabled(): ReactElement {
  return <Button disabled>Nope</Button>;
}

Letter case

A label is drawn in capitals. Set textCase="none" where the case carries meaning: a chess move in SAN (Nf3 is a knight, NF3 is nothing), a call sign or a chart number. The label then keeps its letters as written.

The opening moves of a game in SAN, each a button that keeps its letter case, so Nf3 never reads as NF3.
Code
import { Button } from "@bosunkit/components/button";
import type { ReactElement } from "react";

export const description: string =
  "The opening moves of a game in SAN, each a button that keeps its letter case, so Nf3 never reads as NF3.";

const MOVES: readonly string[] = ["e4", "e5", "Nf3", "Nc6", "Bb5", "a6"];

export default function LetterCase(): ReactElement {
  return (
    <div role="group" aria-label="Moves" style={{ display: "flex", flexWrap: "wrap", gap: "var(--space-xs)" }}>
      {MOVES.map((move: string): ReactElement => (
        <Button key={move} variant="ghost" size="sm" textCase="none">
          {move}
        </Button>
      ))}
    </div>
  );
}

On a narrow screen

On a phone or a tablet, a screen up to the 68.75em breakpoint (1,100 px at 16 px), every size is at least --target-touch-size tall. The width is the screen's the button sits in, not the window's: a page, an app's main area or a phone frame declares itself the screen with container: bk-screen / inline-size, and outside one the button keeps its size. A touch screen takes the touch target at any width.

A 360 px screen: every size is at least the 48 px touch target, as on a phone or a tablet.
Code
import { Button } from "@bosunkit/components/button";
import type { ReactElement } from "react";

export const description: string =
  "A 360 px screen: every size is at least the 48 px touch target, as on a phone or a tablet.";

export default function OnANarrowScreen(): ReactElement {
  return (
    <div
      style={{
        container: "bk-screen / inline-size",
        width: "100%",
        maxWidth: 360,
        display: "flex",
        flexWrap: "wrap",
        gap: "var(--space-sm)",
      }}
    >
      <Button size="sm">Mute</Button>
      <Button>Snooze</Button>
      <Button variant="primary">Acknowledge</Button>
    </div>
  );
}

Motion

By default a button fades its new label in when the label changes, as Acknowledge turns to Acknowledged, and keeps its focus; it has no entrance. motion takes the kit's motion prop: true also scales the button in and out inside a MotionPresence, a preset (fade, rise, scale) enters and leaves that way, { enter, exit, change } names each one, and false turns it off. The button is named and takes a press from its first frame, so motion never delays an action. Under reduced motion nothing scales or rises, and the label fades as it does without it.

Press Acknowledge: by default the label fades in as it turns to Acknowledged; the button beside it, with motion={false}, changes at once. A Retry button with `motion` scales in and out as the download fails and succeeds.
Code
import { Button } from "@bosunkit/components/button";
import { MotionPresence } from "@bosunkit/components/motion";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";

export const description: string =
  "Press Acknowledge: by default the label fades in as it turns to Acknowledged; the button beside it, with " +
  "motion={false}, changes at once. A Retry button with `motion` scales in and out as the download fails and succeeds.";

export default function Motion(): ReactElement {
  const [acknowledged, setAcknowledged]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(false);
  const [failed, setFailed]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(true);
  function toggle(): void {
    setAcknowledged((current: boolean): boolean => !current);
  }
  return (
    <div style={{ display: "flex", flexDirection: "column", gap: "var(--space-md)" }}>
      <div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: "var(--space-sm)" }}>
        <Button variant="primary" onClick={toggle}>
          {acknowledged ? "Acknowledged" : "Acknowledge"}
        </Button>
        <Button variant="primary" motion={false} onClick={toggle}>
          {acknowledged ? "Acknowledged" : "Acknowledge"}
        </Button>
      </div>
      <div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: "var(--space-sm)", minHeight: 40 }}>
        <Button
          variant="ghost"
          onClick={(): void => {
            setFailed((current: boolean): boolean => !current);
          }}
        >
          {failed ? "Download succeeds" : "Download fails"}
        </Button>
        <MotionPresence>
          {failed && (
            <Button key="retry" motion>
              Retry forecast
            </Button>
          )}
        </MotionPresence>
      </div>
    </div>
  );
}

Props

PropTypeDefaultDescription
variantButtonVariant | undefined"default"The button's emphasis: primary for the main action, danger for a destructive one, ghost for a quiet one.
sizeButtonSize | undefined"default"The button's size; lg meets the touch target size. icon is a square touch target for a button whose only content is an icon; it needs an aria-label, and a Tooltip to show that name.
textCaseButtonTextCase | undefined"uppercase"How the label is cased: in capitals by default. none keeps the letters as written, for a label whose case carries meaning, such as a chess move in SAN (Nf3, exd5), a call sign or a chart number.
motionMotionProp | undefined–How the button moves. By default it fades its new label in when the label changes ("Acknowledge" to "Acknowledged"), keeping its focus, and has no entrance. true also scales it in as it mounts, wherever it is, and out when it leaves a MotionPresence; a preset (fade, rise, scale) or { enter, exit, change } chooses; false turns it off. It is named and pressable from the first frame.
refRef<HTMLButtonElement> | undefined–The button element.

Also takes every attribute of ButtonHTMLAttributes<HTMLButtonElement>.

Do
Name the order. A button says what pressing it does, in the imperative.
Don't
Watch started
Don't name the result. A finished state is a badge, and a button that reports one has nothing left to do.
Do
One primary action per view. The accent points at the next thing to do.
Don't
Don't give two actions the accent. Two primaries read as a choice the display has not made.
  • Split button: a primary action with related alternatives.

  • Badge: the state label that sits beside an action.

  • Select: the list a select trigger opens, drawn like a button.

  • Color: the accent and the alarm red the emphases draw on.

  • Accessibility: target sizes, contrast, and the keyboard path every action needs.