Button
Triggers an action or event. A two-axis model — 8 semantic variants × 4 appearances (fill/outline/dashed/soft) — plus a toggle, four sizes, and icon/loading affordances.
Preview
<EsaButton>Get started</EsaButton> Variants
Eight semantic variants. ai maps to the copper AI-context palette; ghost is neutral and transparent.
<EsaButton variant="primary">Primary</EsaButton>
<EsaButton variant="secondary">Secondary</EsaButton>
<EsaButton variant="danger">Danger</EsaButton>
<EsaButton variant="success">Success</EsaButton>
<EsaButton variant="warning">Warning</EsaButton>
<EsaButton variant="info">Info</EsaButton>
<EsaButton variant="ai">AI</EsaButton>
<EsaButton variant="ghost">Ghost</EsaButton> Appearances
The appearance axis is independent of variant — any variant renders as fill, outline, dashed, or soft. soft is a neutral surface with accent-colored text that fills on hover — variant="danger" appearance="soft" is the secondary-danger button.
<EsaButton variant="danger" appearance="fill">Fill</EsaButton>
<EsaButton variant="danger" appearance="outline">Outline</EsaButton>
<EsaButton variant="danger" appearance="dashed">Dashed</EsaButton>
<EsaButton variant="danger" appearance="soft">Soft</EsaButton> Active (toggle)
Set active for a pressed/selected look (and aria-pressed) — the basis for toggle buttons.
<EsaButton appearance="outline">Default</EsaButton>
<EsaButton appearance="outline" active>Active</EsaButton> Sizes
<EsaButton size="xs">Extra small</EsaButton>
<EsaButton size="sm">Small</EsaButton>
<EsaButton size="md">Medium</EsaButton>
<EsaButton size="lg">Large</EsaButton> Icons
icon sits left of the label, iconRight sits right. They are
independent props, not two settings of one, so a button can carry both. Reach for
iconRight when the glyph is an affordance — where the action goes
(arrow-right) or that it opens something (chevron-down) — and
for icon when it labels what the action is. iconOnly
drops the text and squares the box; it uses icon and ignores
iconRight.
<EsaButton icon="plus">Leading</EsaButton>
<EsaButton iconRight="arrow-right">Continue</EsaButton>
<EsaButton appearance="outline" iconRight="chevron-down">Export</EsaButton>
<EsaButton icon="download" iconRight="chevron-down">Download</EsaButton>
<EsaButton iconOnly icon="plus" label="Add" /> States
Conditions the button is in, as opposed to what it is made of. Both are
transient and both block interaction; loading also hides the label in
place and suppresses either icon, so the width never jumps.
<EsaButton loading>Loading</EsaButton>
<EsaButton disabled>Disabled</EsaButton>
<EsaButton appearance="outline" active>Active</EsaButton> Chrome — buttons that live on an app bar
Every other variant owns a color. That is exactly what makes them unusable
on chrome: ghost resolves its text to --color-content-default,
which is --color-gray-12 — the same token --color-background-default-knockout
paints a dark app bar with. Gray-12 on gray-12 is a contrast ratio of 1:1.
chrome takes its color from context instead, so the containing
App Bar decides it and the same markup works on a
dark bar and a light one. The hover scrim and focus ring derive from
currentColor for the same reason. This variant absorbed
esa-icon-button (with iconOnly) and esa-icon-link
(with a label) — both are deprecated shims now.
<EsaAppBar tone="brand-strong">
<EsaButton variant="chrome" icon="trees" iconRight="chevron-down">Mitigation work</EsaButton>
<EsaButton variant="chrome" current href="/reporting" icon="trending-up">Reporting</EsaButton>
<EsaButton slot="end" variant="chrome" iconOnly icon="search" label="Search" />
</EsaAppBar> API
| Prop | Type | Default | Description |
|---|---|---|---|
variant | 'primary' | 'secondary' | 'danger' | 'success' | 'warning' | 'info' | 'ai' | 'ghost' | 'chrome' | 'primary' | Semantic intent. `ai` uses the copper AI-context palette; `ghost` is neutral/transparent. |
appearance | 'fill' | 'outline' | 'dashed' | 'soft' | 'fill' | How the variant renders — solid fill, bordered, dashed border, or a soft neutral surface with accent-colored text. |
active | boolean | false | Pressed/selected state (sets aria-pressed) — for toggle buttons. |
size | 'xs' | 'sm' | 'md' | 'lg' | 'md' | Control size (shared form scale). |
disabled | boolean | false | Disables interaction. |
loading | boolean | false | Shows a spinner; label is held in place but hidden. |
icon | string | '' | Leading Lucide icon name, rendered left of the label. |
iconRight | string | '' | Lucide icon shown to the right of the label — the affordance (`arrow-right`, `chevron-down`). Independent of `icon`; a button may carry both. |
iconOnly | boolean | false | Square icon-only button with no text. Uses `icon` and ignores `iconRight`. Requires `label`. |
label | string | '' | Accessible name. Required with `iconOnly` — there is no text for a screen reader otherwise. Also sets `title`, giving the hover tooltip icon-only chrome needs. |
current | boolean | false | Marks the current item in a navigation set (`aria-current`). Distinct from `active`, which is a toggle's pressed state (`aria-pressed`) — a nav link announced as a depressed toggle is worse than an unlabelled one. |
type | 'button' | 'submit' | 'reset' | 'button' | Native button type. |
tag | 'a' | 'button' | 'summary' | — | Override the rendered element. `href` already picks `<a>` vs `<button>`; this covers the case inference cannot reach — `summary`, the disclosure trigger inside `<details>`. |
href | string | — | When set, the button renders as an <a> styled identically — a link that looks like a button. |
target | string | — | Link target (href only), e.g. "_blank". |
rel | string | — | Link rel (href only). Defaults to "noreferrer" when target="_blank". |
class | string | '' | Extra class(es) — applied to the styled wrapper, not the native control. |
color | 'primary' | 'secondary' | 'danger' | 'success' | 'warning' | 'info' | 'ai' | 'ghost' | 'chrome' | — | DEPRECATED — renamed to `variant`. Still honoured; warns at build time. |
Any other attribute (onclick, id, name,
data-*, aria-*) is forwarded verbatim to the native
element, so plain-JS callers can wire a handler or grab it by id.
Accessibility
- Renders a native
<button>— keyboard and screen-reader behavior come for free. - Visible focus ring via
--focus-ring-*tokens (2px, offset). activesetsaria-pressed(a toggle);currentsetsaria-current(a nav position). They are separate props because they are separate claims.iconOnlyrequireslabel. This is checked at build time and warns rather than failing the type — the label is a slot, so a boolean flag cannot make a sibling prop mandatory. Passingaria-labeldirectly also satisfies it.variant="chrome"derives its focus ring fromcurrentColorrather than--focus-ring-color, so the ring stays visible on a dark app bar.loadingkeeps the label in the DOM (visually hidden) so width doesn't jump and the name persists.warningfill uses dark text for contrast on the amber background.
Theming surface
Every public token this component reads, extracted from its source at build time,
split by blast radius: the hooks that belong to this component,
then the shared and system tokens it consumes alongside everything else. The
Resolves to column walks each token's real lineage —
component → semantic → primitive → raw value — so you
can see exactly where re-pointing it lands. A spoke re-skins by overriding these in
its theme-<slug>.css; component internals are never edited. No
hook for what you need? File it with /request-lego.
Type roles it names
This component does not set a size, weight, face or tracking of its own — it names a composite, and the composite carries all five. A composite is a class rather than a token, so none of it appears in the tables below; re-theme text by re-pointing the token family, not by editing the component.
| Role | Re-point | Reach |
|---|---|---|
.typography-microcopy-2xs |
| +3 more componentsesa-button-toggle, esa-pill, esa-range-slider |
.typography-microcopy-lg |
| +2 more componentsesa-button-toggle, esa-range-slider |
.typography-microcopy-md |
| +4 more componentsesa-button-toggle, esa-pill, esa-range-slider, esa-sidebar-nav |
.typography-microcopy-xs |
| +6 more componentsesa-app-shell, esa-button-toggle, esa-filter-pills, esa-kbd, esa-pill, esa-range-slider |
Wired to this component
No other component reads these — re-point them and esa-button is the
only thing that moves.
| Token | Tier | Resolves to |
|---|---|---|
--button-chrome-bg-hover | component | color-mix(in srgb, currentColor 14%, transparent) |
--button-on-warning | component | #4f3422 via --color-content-on-utility-warning → --color-yellow-12 |
--button-radius-lg | component | 0.5rem via --radius-md → --radius-200 |
--button-radius-sm | component | 0.25rem via --radius-sm → --radius-100 |
--button-radius-xs | component | 0.25rem via --radius-sm → --radius-100 |
Shared tokens it reads
Owned by a family or by the semantic layer. Still overridable — but scope the
override (esa-foo { --form-border-color: … }) unless you mean to move
everything that reads it. Reach says what else that is.
| Token | Reach | Resolves to |
|---|---|---|
--button-radius-md | component +1 more | 0.5rem via --radius-md → --radius-200 |
--focus-ring-color | component focus ring group · +34 more | #3e9b4f via --color-border-default-focus → --color-grass-10 |
--focus-ring-offset | component focus ring group · +31 more | 2px |
--focus-ring-width | component focus ring group · +35 more | 2px via --border-width-focus → --border-width-200 |
--animation-spin | semantic every component | 750ms via --duration-750 |
--border-width-default | semantic every component | 1px via --border-width-100 |
--color-background-ai | semantic every component | #a18072 via --color-copper-9 |
--color-background-ai-hover | semantic every component | #957468 via --color-copper-10 |
--color-background-brand | semantic every component | #46a758 via --color-grass-9 |
--color-background-brand-hover | semantic every component | #3e9b4f via --color-grass-10 |
--color-background-brand-muted | semantic every component | #e9f6e9 via --color-grass-3 |
--color-background-brand-muted-hover | semantic every component | #daf1db via --color-grass-4 |
--color-background-elevation-raised | semantic every component | #fcfcfc via --color-gray-1 |
--color-background-elevation-sunken | semantic every component | #f0f0f0 via --color-gray-3 |
--color-background-utility-danger | semantic every component | #ce2c31 via --color-red-11 |
--color-background-utility-danger-hover | semantic every component | #641723 via --color-red-12 |
--color-background-utility-info | semantic every component | #0d74ce via --color-blue-11 |
--color-background-utility-info-hover | semantic every component | #113264 via --color-blue-12 |
--color-background-utility-success | semantic every component | #218358 via --color-green-11 |
--color-background-utility-success-hover | semantic every component | #193b2d via --color-green-12 |
--color-background-utility-warning | semantic every component | #ffc53d via --color-yellow-9 |
--color-background-utility-warning-hover | semantic every component | #ffba18 via --color-yellow-10 |
--color-border-default | semantic every component | one role per property, so a theme can move the ring's weight and its colour independently. Tier 3's --focus-ring-width now reads this instead of holding a bare 2px, which is the same shape --focus-ring-color has always had (a thin tier-3 alias over a tier-2 role) — width was simply never given the tier-2 half.
*
* This role was rejected once, and the rejection is worth recording because its PREMISE was wrong rather than its reasoning. The argument against an `emphasis` width was that every 2px border in the kit is micro-geometry — spinner rings, the kbd keycap lip, a chevron. The focus ring was not in that enumeration and does not fit it: 33 components render it, more than read any other tier-3 token, and it is not micro-geometry but an accessibility affordance whose weight is a legitimate brand axis (a spoke that needs a heavier ring for contrast has to be able to reach it). It is also not a `border` in any component — every consumer paints it with `outline` or `box-shadow` — which is precisely why a survey of BORDERS missed it. The name still says `border` because tier 2 already calls the ring's colour --color-border-default-focus |
--color-border-default-strong | semantic every component | #bbbbbb via --color-gray-8 |
--color-content-ai | semantic every component | #7d5e54 via --color-copper-11 |
--color-content-brand | semantic every component | #2a7e3b via --color-grass-11 |
--color-content-default | semantic every component | #202020 via --color-gray-12 |
--color-content-default-knockout | semantic every component | #fcfcfc via --color-gray-1 |
--color-content-on-brand-muted | semantic every component | #203c25 via --color-grass-12 |
--color-content-on-utility-success | semantic every component | #fcfcfc via --color-gray-1 |
--color-content-utility-danger | semantic every component | #ce2c31 via --color-red-11 |
--color-content-utility-info | semantic every component | #0d74ce via --color-blue-11 |
--color-content-utility-success | semantic every component | #218358 via --color-green-11 |
--color-content-utility-warning | semantic every component | #ab6400 via --color-yellow-11 |
--transition-fast | semantic every component | 150ms via --duration-150 |
--spacing-200 | primitive every component | 0.5rem |
--spacing-250 | primitive every component | 0.625rem |
--spacing-300 | primitive every component | 0.75rem |
--spacing-400 | primitive every component | 1rem |