BridgeHandbookDraft
For agents
Menu
ExperimentalatomActions

Button

Starts an action. Six variants, four sizes; one primary per group.

import { … } from "@brdgapp/ui/components/button"

Usage

Button starts an action: save, send, sync, delete. It is Bridge's one button — every button-shaped control should be this component, not a hand-made <button> with pill classes. Its look comes only from variant and size. The usual mistake is restyling it with className (a font size, a color, a hover effect); className is for layout only.

When to use

  • primary for the main action of a group, such as Save or Make intro.
  • secondary for a neutral action beside it, such as Settings, Copy link or Edit.
  • ghost for a quiet, tertiary action, such as Cancel or Decline.
  • link for an inline text action, such as Load more, Retry or View all.
  • destructive for a confirmed dangerous action, such as Delete or Stop sharing.
  • danger-ghost for a dangerous action inside a menu or list, such as Sign out or Remove.
  • With render for a button that navigates, so it stays a real link.

When not to use

  • For an icon with no visible label (close, kebab, back, copy, sync). Use IconButton instead.
  • For two or more joined actions in one pill. Use ButtonGroup instead.
  • For a filter that toggles on and off in a row of filters. Use FilterChip instead.
  • For an on/off setting that applies immediately. Use Switch instead.
  • For picking one of a few modes of a view, such as a date range. Use SegmentedControl instead.
  • To show a status or category. Labels are not controls. Use Tag instead.

One of each

Bridge has one button, one label, one menu, one tooltip, one confirm dialog and one table. Before building a control, look for it here. Never hand-make a second version (a <button> with pill classes, a <span> chip, a new kebab), even if it looks the same today: copies drift.

The look comes from props

A component's appearance comes only from its own options (variant, size, color). className on a package component is for layout (mt-4, shrink-0, w-full), never for font size, color, padding, radius or hover. If the look needs a className, the component or the chosen option is wrong: fix that instead.

Fix the system first

If the system can't express something a screen genuinely needs, extend the component (a new size, a new option) and document it in the same change, then use it. Never work around a gap at the call site. A 1–2px change from the old design is the expected cost of joining the system, not a reason to stay hand-made.

Fill with -solid

Anything with white text on a colored fill uses --primary-solid or --danger-solid. The brand --primary and --error are for text, icons and focus rings; as fills they fail contrast (white on --error is 4.41:1).

One blue button per group

In each group of actions, only the single most important one is a primary (filled blue) button. The others are secondary, ghost or link. When everything is blue, nothing stands out.

Reserve the space

Nothing that arrives later may push content down. A skeleton has the same height and shape as what it stands for, measured, not guessed. A visible jump after the first paint is a bug.

Sentence case

Buttons, titles, headings, tabs, labels and statuses use sentence case: only the first word and names get a capital. "Make intro", "Intro forwarded", "Stop sharing".

Say what happens

Buttons start with a verb and name the object: "Stop sharing", not "OK" or "Submit". A confirm dialog asks a question about the specific thing and its button repeats the verb. Errors say what happened and what to do next.

Anatomy

  1. Container. A pill (rounded-full) with a fixed height per size. The link variant has no pill, height or padding.

  2. Leading icon. Optional, from the icon prop. Sized by the button (14px at xs, 16px at sm/md, 18px at lg) and hidden from screen readers.

  3. Label. SemiBold with 0.4px letter spacing. Its size comes from size: 12, 14, 14 or 16px.

  4. Spinner. Replaces the icon while loading.

  5. Focus ring. A 2px ring with a 2px offset, shown only for keyboard focus. Red for the danger variants, blue for the rest.

Examples

Variants

Six looks, from the main action to a danger link.

Sizes

xs is the floor; sm is the default.

With an icon

Pass a bare Lucide icon; the button sizes it.

Loading and not ready

loading for an action in progress; ButtonSkeleton before the data arrives.

Full width

States

RestHoverFocusPressedDisabledLoading
Primary
Secondary
Ghost
Destructive
Danger ghost
Link
StateWhat happens
RestThe variant's fill. Bordered variants sit on a solid raised surface, never transparent.
HoverFills darken (--primary-solid-hover, --danger-solid-hover); secondary gets a darker border; ghost gets a light fill; link underlines.
Focus visibleThe focus ring. Mouse focus draws nothing.
PressedPills scale to 98% while held. Primary deepens to --primary-active.
Disabled30% opacity and no pointer events.
LoadingSpinner in place of the icon, disabled, and aria-busy set, so it can't be pressed twice.
Not readyBefore the data a button needs has loaded, show ButtonSkeleton at the same size, so nothing shifts when the real button arrives.

Behavior

  • Renders a native <button> through Base UI, so Enter and Space press it and a disabled button leaves the tab order.
  • Base UI gives native buttons type="button", so a Button inside a form never submits it by accident. Pass type="submit" on the one that should.
  • render={<Link href="…" />} draws a link with the button's look. It stays a link: screen readers announce it as one and ⌘-click opens a new tab. In this mode the link owns its content, so icon and loading don't apply.
  • An icon without its own size class is sized for you. An icon with an explicit size-* class keeps it.
  • fullWidth stretches every variant except link, which always reads as text.

Do and don't

Do

Choose the look with variant and size only.

Don't

Pass className="text-[13px]" or a color or hover class. If the look needs a className, the variant or size is wrong.

Do

When moving an old control over, snap it to the nearest size: 13px becomes sm, 10px becomes xs.

Don't

Keep the old pixel value. A 1–2px change is the expected cost of joining the system.

Do

Drop one-off flourishes when migrating. A text arrow (→) is fine.

Don't

Graft on an animated arrow, a custom transition or a one-off accent color.

Do

If the system truly can't express a button, extend button.tsx and document it here in the same change.

Don't

Work around the gap with a className at the call site.

Content

  • Start with a verb and name the object: Make intro, Save changes, Stop sharing. Never OK, Yes, Submit or Click here.
  • Primary buttons: one to three words, sentence case.
  • A destructive button names exactly what goes: "Stop sharing", not "Delete".
  • A confirm button repeats the verb of its dialog's title.
  • Cancel is always "Cancel".

Accessibility

  • Fills use the AA-checked tokens --primary-solid and --danger-solid, dark enough for white text in both themes. The brand --primary and --error fail as fills (#E13535 with white text is 4.0:1), so never fill a button with them.
  • danger-ghost text uses --danger-text, readable on both themes.
  • xs (28px tall) is the smallest size; it clears the 24px minimum target.
  • The leading icon is hidden from screen readers; the label is the button's name.
  • An icon-only action needs a name: use IconButton, which requires aria-label.
  • link and danger-ghost don't rely on color alone: the label says what happens.
KeysAction
TabMoves focus to the button. Disabled buttons are skipped unless `focusableWhenDisabled` is set.
EnterPresses the button.
SpacePresses the button.

API reference

Button

The button, built on Base UI Button. Extends Base UI Button (native <button> attributes).

PropTypeDefaultDescription
variant"primary" | "secondary" | "ghost" | "link" | "destructive" | "danger-ghost""primary"The look. See When to use.
size"xs" | "sm" | "md" | "lg""sm"Height 28, 32, 40 or 48px; label 12, 14, 14 or 16px. md matches a text field, so the two line up in a row.
iconReactNode—A leading icon. Pass a bare Lucide icon. Ignored while loading and with render.
loadingbooleanfalseShows a spinner, disables the button and sets aria-busy.
fullWidthbooleanfalseStretches to the container width (not for link).
renderReactElement | (props, state) => ReactElement—Draw another element with the button's look, such as a Next Link. It keeps its own semantics.
disabledbooleanfalseDims to 30% and blocks interaction.
focusableWhenDisabledbooleanfalseKeeps a disabled button in the tab order, so a tooltip can explain why.
classNamestring—Layout only (shrink-0, mt-4, self-start). Merged last.

ButtonSkeleton

A placeholder pill for a button whose data hasn't loaded. Same height as the real button.

PropTypeDefaultDescription
size"xs" | "sm" | "md" | "lg""sm"Match the size of the button it stands in for.
widthnumber | string"7rem"Roughly the real button's width.
classNamestring—Layout only.

buttonVariants

The class recipe behind Button, for the rare element that must look like a button but can't be one.

PropTypeDefaultDescription

Lineage

Known gaps

Where the code still differs from the system. Follow the system, not the gap.

  • Bridge OS still has about 300 hand-made buttons to migrate (docs/BUTTON-AUDIT.md). Not yet swept: dashboard and marketing pages, admin, layout chrome, login, service-detail-modal, app-detail-content.
  • Bridge OS's own Button uses asChild; this package uses render. Call sites change when Bridge OS moves to the package: <Button asChild><Link …/></Button> becomes <Button render={<Link …/>} />.
  • In Bridge OS, cn is plain clsx, so a className can't reliably override a Button class there. The package's cn resolves clashes.

Sources

  • Bridge OS components.md → Button / ButtonSkeleton, migration contract, button-system family (2026-10-29).
  • Bridge OS src/components/ui/button.tsx (commit 5d9b382).
  • Base UI 1.9.0 useButton (default type="button"), checked in the installed source.