Skip to main content
Executive Support by Beige Threat

Buttons

A button does one thing – signal a single, primary action. Everything else is restraint. Five variants, three sizes, six states; every measurement a token reference, so re-tuning the system re-tunes every button on the page. This is the first component we shipped, and the one every other component is measured against.

Variants

Hierarchy lives in the variant. Pick the variant that matches the action’s weight in the page – not its mood. Primary is the single signature CTA. Everything else clears the way for it.

primaryteal · paper · once per region
secondarynavy · paper · the “or instead”
ghosttransparent until needed
linklooks like a link · behaves like a button
dangerdestructive only · confirm on click

Primary

The single most-important action on the surface. Teal background, paper text. One per view, ideally per region. Use it for the verb that defines the page – Subscribe, Save draft, Send to editor. Never pair primary with primary; the second one wins by accident.

Secondary

The “or instead” companion. Navy fill, paper text. Use beside primary when there’s a real, equal-weight alternative – Save vs. Save and continue. Never pair secondary with link doing the same job; pick one alternative form.

Ghost

Transparent background, ink text, hover surface. For tertiary actions inside dense UI – table rows, dialog dismiss, toolbar overflow. Ghost should be invisible until the user goes looking for it. Never use ghost as a primary action; if it matters, give it weight.

An inline action that looks like a link but behaves like a button. Use inside prose when the verb belongs in the sentence – “or read the issue”. The role matters; see Button vs link below. Never pair link with primary; reach for secondary instead.

Danger

Feedback-danger surface, paper text. Reserved for destructive, irreversible actions – Delete account, Discard draft, Cancel subscription. Always confirm on click, never make danger the default focused button in a dialog. Never pair danger with primary in the same row; put space between them.

Sizes

Three sizes. md is the default. Use sm only inside dense UI – toolbars, table rows, inline filters. Use lg only for primary marketing CTAs and hero blocks. On touch surfaces every size keeps a 44 px tap target via outer padding; the visible chrome stays at 32, 40, or 48.

sm · 32
md · 40
lg · 48

States

Six states, every variant. Tab into the page – the focus ring lights for keyboard, not for mouse, exactly as :focus-visible intends. The other variants follow the same pattern as primary, shifting hover and pressed tints proportionally.

defaultresting state · token-fed
hover—es-color-action-primary-hover · 150ms
focus-visibleteal-600 ring · 2px · 2px offset
pressed—es-color-action-primary-pressed
disabledopacity 0.40 · not-allowed
loadingaria-busy · width preserved

Anatomy

Every measurement in a button is a token. Padding is --es-spacing-inset-md horizontally and a half-step vertically (height does the rest). Icon and label sit on a --es-space-2 gap. Radius is --es-radius-action – a pill, only ever a pill.

height · 40 px
md default

radius · pill
—es-radius-action

padding-x · 16 px
—es-spacing-inset-md

icon gap · 8 px
—es-space-2

label · Inter 600 · 14 / 1.0 · -0.011em

icon · 16 px · stroke 1.5

With icon

Icons left- or right-anchor with an 8 px gap. Leading icons announce category – arrow-left for back, download for fetch. Trailing icons announce direction or change – arrow-right for next, external for off-site. Icon-only buttons require an aria-label that matches the visible tooltip exactly; without it, the action is invisible to screen readers.

leading iconverb · category
trailing icondirection · “and then”
icon-onlyaria-label required

Loading

Loading replaces the label with the spinner – never both visible at once. The button keeps its width to prevent layout shift; the surrounding form should not jump as the user waits. Set aria-busy="true" so screen readers announce the wait without losing focus.

The rules are simple, and routinely broken. The semantic mismatch hurts keyboard users (Space vs Enter), screen readers (button role vs link role), and middle-click expectations.

It does thisUse thisNotes
Submits a form<button type="submit">Browser submit + keyboard default
Triggers JavaScript<button type="button">Always set type to avoid accidental submit
Navigates to a URL<a href>Even if styled as a pill
Toggles a panel in place<button> with aria-expandedThe URL doesn’t change, so it isn’t a link
Opens a new tab<a href target="_blank" rel="noopener">Add a visually-hidden “(opens in new tab)” label

A pill-shaped link is fine, if it’s actually a navigation. Style follows behaviour, not the other way around.

HTML recipe

The minimal HTML for each variant and size, with full class names and ARIA attributes. Drop in; everything else is tokens.

<!-- Primary · md · the default -->
<button type="button" class="es-btn">Subscribe</button>

<!-- Secondary · sm · inline action -->
<button type="button" class="es-btn es-btn--secondary es-btn--sm">Save for later</button>

<!-- Ghost · md · dialog dismiss -->
<button type="button" class="es-btn es-btn--ghost">Cancel</button>

<!-- Link · inline · inside prose -->
<button type="button" class="es-btn es-btn--link">Read the issue</button>

<!-- Danger · md · with confirm-on-click handler -->
<button type="button" class="es-btn es-btn--danger">Delete account</button>

<!-- Primary · lg · with leading icon -->
<button type="button" class="es-btn es-btn--lg">
  <svg aria-hidden="true" viewBox="0 0 24 24"><!-- icon path --></svg>
  <span>Read the issue</span>
</button>

<!-- Icon-only · ghost · sm -->
<button type="button" class="es-btn es-btn--ghost es-btn--icon es-btn--sm" aria-label="More options">
  <svg aria-hidden="true" viewBox="0 0 24 24"><!-- icon path --></svg>
</button>

<!-- Loading · primary · width preserved -->
<button type="button" class="es-btn es-btn--loading" aria-busy="true">Saving</button>

<!-- Navigates · use an anchor, even if it looks like a button -->
<a href="/issues/04" class="es-btn">Read issue 04</a>

Token table

Every token a Button consumes. Padding, gap, type, border, radius, fill, hover overlay, pressed overlay, focus ring, motion. Re-tune any of these in the foundation and every button on the page re-tunes with it.

Geometry
--es-radius-action
999px (pill)

Radius for any actionable surface. Buttons only — never reach for pill on cards.

--es-spacing-inset-md
16px horizontal

Default horizontal padding for md size. Sm uses inset-sm (12px); lg uses inset-lg (24px).

--es-space-2
8px

Gap between icon and label. Constant across all three sizes — the type scales, the breath stays.

--es-border-hairline
1px

Border width for every variant — even the filled ones, so secondary on hover doesn't shift by a pixel.

Typography
--es-font-family-ui
Inter · 600 · 14 / 1.0 · -0.011em

UI family at semibold. Letter-spacing nudges in for Inter at mid-sizes. Never serif on a button.

Colour — primary
--es-color-action-primary-default
#06A0A9 · teal-600

Brand teal. Reserved for the primary action. The single signature CTA on the page.

--es-color-action-primary-hover
#056B71 · teal-700

Hover overlay for primary. Resolves to a deeper teal — never a tint.

--es-color-action-primary-pressed
#044C51 · teal-800

Pressed state. One shade darker than hover. Pairs with translateY(1px).

Colour — secondary, ghost, link, danger
--es-color-action-secondary-default
#181F2C · navy-900

Secondary fill. The 'or instead' companion to primary teal.

--es-state-hover-on-light
ink @ 6%

Hover overlay for ghost variant on cream and paper. Ink at 6% — barely there until you need it.

--es-color-text-link
#056B71 · teal-700

Link variant colour. Passes AA on cream and paper. Underline offset 4px for a magazine feel.

--es-color-feedback-danger-text
#530203 · red-900

Danger fill, paper text. Reserved for irreversible destructive actions.

State
--es-focus-ring
2px teal-600 · 2px paper offset

Project-wide focus ring. Inherited from states.css; never re-implemented per component.

--es-state-disabled-opacity
0.40

Whole-button opacity when disabled. Cursor switches to not-allowed. Removed from tab order via the disabled attribute.

Motion
--es-motion-state
150ms · cubic-bezier(0.2, 0, 0.2, 1)

State transition timing. Hover, press, focus all share this token. Reduced-motion collapses to 1ms.

Usage rules

Eight rules. Most failures are one of these.

Do · One primary per region

Dismiss left, commit right. The eye lands on the teal pill and the action is obvious.

Don't · Two primaries side by side

Two primaries leave the user guessing. Pick one.

Do · Real validation, not a disabled button

Let them click. Then explain what’s missing in plain language.

Don't · Disable to prevent the click

A disabled button is silent failure. The user can’t ask why.

Do · Loading replaces the label

Spinner inherits the label colour. Width holds. No layout shift in the form above.

Don't · Spinner alongside the label

Two signals for the same state. Pick one — and it’s the spinner.

Do · Verb-led labels

Verb first parses instantly — for cursor, keyboard, and screen reader.

Don't · OK · Submit · Yes

“OK” tells me nothing about what I’m agreeing to. Make the verb specific.

Do · Danger sits apart from dismiss

Destructive on the left, safe on the right. Distance prevents the misclick.

Don't · Danger as the default focus

Pressing Enter shouldn’t trigger an irreversible action.

Do · Inter on every button

UI family, semibold, tracking nudged. The same on every button on every page.

Don't · Serif on a button

Freight is editorial. Serif on a button reads as unfinished.

Do · Ghost stays invisible until needed

Tertiary should not compete. The teal pill leads; ghost waits its turn.

Don't · Ghost as the primary action

If it matters, give it weight. Ghost as the only CTA looks broken.

Do · Pill radius only on actions

The pill is the action signal. A reader learns it once.

Don't · Pill on cards or inputs
A card, but pill-shaped, somehow.

Pill on non-actions dilutes the signal. Cards use --es-radius-card; inputs use --es-radius-control.

API contract

The prop names a future React component would expose. Keep the surface small; resist adding props that overlap with class names. Anything not on this list is a custom component, not a Button.

type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'link' | 'danger';
type ButtonSize    = 'sm' | 'md' | 'lg';

interface ButtonProps {
  /** Visual hierarchy. Default: 'primary'. */
  variant?: ButtonVariant;
  /** Control density. Default: 'md'. */
  size?: ButtonSize;
  /** Replace the label with a spinner; preserve width; sets aria-busy. */
  loading?: boolean;
  /** Native disabled. Removes from tab order. Prefer real validation instead. */
  disabled?: boolean;
  /** Optional leading icon. Pass an icon name from the system set. */
  iconLeft?: IconName;
  /** Optional trailing icon. */
  iconRight?: IconName;
  /** Render as an <a> when the action navigates. Default: 'button'. */
  as?: 'button' | 'a';
  /** Required when as='a' — the navigation target. */
  href?: string;
  /** Required for icon-only buttons; matches the visible tooltip. */
  'aria-label'?: string;
}