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.
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.
Link
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.
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.
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.
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.
Button vs link
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 this | Use this | Notes |
|---|---|---|
| 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-expanded | The 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.
Usage rules
Eight rules. Most failures are one of these.
Dismiss left, commit right. The eye lands on the teal pill and the action is obvious.
Two primaries leave the user guessing. Pick one.
Let them click. Then explain what’s missing in plain language.
A disabled button is silent failure. The user can’t ask why.
Spinner inherits the label colour. Width holds. No layout shift in the form above.
Two signals for the same state. Pick one — and it’s the spinner.
Verb first parses instantly — for cursor, keyboard, and screen reader.
“OK” tells me nothing about what I’m agreeing to. Make the verb specific.
Destructive on the left, safe on the right. Distance prevents the misclick.
Pressing Enter shouldn’t trigger an irreversible action.
UI family, semibold, tracking nudged. The same on every button on every page.
Freight is editorial. Serif on a button reads as unfinished.
Tertiary should not compete. The teal pill leads; ghost waits its turn.
If it matters, give it weight. Ghost as the only CTA looks broken.
The pill is the action signal. A reader learns it once.
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;
}