GitHub

Collapsible

A single disclosure section — icon + title over a slotted body — built on native <details>/<summary>. Free a11y, keyboard, and open/close state, with no JavaScript.

Stable .astro import EsaCollapsible from '@esa/ecology/esa-collapsible.astro';

Preview

Permit conditions

Body content, revealed on toggle. The whole summary row is the control — not just the caret.

<Collapsible title="Permit conditions">
  <p>Body content, revealed on toggle.</p>
</Collapsible>

Icon and default state

A leading icon reads as a quiet, secondary glyph — it labels the section, it doesn't decorate it. open renders the section expanded on load.

Site details

Open on load — use this for the section a reader wants first.

Attachments

Closed by default.

<Collapsible title="Site details" icon="map-pin" open>…</Collapsible>
<Collapsible title="Attachments" icon="file-text">…</Collapsible>

Flush

flush strips the card chrome and the horizontal padding, so sections sit directly in a drawer, sidebar, or filter rail where the host already supplies the dividers and gutter.

Status

Flush sections take the host's gutter, not their own.

Date range

Stacked, each opening independently.

<Collapsible title="Status" icon="circle-check" flush open>…</Collapsible>
<Collapsible title="Date range" icon="calendar" flush>…</Collapsible>

Stacking — not an accordion

This is a single disclosure, not a single-open accordion group. Stack several for a set and each opens independently, matching native <details> semantics. There is deliberately no group component: "only one open at a time" is a behavior that costs readers the comparison they usually want, and it takes JS to enforce something the platform gives you for free otherwise.

Overview

Both of these can be open at once.

Conditions

Independent state per section.

History

No coordination between siblings.

<Collapsible title="Overview" icon="info" open>…</Collapsible>
<Collapsible title="Conditions" icon="notepad-text">…</Collapsible>
<Collapsible title="History" icon="history">…</Collapsible>

API

PropTypeDefaultDescription
title string required Summary label. Required — the row is the control, so it always needs a name.
icon string — Optional leading Lucide icon name (kebab-case), rendered through esa-icon at size sm.
iconPaths string — Inner SVG markup for an icon outside esa-icon's registry. Passed straight through.
open boolean false Render expanded on load.
flush boolean false Drop the card chrome (border, radius, background) and the horizontal padding — for stacked drawer or sidebar sections separated by the host’s own dividers.

Slots

SlotTypeDefaultDescription
default slot — The disclosed body. A flex column with a 1rem gap between children.

Accessibility

  • Native <details>/<summary>: the expanded state, the button role, Enter/Space activation, and focus all come from the platform. No ARIA is added, and none should be — hand-written aria-expanded on a summary fights the element.
  • The entire summary row is the hit target, not just the caret — a full-width tap target, which is the point of using the native element.
  • The caret is a CSS ::after and the native marker is hidden, so nothing is announced twice.
  • Collapsed content is genuinely hidden from find-in-page in some browsers. Don't hide content a reader must be able to search for — and never a form field carrying a validation error.
  • title is a <span>, not a heading. If the sections are page structure rather than a widget, wrap each summary's label in the right <h*> at the call site.

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-card, esa-checkbox, esa-checkbox-group, 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-label-sm-strong
  • --typography-label-sm-strong-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-label-sm-strong-font-size clamp(0.6875rem, 0.61rem + 0.38vw, 0.875rem) via --font-size-150
  • --typography-label-sm-strong-font-weight 550 via --typography-font-weight-semibold → --font-weight-550
  • --typography-label-sm-strong-letter-spacing 0.01em via --letter-spacing-default
  • --typography-label-sm-strong-line-height 1.6 via --line-height-normal
+2 more components esa-alert-box, esa-empty-state

Wired to this component

None. This component has no hooks of its own — it themes entirely through the shared tokens below, so every override here also moves its siblings.

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-elevation-raised semantic every component #fcfcfc via --color-gray-1
--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-content-default semantic every component #202020 via --color-gray-12
--color-content-default-secondary semantic every component #646464 via --color-gray-11
--radius-md semantic every component 0.5rem via --radius-200
--spacing-200 primitive every component 0.5rem
--spacing-300 primitive every component 0.75rem
--spacing-400 primitive every component 1rem