Prototype Library

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.

Inherited .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.