Skip to content

Button

Triggers an action, with variants for emphasis, a danger tone for destructive actions, three sizes plus icon-only, and a pending state.

Preview

Installation

pnpm add @syntara/react @syntara/tokens
import { Button } from '@syntara/react';

Usage

import { Button } from '@syntara/react';

export function SaveButton() {
  return <Button onPress={() => save()}>Save changes</Button>;
}

Examples

Variants

Every variant, including the monochrome contrast action.

Tone

tone="danger" on primary, outline and ghost: one solid destructive action and two quieter ones. Disabled looks the same in every tone.

Contrast

The monochrome strong action: near-black in light schemes, near-white in dark, at the lg hero size.

Sizes

sm, md (the control height), lg (the hero size) and a square icon button, which requires an aria-label.

With icons

Icons in children are sized automatically; arrows flip in right-to-left layouts.

Pending

The spinner replaces the leading icon and the width never changes.

Accessibility

KeysAction
EnterorSpaceActivates the button.
TabMoves focus to the button, including while pending.
  • Icon-only buttons must have an aria-label; the type requires it for size="icon".
  • While pending the button stays focusable, is aria-disabled and aria-busy, and screen readers hear the label again when the state changes.
  • Focus ring: 2px focus.ring outline with a 2px offset, keyboard only.
  • Every size keeps a target of at least 24×24px, including the link variant.
  • tone="danger" is colour only. The label must say what is destroyed, e.g. "Delete account".
  • The danger label on outline and ghost buttons is at least 4.5:1 on every opaque surface and on the hover face, for every tenant and 1,000 generated brands (test/button.test.tsx).

Guidelines

Do

  • Use one primary button per view for the main action.
  • Use a verb for the label: "Save changes", not "OK".
  • Use isPending for async actions instead of disabling the button.
  • Use variant="contrast" for strong actions when the page's one hero action already carries the brand colour.
  • Use tone="danger" for actions that delete or can't be undone, and name the thing in the label: "Delete account".
  • Use the solid danger button (primary) for the final confirming step. Use outline or ghost for destructive actions that sit in a row, a toolbar or a settings list.
  • Inside dialogs, popovers and menus use primary or outline with tone="danger": both have an opaque face.

Don’t

  • Don't use tone="danger" for anything that isn't destructive.
  • Don't put a ghost button with tone="danger" directly on a glass overlay (dialog, popover, menu, sheet). Its face is transparent, and feedback colours aren't solved for contrast on glass.
  • Don't use variant="danger" in new code. It is deprecated and will be removed in 1.0.0.
  • Don't use the link variant for navigation; use Link, which renders an anchor.
  • Don't put a contrast and a primary button side by side: that's two loud actions. Pair either with outline or ghost.

API reference

Button

variant
  • primarydefault
  • secondary
  • outline
  • ghost
  • link
  • contrast
  • dangerdeprecated

Visual emphasis. Use one primary per view. contrast is the monochrome strong action (surface.inverse + text.inverse), for when brand colour should stay rare. For destructive actions set tone="danger". 'danger' is deprecated: it renders as before and logs one warning in development.

Deprecated: variant="danger", since 0.2.0. It keeps working until 1.0.0. Use tone="danger" instead. Status belongs in tone, as in every other component; variant is only the visual emphasis.

Migrate with npx @syntara/codemods button-variant-danger-to-tone <path>. The decision is in RFC-001.

tone
  • neutraldefault
  • danger

Feedback colour. danger marks a destructive action. It works with the primary (solid), outline and ghost variants and sets data-tone="danger". secondary, link and contrast ignore it: they render as neutral, with one warning in development.

size
  • sm
  • mddefault
  • lg
  • icon

md is the density's control height; sm is 8px shorter, lg 16px taller (the hero size). Height and corner radius match a field (TextField, Select, SearchField, Combobox) of the same size: the corner is radius.field plus half a space step, scaled 0.8 for sm and 1.25 for lg (round brands stay pills). Inline padding is ~1.25 × half the height. icon is a square button of the size's height and requires aria-label.

isPending
boolean

Shows a spinner, sets aria-busy and aria-disabled, keeps focus and ignores presses. Width stays the same.

Default false

isDisabled
boolean

Disables the button.

Default false

onPress
(e: PressEvent) => void

Called on mouse, touch, Enter or Space.

type
  • buttondefault
  • submit
  • reset

Native button type for forms.

aria-label
string

Accessible name. Required when size="icon".

childrenRequired
ReactNode | ((renderProps) => ReactNode)

Label, optionally with Tabler icons (mark them aria-hidden).

className
string | ((renderProps) => string)

Extra classes on the button, e.g. for full width.

Tokens

The semantic tokens this component reads, grouped by what they control. Swatches show this site’s theme; change a tenant’s brand and the component follows with no code change.

Colour24
action.primary.bgaction.primary.borderaction.primary.fgaction.primary.hoveraction.primary.pressedaction.secondary.bgaction.secondary.fgaction.secondary.hoveraction.secondary.pressedborder.defaultborder.strongfeedback.danger.bgfeedback.danger.borderfeedback.danger.fgfeedback.danger.onSolidfeedback.danger.solidfocus.ringsurface.defaultsurface.inversetext.brandtext.defaulttext.disabledtext.inversetext.subtle
Type6
font.size.mdfont.size.smfont.tracking.mdfont.tracking.smfont.weight.mediumline-height.tight
Space and size6
control-heighticon-strokespace.1space.2space.4space.6
Shape3
radius.buttonradius.fieldradius.pill
Depth2
shadow.highlightshadow.raised
Motion5
motion.duration.fastmotion.duration.normalmotion.duration.springmotion.easingmotion.spring