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.
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
| Prop | Type | Default | Description |
|---|---|---|---|
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
| Slot | Type | Default | Description |
|---|---|---|---|
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-writtenaria-expandedon 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
::afterand 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.
titleis 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.
| Role | Re-point | Reach |
|---|---|---|
.typography-body-md |
| +28 more componentsesa-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 |
| +2 more componentsesa-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 |