Skip to content

Action

Button

Stable

Triggers an action or event, available on web (React) and native (React Native). Supports four variants, six sizes, optional leading/trailing icons, inverse rendering for branded backgrounds, and disabled and loading states.

Variants

Accent

The single highest-emphasis action on a page — 'Get quotes', 'Book now'. Restricted to lg/xl sizes, defaults to xl.

<Button label="Get quotes" />

Primary

Important actions that aren't the page-level CTA — form submissions, confirmations.

<Button label="Continue" variant="primary" />

Secondary

Paired with a primary to offer an alternative — 'Cancel', 'Save draft'. Has a visible border.

<Button label="Save draft" variant="secondary" />

Tertiary

Low-emphasis actions — inline alternatives, toolbar actions. Transparent background.

<Button label="Learn more" variant="tertiary" />

Inverse

Inside an inverse <Surface> this happens automatically — Surface is the source of truth. Pass inverse explicitly only for a dark or branded background that isn’t a Surface. Toggle to dark theme to see the canvas context.

Accent inverse

<Surface tone="inverse" className="rounded-lg p-6">
<Button label="Get quotes" inverse />
</Surface>

Primary inverse

<Surface tone="inverse" className="rounded-lg p-6">
<Button label="Continue" variant="primary" inverse />
</Surface>

Secondary inverse

<Surface tone="inverse" className="rounded-lg p-6">
<Button label="Save draft" variant="secondary" inverse />
</Surface>

Tertiary inverse

<Surface tone="inverse" className="rounded-lg p-6">
<Button label="Learn more" variant="tertiary" inverse />
</Surface>

Sizes

Extra extra small

The most compact size — dense toolbars or tight inline groups. Not available for the accent variant.

<Button label="Get quotes" variant="primary" size="xxs" />

Extra small

Compact UI where even `sm` is too large. Not available for the accent variant.

<Button label="Get quotes" variant="primary" size="xs" />

Small

Dense layouts, tables, or inline with other compact elements.

<Button label="Get quotes" variant="primary" size="sm" />

Medium

The default size for most layouts.

<Button label="Get quotes" variant="primary" />

Large

Prominent form actions and larger touch targets. Smallest size available for the accent variant.

<Button label="Get quotes" variant="primary" size="lg" />

Extra large

Page-level CTAs. The default and largest size for the accent variant.

<Button label="Get quotes" variant="primary" size="xl" />

States

Disabled

Applied when the action is permanently unavailable in the current state.

<Button label="Continue" variant="primary" disabled />

Loading

Replaces the label with a spinner while an async operation is in progress.

<Button label="Submitting" variant="primary" loading />

Icons

Add an optional leadingIcon and/or trailingIcon to reinforce a button’s action. Icons are decorative — the label stays the accessible name — and they scale with the button size and inherit the label colour.

Leading icon

An icon before the label — e.g. a back arrow on a 'Back' action.

<Button label="Back" variant="primary" leadingIcon={MaterialArrowBack} />

Trailing icon

An icon after the label — e.g. a forward arrow on a 'Next' action.

<Button label="Next" variant="primary" trailingIcon={MaterialArrowForward} />

Both icons

Leading and trailing icons can be combined.

<Button label="Continue" variant="primary" leadingIcon={MaterialArrowBack} trailingIcon={MaterialArrowForward} />

Full width

Stretches to fill its container. Useful for mobile form submissions or stacked button groups.

<Button label="Get quotes" fullWidth />

href renders a real <a> wearing identical button styling. external opens a new tab and appends a spoken new-tab warning to the accessible name.

<Button label="View details" variant="primary" size="sm" href="/vacancies/1" />
<Button label="Apply now" variant="primary" size="sm" href="https://jobs.example.com" external />

When to use

Use a button to trigger an action or transition. Keep labels short and action-oriented — favour verbs like “Save”, “Submit”, or “Get quotes” over vague terms like “Click here”.

Variants

  • Accent: the single most important action on a page — “Get quotes”, “Book now”. One accent button per viewport. Locked to lg/xl sizes.
  • Primary: important actions that aren’t the page-level CTA — form submissions, confirmations. One primary per distinct area.
  • Secondary: supporting actions paired alongside a primary — “Cancel”, “Save draft”. Has a visible border.
  • Tertiary: low-emphasis actions — inline alternatives, toolbar items, supplementary links-as-buttons.

Do

Use correct hierarchy: one high-emphasis CTA with a lower-emphasis alternative.

Don't

Use multiple high-emphasis buttons at the same level — it removes visual hierarchy.

Sizes

SizeTypical use
xlPage-level CTAs; default and largest size for accent
lgProminent form actions, larger touch targets; smallest size for accent
mdDefault for most layouts
smCompact UI — data tables, inline actions
xsCompact UI where sm is still too large; not available for accent
xxsThe most compact size — dense toolbars, tight inline groups; not available for accent

Full width

Use fullWidth for mobile form submissions or stacked button groups where the button should span the container. Avoid it in desktop contexts where a natural content-width button is more appropriate.

Pass href when the control’s job is to go somewhere, and onClick/onPress when it performs an action. href renders a real anchor, which keeps prefetch, middle-click, ⌘-click, “open in new tab” and the no-JS fallback — all of which are lost if you fake navigation with onClick={() => window.location.assign(url)}. Add external for a destination outside the app, which opens a new tab and tells screen-reader users it will.

When not to use

  • Text-styled links: use Link. Button is for button-styled controls; for navigation that should look like a link in a sentence or a standalone text link, Link is the right component.
  • A navigating CTA that must be conditionally inert: the href branch forbids disabled/loading, because an <a> has no disabled attribute and the state could only ever be a visual lie. Use the action branch and navigate yourself.
  • Destructive actions without confirmation: pair with a confirmation dialog.
  • Multiple accent or primary buttons: if everything is high-emphasis, nothing is.

Props

Standard platform props also pass through to the rendered element (ButtonHTMLAttributes on web, PressableProps on native) — e.g. onMouseEnter/hitSlop, id/nativeID, aria-*. className/style stay blocked, along with the interaction/state props Button manages itself (type, aria-busy, onHoverIn/onFocus/etc).

Prop Type Default Description
label * string - Button text. Also used as the accessible name via aria-label.
variant 'accent' | 'primary' | 'secondary' | 'tertiary' 'accent' Visual style. accent is restricted to size lg or xl at the type level.
size 'xxs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' 'xl' for accent, 'md' for all others Controls padding, font size, and border radius.
inverse boolean surrounding Surface tone Swaps all colours to their inverse token equivalents. Defaults to the surrounding Surface tone — inverse inside `<Surface tone="inverse">`. Pass explicitly to force it on/off, e.g. on a dark background that is not a Surface.
fullWidth boolean false Stretches the button to fill its container width.
disabled boolean false Prevents interaction and applies disabled token colours.
loading boolean false Shows a spinner, disables interaction, and sets aria-busy="true". The label is retained as the accessible name. Icons are hidden while loading.
leadingIcon IconComponent (web) · IconAsset (native) - Optional icon rendered before the label. Decorative — the label remains the accessible name. Scales with size and inherits the label colour.
trailingIcon IconComponent (web) · IconAsset (native) - Optional icon rendered after the label. Decorative — the label remains the accessible name. Scales with size and inherits the label colour.
href string - Turns the button into navigation: a real <a> on web (and on react-native-web via Pressable), announced as a link. On iOS/Android the press opens the URL through Linking. Mutually exclusive with onClick/onPress, disabled and loading.
external boolean - Only with href. Opens in a new tab with target="_blank" rel="noopener noreferrer" and appends "(opens in a new tab)" to the accessible name. No open-in-new glyph is added — pass trailingIcon if you want the visual affordance.
onClick () => void - Web — called on click. No-op when disabled or loading. Mutually exclusive with href.
onPress () => void - Native — called on press. No-op when disabled or loading. Mutually exclusive with href.
accessibilityLabel string - Native — overrides the default accessible name (which falls back to label).
testID string - Native — passed to the root Pressable and the spinner for test targeting.

Import

Web

import { Button } from '@checkatrade/components-web';

Native

import { Button } from '@checkatrade/components-native';

Basic usage

<Button label="Get quotes" />
<Button label="Continue" variant="primary" />
<Button label="Save draft" variant="secondary" />
<Button label="Cancel" variant="tertiary" />

Inverse on a dark background

<div
  style={{ background: 'var(--color-background-surface-brand-primary-default)', padding: '2rem' }}
>
  <Button label="Get quotes" inverse />
</div>

Full width

<Button label="Submit" variant="primary" fullWidth />

Loading state

<Button label="Submitting" variant="primary" loading />

Platform status

Platform / AreaStatus
Design (Figma) Stable
Web (React) Stable
Native (React Native) Stable
iOS (Swift) Planned
Android (Kotlin) Planned
Accessibility audit Planned

Accessibility

Web

  • Uses a native <button> element — keyboard accessible by default. With href it renders a native <a> instead, so it is announced as a link and keeps the browser’s own navigation affordances.
  • aria-label is set from the label prop, so the accessible name is always present even when the label is visually replaced by a spinner during loading.
  • Loading state sets aria-busy="true".
  • Focus ring is provided globally via @checkatrade/tokens/css/base — button:focus-visible gets a 3px solid outline with 2px offset. The inverse focus ring switches automatically via data-inverse on the underlying element.

React Native

  • Uses Pressable with accessibilityRole="button" — announced as a button by screen readers on iOS (VoiceOver) and Android (TalkBack). With href the role becomes link, and react-native-web renders a real <a> (via href/hrefAttrs) so the web build gets genuine link semantics; on iOS/Android the press opens the URL through Linking.
  • accessibilityLabel defaults to label, so the accessible name is always present. Override with the accessibilityLabel prop when the visible label needs context.
  • Loading state sets accessibilityState={{ busy: true }}.
  • Hover state (onHoverIn/onHoverOut) is active on react-native-web for web users; it is a no-op on iOS and Android.

Both platforms

  • Every size renders at a fixed per-size height from @checkatrade/tokens (--sizing-button-height-{size} / sizingButtonHeight{Size}: 34/40/44/48/54/64 for xxs–xl) — a guaranteed consistent height across variants and states at a given size, not a free-form minimum. xxs/xs sit below the WCAG 2.5.5 (AAA) 44px touch target guidance by design; pick a size appropriate to the surrounding UI density.
  • accent is locked to lg/xl to ensure the page-level CTA always has sufficient visual weight and touch target size.
  • external appends “(opens in a new tab)” to the accessible name, so screen-reader users are warned before activating. There is deliberately no automatic open-in-new glyph (unlike Link) — Figma’s button spec has no slot for one; pass trailingIcon if the visual affordance is wanted.

Button Changelog

2026-05-08 — fullWidth and hover (PR #26)

Added

  • fullWidth prop — stretches the button to fill its container width.

2026-05-07 — Code review (PR #18)

Changed

  • Focus ring, cursor, and font-weight moved out of component classes into @checkatrade/tokens/css/base. The component no longer hardcodes these — they are provided by a single import at the app level.
  • data-inverse attribute added to the underlying <button> element so base.css can target the inverse focus ring colour via button[data-inverse]:focus-visible.
  • defaultVariants.size removed — it was dead code, always overridden by resolvedSize.
  • Redundant onClick guard removed — <button disabled> natively prevents click events.

Added

  • TypeScript discriminated union: variant="accent" now enforces size="xl" at compile time. Passing a smaller size to an accent button is a type error.
  • Storybook: onPress action wired via argTypes instead of inline in each story arg. Accent story locks size control. AllVariants, AllVariantsInverse, and AllSizes stories added.

Fixed

  • AllVariants story was mixing sizes; all variants now shown at xl for consistent comparison.

2026-04-23 — Initial implementation (PR #2, #15)

Added

  • Button component with accent, primary, secondary, tertiary variants.
  • sm, md, lg, xl sizes with token-backed padding and border radius.
  • inverse, disabled, loading props.
  • resolvedSize logic: accent defaults to xl, others to md.
  • Full token coverage: colours, spacing, typography via Tailwind utilities.
  • Storybook stories: Accent, Primary, Secondary, Tertiary, Disabled, Loading, AllVariants, AllVariantsInverse, AllSizes.
  • Tests: rendering, interaction, disabled state, loading state (12 tests).

Button Changelog (Native)

2026-05-08 — fullWidth and hover (PR #26)

Added

  • fullWidth prop — stretches the button to fill its container via style={{ width: '100%' }}.
  • Hover state via onHoverIn/onHoverOut on Pressable — no-op on iOS/Android, active on react-native-web.

2026-05-07 — Alignment with web Button (PR #18)

Changed

  • Variants renamed to match the web component: accent | primary | secondary | tertiary (previously primary | secondary | outline, where “primary” incorrectly used accent tokens and “outline” used tertiary tokens).
  • Styling rewritten using Unistyles v3 StyleSheet.create(theme => ...) with variants and compoundVariants instead of StyleSheet.flatten with inline conditionals.
  • effectiveVariant pattern removed — disabled state is now its own variant axis (isDisabled) rather than overriding the semantic variant.
  • resolvedSize logic aligned with web: accent defaults to xl, others default to md.
  • Spinner colour resolved directly from theme by props, not extracted from the resolved style object.

Added

  • xl size with token-backed padding and border radius (spacingButtonXl*).
  • inverse prop with compound variants covering all four variants and disabled state.
  • TypeScript discriminated union: variant="accent" enforces size="xl" at compile time.
  • Storybook: onPress action wired via argTypes. AllVariants, AllVariantsInverse, AllSizes stories added.
  • Test: inverse prop rendering (2 tests). “Retains accessible name when loading” test added.

Fixed

  • Font size and line height tokens were silently undefined — keys were typographyLabelFontSizeActionSm (non-existent). Corrected to typographyLabelFontSizeButtonSm/Md/Lg/Xl.
  • Unistyles mock updated to strip variants/compoundVariants from style objects and expose a no-op useVariants so tests run without errors.

2026-04-23 — Initial implementation (PR #2, #3)

Added

  • Button component with primary, secondary, outline variants (using accent, secondary, and tertiary tokens respectively).
  • sm, md, lg sizes.
  • disabled and loading props.
  • accessibilityLabel override prop and testID support.
  • registerTheme helper for Unistyles setup.
  • Tests: rendering, interaction, disabled state, loading state (11 tests).