GitHub

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.

Stable .astro import EsaButton from '@esa/ecology/esa-button.astro';

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.

Reporting
<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

PropTypeDefaultDescription
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).
  • active sets aria-pressed (a toggle); current sets aria-current (a nav position). They are separate props because they are separate claims.
  • iconOnly requires label. 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. Passing aria-label directly also satisfies it.
  • variant="chrome" derives its focus ring from currentColor rather than --focus-ring-color, so the ring stays visible on a dark app bar.
  • loading keeps the label in the DOM (visually hidden) so width doesn't jump and the name persists.
  • warning fill 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.

RoleRe-pointReach
.typography-microcopy-2xs
  • --typography-microcopy-2xs-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-microcopy-2xs-font-size clamp(0.5rem, 0.44rem + 0.3vw, 0.625rem) via --font-size-050
  • --typography-microcopy-2xs-font-weight 500 via --typography-font-weight-medium → --font-weight-500
  • --typography-microcopy-2xs-letter-spacing 0.01em via --letter-spacing-default
  • --typography-microcopy-2xs-line-height 1 via --line-height-none
+3 more components esa-button-toggle, esa-pill, esa-range-slider
.typography-microcopy-lg
  • --typography-microcopy-lg-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-microcopy-lg-font-size clamp(0.875rem, 0.77rem + 0.52vw, 1.125rem) via --font-size-300
  • --typography-microcopy-lg-font-weight 500 via --typography-font-weight-medium → --font-weight-500
  • --typography-microcopy-lg-letter-spacing 0.01em via --letter-spacing-default
  • --typography-microcopy-lg-line-height 1 via --line-height-none
+2 more components esa-button-toggle, esa-range-slider
.typography-microcopy-md
  • --typography-microcopy-md-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-microcopy-md-font-size clamp(0.75rem, 0.66rem + 0.44vw, 0.9375rem) via --font-size-200
  • --typography-microcopy-md-font-weight 500 via --typography-font-weight-medium → --font-weight-500
  • --typography-microcopy-md-letter-spacing 0.01em via --letter-spacing-default
  • --typography-microcopy-md-line-height 1 via --line-height-none
+4 more components esa-button-toggle, esa-pill, esa-range-slider, esa-sidebar-nav
.typography-microcopy-xs
  • --typography-microcopy-xs-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-microcopy-xs-font-size clamp(0.625rem, 0.56rem + 0.32vw, 0.75rem) via --font-size-100
  • --typography-microcopy-xs-font-weight 500 via --typography-font-weight-medium → --font-weight-500
  • --typography-microcopy-xs-letter-spacing 0.01em via --letter-spacing-default
  • --typography-microcopy-xs-line-height 1 via --line-height-none
+6 more components esa-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