GitHub

Card

Surface container with an optional header (icon, title, meta pairs, actions), body, and footer. Variants, header tints, and padding scales.

Stable .astro import EsaCard from '@esa/ecology/esa-card.astro';

Preview

Quarterly report

Reporting period
Calendar Q3 2026

Body content for the card.

<Card
  title="Quarterly report"
  meta={[{ label: 'Reporting period', value: 'Calendar Q3 2026' }]}>
  <p>Body content for the card.</p>
</Card>

Meta pairs

Each entry in meta is a { label, value } pair: the label is the field's name in the record, the value is what is stored there. Both parts are required, so the header can carry several facts without the reader having to work out how they relate.

A pair identifies the record: which project, which permit, which period. A figure the card exists to report is a stat, so put it in an esa-stat in the body, where it reads at stat scale.

Eelgrass restoration

Permit
USACE Section 404
County
Marin
Received
Mar 4, 2026

Body content for the card.

<Card
  title="Eelgrass restoration"
  meta={[
    { label: 'Permit', value: 'USACE Section 404' },
    { label: 'County', value: 'Marin' },
    { label: 'Received', value: 'Mar 4, 2026' },
  ]}>
  <p>Body content for the card.</p>
</Card>

Variants

Default

Default card.

Elevated

Elevated card.

Filled

Filled card.

<Card title="Default">…</Card>
<Card variant="elevated" title="Elevated">…</Card>
<Card variant="filled" title="Filled">…</Card>

Header variants

Primary header

Status
Active

Tinted header band.

Muted header

Subtle sunken header.

<Card headerVariant="primary" title="Primary header" meta={[{ label: 'Status', value: 'Active' }]}>…</Card>
<Card headerVariant="muted" title="Muted header">…</Card>

Padding

Compact

Tighter body.

Default

Standard body.

Spacious

Roomy body.

<Card padding="compact" title="Compact">…</Card>
<Card padding="default" title="Default">…</Card>
<Card padding="spacious" title="Spacious">…</Card>

Actions & footer

The actions slot sits at the right of the header; the footer slot renders a bottom band.

Quarterly report

Reporting period
Calendar Q3 2026

The actions slot sits at the right of the header; the footer slot renders a bottom band.

<Card title="Quarterly report" meta={[{ label: 'Reporting period', value: 'Calendar Q3 2026' }]} icon="info">
  <button slot="actions" type="button">Edit</button>
  <p>Body content.</p>
  <span slot="footer">Last updated 2 hours ago</span>
</Card>

API

PropTypeDefaultDescription
title string — Header title. Presence (or meta, or a header/actions slot) renders the header band.
meta EsaMetaPair[] — Identifying facts about the record, as {label, value} pairs. Renders as a definition list under the title, each label beside its value. Both parts are required. A period pair states its calendar: "Calendar Q3 2026", not "Q3".
subtitle string — Deprecated 2026-08-16 in favour of meta; still renders. A free-text line cannot name what its facts are.
icon string — Lucide icon name shown left of the title.
variant 'default' | 'outlined' | 'elevated' | 'filled' 'default' Surface treatment: bordered, shadowed, or sunken.
headerVariant 'default' | 'primary' | 'muted' 'default' Header band tint.
padding 'none' | 'compact' | 'default' | 'spacious' 'default' Body and header padding scale.

Slots

PropTypeDefaultDescription
default slot — Card body content.
header slot — Replaces the title block entirely with custom markup.
actions slot — Right-aligned controls in the header band.
footer slot — Bottom band below the body.

Accessibility

  • Renders a plain <div> — add role/landmarks at the call site if the card is interactive or a region.
  • The title is an <h3>; keep heading order sensible within the surrounding page.
  • The header icon is aria-hidden — it decorates the title, not replaces it.

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-body-md
  • --typography-body-md-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-body-md-font-size clamp(0.75rem, 0.66rem + 0.44vw, 0.9375rem) via --font-size-200
  • --typography-body-md-font-weight 350 via --typography-font-weight-regular → --font-weight-350
  • --typography-body-md-letter-spacing 0.01em via --letter-spacing-default
  • --typography-body-md-line-height 1.6 via --line-height-normal
+28 more components esa-app-shell, esa-breadcrumbs, esa-checkbox, esa-checkbox-group, esa-collapsible, esa-combobox, esa-command-palette, esa-confirm-dialog, esa-container, esa-danger-zone, esa-dialog, esa-dropdown-menu, esa-empty-state, esa-entity-search, esa-form-field, esa-header-nav, esa-input-tag, esa-nav-dropdown, esa-page-header, esa-popover, esa-progress-bar, esa-radio-group, esa-select, esa-side-dialog, esa-snackbar-item, esa-switch-toggle, esa-tab-layout, esa-textarea
.typography-body-sm
  • --typography-body-sm-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-body-sm-font-size clamp(0.6875rem, 0.61rem + 0.38vw, 0.875rem) via --font-size-150
  • --typography-body-sm-font-weight 350 via --typography-font-weight-regular → --font-weight-350
  • --typography-body-sm-letter-spacing 0.01em via --letter-spacing-default
  • --typography-body-sm-line-height 1.6 via --line-height-normal
+20 more components esa-alert-box, esa-breadcrumbs, esa-button-toggle, esa-checkbox-group, esa-combobox, esa-command-palette, esa-date-picker, esa-empty-state, esa-file-upload, esa-form-field, esa-input-tag, esa-link-column, esa-loading-overlay, esa-progress-bar, esa-radio-group, esa-search-panel, esa-select, esa-stat, esa-text-field, esa-textarea
.typography-label-md
  • --typography-label-md-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-label-md-font-size clamp(0.75rem, 0.66rem + 0.44vw, 0.9375rem) via --font-size-200
  • --typography-label-md-font-weight 500 via --typography-font-weight-medium → --font-weight-500
  • --typography-label-md-letter-spacing 0.01em via --letter-spacing-default
  • --typography-label-md-line-height 1.6 via --line-height-normal
+33 more components esa-app-bar, esa-breadcrumbs, esa-button-group, esa-button-toggle, esa-checkbox-group, esa-color-picker, esa-combobox, esa-command-palette, esa-confirm-dialog, esa-danger-zone, esa-date-picker, esa-dialog, esa-empty-state, esa-entity-search, esa-file-upload, esa-filter-container, esa-form-field, esa-header-nav, esa-input-tag, esa-link-column, esa-page-header, esa-pillbox, esa-popover, esa-progress-bar, esa-radio-group, esa-range-slider, esa-select, esa-side-dialog, esa-sidebar-nav, esa-stat, esa-text-field, esa-textarea, esa-tooltip
.typography-meta
  • --typography-meta-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-meta-font-size clamp(0.625rem, 0.56rem + 0.32vw, 0.75rem) via --font-size-100
  • --typography-meta-font-weight 350 via --typography-font-weight-regular → --font-weight-350
  • --typography-meta-letter-spacing 0.01em via --letter-spacing-default
  • --typography-meta-line-height 1.6 via --line-height-normal
only here
.typography-title-sm-strong
  • --typography-title-sm-strong-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-title-sm-strong-font-size clamp(0.8125rem, 0.71rem + 0.5vw, 1.0625rem) via --font-size-250
  • --typography-title-sm-strong-font-weight 550 via --typography-font-weight-semibold → --font-weight-550
  • --typography-title-sm-strong-letter-spacing 0.01em via --letter-spacing-default
  • --typography-title-sm-strong-line-height 1.6 via --line-height-normal
+1 more component esa-danger-zone

Wired to this component

No other component reads these — re-point them and esa-card is the only thing that moves.

Token Tier Resolves to
--card-bg component #fcfcfc via --color-background-elevation-raised → --color-gray-1
--card-border-color 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 via --color-border-default
--card-header-bg component transparent

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
--border-width-default semantic every component 1px via --border-width-100
--color-background-brand semantic every component #46a758 via --color-grass-9
--color-background-elevation-sunken semantic every component #f0f0f0 via --color-gray-3
--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-subtle semantic every component #d9d9d9 via --color-gray-6
--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-default-secondary semantic every component #646464 via --color-gray-11
--color-content-on-brand semantic every component #fcfcfc via --color-gray-1
--elevation-2 semantic every component 0 via --shadow-offset-x
--font-weight-medium semantic every component 500 via --typography-font-weight-medium → --font-weight-500
--radius-md semantic every component 0.5rem via --radius-200
--typography-label-md-font-size semantic every component clamp(0.75rem, 0.66rem + 0.44vw, 0.9375rem) via --font-size-200
--typography-label-sm-font-size semantic every component clamp(0.6875rem, 0.61rem + 0.38vw, 0.875rem) via --font-size-150
--spacing-050 primitive every component 0.125rem
--spacing-100 primitive every component 0.25rem
--spacing-200 primitive every component 0.5rem
--spacing-300 primitive every component 0.75rem
--spacing-400 primitive every component 1rem
--spacing-500 primitive every component 1.5rem
--spacing-700 primitive every component 3rem