Collapse
Collapse is a trigger that reveals a content panel in place on activation, such as an FAQ answer or a "read more" block, without navigating anywhere else or overlaying the page.
Dependencies
When to use
Use collapse when there's content most people won't need right away, such as an FAQ answer, extra details on a product, or a "show more" block, and you want to keep the page short without hiding that content away on another screen. It's not the right tool for content that should always stay visible and merely feel less prominent (dim it with CSS instead). A closed collapse panel is completely removed from the page for anyone using a screen reader, not just visually tucked away. See Alternatives below for when the content should overlay the page instead, or stay in an even more compact footprint.
Install & usage
Pick a framework in the toolbar above and these snippets adapt.
npm install @eunomia/elements
import "@eunomia/elements/collapse.js";
<eun-collapse heading="What is your return policy?">
You can return any item within 30 days of delivery for a full refund.
</eun-collapse>
document
.querySelector("eun-collapse")
.addEventListener("eunchange", (event) => {
console.log(event.target.open);
});
Importing the file registers <eun-collapse> as a custom element, with no
further setup needed. It works with any framework, or none, since it's a
standard web component.
npm install @eunomia/elements
<script type="module">
import "@eunomia/elements/collapse.js";
</script>
<eun-collapse heading="What is your return policy?">
You can return any item within 30 days of delivery for a full refund.
</eun-collapse>
<script>
document
.querySelector("eun-collapse")
.addEventListener("eunchange", (event) => {
console.log(event.target.open);
});
</script>
npm install @eunomia/elements
import "@eunomia/elements/collapse.js";
function ReturnPolicy() {
return (
<eun-collapse
heading="What is your return policy?"
oneunchange={(event) => console.log(event.target.open)}
>
You can return any item within 30 days of delivery for a full refund.
</eun-collapse>
);
}
npm install @eunomia/elements
"use client";
import "@eunomia/elements/collapse.js";
export function ReturnPolicy() {
return (
<eun-collapse
heading="What is your return policy?"
oneunchange={(event) => console.log(event.target.open)}
>
You can return any item within 30 days of delivery for a full refund.
</eun-collapse>
);
}
npm install @eunomia/elements
<script setup>
import "@eunomia/elements/collapse.js";
</script>
<template>
<eun-collapse
heading="What is your return policy?"
@eunchange="(event) => console.log(event.target.open)"
>
You can return any item within 30 days of delivery for a full refund.
</eun-collapse>
</template>
npm install @eunomia/elements
import { CUSTOM_ELEMENTS_SCHEMA, Component } from "@angular/core";
import "@eunomia/elements/collapse.js";
@Component({
selector: "app-return-policy",
template: `
<eun-collapse
heading="What is your return policy?"
(eunchange)="onChange($event)"
>
You can return any item within 30 days of delivery for a full refund.
</eun-collapse>
`,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class ReturnPolicyComponent {
onChange(event: Event) {
console.log((event.target as HTMLElement & { open: boolean }).open);
}
}
Alternatives
| You want… | Prefers |
|---|---|
| The content to overlay the page instead of expanding in place | eun-modal |
| A compact, contained reveal that never pushes surrounding content | eun-sliding-card |
Guidance
- Use
eun-collapseto let someone reveal supplementary content on demand without navigating away, such as an FAQ answer, a "show more" details block, or a single expandable card - Set
level(2-6) on each item in a group of several collapses (e.g. FAQ entries) so screen reader users can jump between them by heading, matching the page's own outline - Set
independenton collapses placed side by side with no shared exclusivity between them. Leave it unset for a single collapse, or a group meant to read as one connected list (e.g. an accordion) - Use
rich-textfor short, data-driven copy (a description coming from an API) and the default slot for actual markup: both can be combined - Keep slotted
icon/tag/chevroncontent non-interactive. See the Accessibility tab for why
- Expecting several
eun-collapseto behave as an exclusive accordion (opening one closing the others) by default. Each instance is fully independent. See the "Exclusive (accordion)" example in the Examples tab for the few lines ofeunchangewiring needed to get that behavior - Slotting a link, button, or other interactive element into
icon,tag, orchevron. They render inside the trigger's own native<button>, and nested interactive content there is invalid and unreliable for keyboard/assistive tech users - Using
eun-collapsefor content that should always be visible and just visually de-emphasized. It fully removes its panel from the accessibility tree while closed
Live testing
Properties
Collapse <eun-collapse>
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
| heading | string | — | The trigger's heading text. Ignored once something is slotted into heading |
| subheading | string | — | Optional secondary text shown under the heading. Ignored once something is slotted into subheading |
| level | 2 | 3 | 4 | 5 | 6 | — | Wraps the trigger in a heading element of this level, so screen reader users can jump between items, such as several FAQ entries, by heading. Matches the page's own outline, so it's opt-in rather than assumed |
| icon | EunomiaIconName | — | An optional leading icon for the trigger. Ignored once the icon slot has content |
| tag | string | — | An optional short label rendered as a default tag in the trigger, such as a count or category. Ignored once something is slotted into tag |
| chevron-icon | EunomiaIconName | 'add' | The trailing indicator icon. Ignored once the chevron slot has content. Pair a non-default icon with the chevron rotation custom property, since the default plus-sign-to-cross rotation doesn't suit every icon |
| rich-text | string | — | Lightweight formatted content rendered above the default slot. Supports simple lists, links, bold, and italic text |
| open | boolean | false | Whether the panel is currently expanded |
| disabled | boolean | false | Whether the trigger is disabled |
| independent | boolean | false | Renders the trigger and panel as two fully rounded, separated boxes with a gap instead of the default flush shape with a divider. Use it for instances with no shared exclusivity between them |
Import the exact TypeScript type behind any property above, see
Slots
| Name | Description |
|---|---|
| (default) | The panel's content, shown when expanded, combining with richText if both are set. Only the panel's own padding and text color apply to it, with no margins, typography, or list styles forced onto slotted content |
| heading | The trigger's heading as light DOM content, so it's part of the server-rendered HTML. Falls back to heading when empty. Keep it to text and inline phrasing, since it renders inside the trigger button |
| icon | Replaces the leading icon entirely with any content, not just an icon element. Falls back to icon when empty |
| subheading | The secondary text under the heading as light DOM content, so it's part of the server-rendered HTML. Falls back to subheading when empty |
| tag | The trigger's tag as light DOM content. Plain elements, such as a span of text, keep the default tag style, while a custom element, such as a colored eun-tag, replaces the tag entirely. Falls back to tag when empty |
| chevron | Replaces the trailing indicator entirely with any content, not just an icon element. Falls back to chevronIcon when empty. Whatever ends up there still rotates while open, unless that rotation is set to zero |
Events
| Name | Type | Description |
|---|---|---|
| eunchange | ChangeEvent | Fired whenever open changes |
Every event above follows the same naming convention, covered in
CSS custom properties
| Name | Description |
|---|---|
| --collapse-background | Sets the background color of the trigger and panel while closed |
| --collapse-background-hover | Sets the background color of the trigger on hover, while closed |
| --collapse-background-open | Sets the background color of the trigger and panel while open |
| --collapse-color | Sets the heading and icon color while closed |
| --collapse-color-hover | Sets the heading and icon color of the trigger on hover, while closed |
| --collapse-color-open | Sets the heading and icon color while open |
| --collapse-background-focus | Sets the background color of the trigger on keyboard focus |
| --collapse-color-focus | Sets the heading and icon color of the trigger on keyboard focus |
| --collapse-subheading-color | Sets the color of the subheading |
| --collapse-content-color | Sets the color of the panel's content. Rich text and slotted content both inherit it by default, and either can still override it locally |
| --collapse-content-font-size | Sets the font size of the panel's content, inherited by richText and slotted content alike |
| --collapse-rich-text-link-color | Sets the color of links inside richText, independently of the trigger's open-state color |
| --collapse-icon-color | Sets the color of the leading and chevron icons, independently of the text color |
| --collapse-chevron-rotation | Sets the rotation applied to the trailing indicator while open. Defaults to a plus-sign-to-cross rotation. Use a different value for a chevron or arrow icon, or zero to disable rotation entirely |
| --collapse-focus-outline-color | Sets the focus outline color of the trigger |
| --collapse-disabled-opacity | Sets the opacity while disabled |
| --collapse-border-radius | Sets the corner radius of the trigger and panel |
| --collapse-padding | Sets the padding of the trigger, and of the panel unless --collapse-panel-padding is set |
| --collapse-padding-block | Sets the vertical padding, as one length applied to both sides. Ignored when --collapse-padding is set |
| --collapse-padding-inline | Sets the horizontal padding, as one length applied to both sides. Ignored when --collapse-padding is set |
| --collapse-panel-padding | Sets the padding of the panel, falling back to the trigger's padding |
| --collapse-panel-padding-block | Sets the vertical padding of the panel, as one length applied to both sides. Ignored when --collapse-panel-padding or --collapse-padding is set |
| --collapse-panel-padding-inline | Sets the horizontal padding of the panel, as one length applied to both sides. Ignored when --collapse-panel-padding or --collapse-padding is set |
| --collapse-heading-font-size | Sets the font size of the heading |
| --collapse-heading-font-weight | Sets the font weight of the heading |
| --collapse-subheading-font-size | Sets the font size of the subheading |
| --collapse-divider-width | Sets the width of the line between the trigger and the panel. Zero removes it |
| --collapse-divider-color | Sets the color of the line between the trigger and the panel |
| --collapse-gap | Sets the gap between the trigger's leading icon, heading, and actions |
| --collapse-panel-gap | Sets the gap between the trigger and the panel while open, with independent set |
| --collapse-transition-duration | Sets the duration of the open, close, and color transitions |
CSS shadow parts
| Name | Description |
|---|---|
| trigger | The trigger button, for effects its custom properties don't cover, such as an active indicator |
| panel | The panel holding the content, below the trigger |
Basic
<eun-collapse heading="What is your return policy?">
You can return any item within 30 days of delivery for a full refund.
</eun-collapse>
Header
Subheading
<eun-collapse heading="Shipping" subheading="Delivery times and carriers">
...
</eun-collapse>
Leading icon
<eun-collapse heading="Shipping" icon="local_shipping">...</eun-collapse>
Need something other than a eun-icon, or full control over it? Slot your
own non-interactive content into icon instead. It replaces the
prop-driven icon entirely.
<eun-collapse heading="Shipping">
<span
slot="icon"
aria-hidden="true"
style="
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 999px;
background: #148cd2;
color: #fff;
font-size: 12px;
font-weight: bold;
"
>!</span
>
...
</eun-collapse>
Tag
A short label rendered as a default eun-tag in the trigger, e.g. a count
or category.
<eun-collapse heading="Do you ship internationally?" tag="Popular">
...
</eun-collapse>
The tag prop only covers the default look. For a specific
type/color/severity, slot your own eun-tag into tag instead. A
custom element slotted there replaces the prop-driven tag entirely, while a
plain element, such as a span of text, keeps the default tag style.
<eun-collapse heading="Do you ship internationally?">
<eun-tag slot="tag" type="outline" severity="warning" rounded
>Limited</eun-tag
>
...
</eun-collapse>
Trailing indicator
Defaults to a plus sign (add) that rotates 45deg into a cross while open.
A different icon usually needs a different rotation, e.g. a chevron wants
to flip 180deg, not 45deg, so chevron-icon and
--collapse-chevron-rotation are set together.
<eun-collapse
heading="..."
chevron-icon="keyboard_arrow_down"
style="--collapse-chevron-rotation: 180deg;"
>...</eun-collapse
>
For anything rotation can't express, such as swapping the icon itself
rather than turning it, slot your own non-interactive content into
chevron and set the rotation to 0deg to opt out of the built-in
animation entirely.
<eun-collapse heading="..." style="--collapse-chevron-rotation: 0deg;">
<eun-icon slot="chevron" name="keyboard_arrow_down" size="20"></eun-icon>
...
</eun-collapse>
collapse.addEventListener("eunchange", () => {
icon.name = collapse.open ? "keyboard_arrow_up" : "keyboard_arrow_down";
});
Content
Custom content
The default slot accepts anything, not just text. Only the panel's own
padding applies to whatever is slotted in. No margins, typography, or list
styles are ever forced onto it beyond ordinary CSS inheritance
(color/font-family/font-size, which any slotted element can still
override locally). Build the panel body entirely yourself for content
you're authoring by hand, or when rich-text's small Markdown-like subset
isn't enough. See "Rich text" below for the opposite case, a plain string
arriving at runtime rather than markup you write yourself.
<eun-collapse heading="Invite a teammate">
<form>
<input type="email" placeholder="teammate@company.com" />
<button type="submit">Send invite</button>
</form>
</eun-collapse>
Rich text
rich-text accepts a small Markdown-like subset (- item lists,
[label](url) links, **bold**, *italic*) rendered above the default
slot, so both can combine. No HTML string is ever built (no unsafeHTML),
so arbitrary content can never inject markup. It's the same engine (and the
same non-standard "Markdown-lite" syntax) as
formatting toolbar. See
eun-collapse itself, the same reason every multi-paragraph example
on the Markdown guide page above is wired up the same way.
Reach for it specifically when the content is a plain string arriving at runtime that you have no HTML to author for, such as a CMS-driven FAQ answer or a description fetched from an API. When you're writing the content by hand in your own template instead, slot real markup into the default slot directly (see "Custom content" above). It skips the Markdown-lite subset and its limitations entirely, at the cost of writing actual HTML instead of a plain string.
<eun-collapse
id="return-policy"
heading="What is your return policy?"
tag="Popular"
></eun-collapse>
document.querySelector("#return-policy").richText =
"Returns are accepted within **30 days** of delivery.\n\n" +
"Before sending anything back :\n" +
"- Contact [our support team](https://example.com/support) to get a return label\n" +
"- Pack the item securely in its original box";
States
Open by default
<eun-collapse heading="What is your return policy?" open>...</eun-collapse>
Disabled
<eun-collapse heading="What is your return policy?" disabled>...</eun-collapse>
Groups
Several
Each eun-collapse gets its own level="3" here, matching the fact that
these three FAQ questions sit at the same depth in the page's own outline.
See the Accessibility tab for why that's opt-in rather than automatic. Each
instance opens and closes fully on its own. Opening one never closes the
others, since eun-collapse has no concept of a shared group (see
"Exclusive (accordion)" below for linking their open state together
yourself).
<eun-collapse heading="What is your return policy?" level="3">...</eun-collapse>
<eun-collapse heading="How long does shipping take?" level="3"
>...</eun-collapse
>
<eun-collapse heading="Do you ship internationally?" level="3"
>...</eun-collapse
>
Independent
By default the trigger and panel merge into one flush shape (square
corners at the shared edge, a divider line) which reads as "connected" and
suits a single collapse, or a group meant to look like one continuous list
(e.g. the accordion below). Set independent instead when a collapse sits
side by side with others with no shared exclusivity between them, so
the layout doesn't visually suggest a connection that isn't there. It
renders the trigger and panel as two fully rounded, separated boxes with a
gap, and drops the divider.
<eun-collapse heading="What is your return policy?" independent
>...</eun-collapse
>
Exclusive (accordion)
To get the classic "only one open at a time" accordion behavior, listen for
eunchange on each item and close the others whenever one opens. There's
no prop for this, since not every group of collapses wants it. Left without
independent here on purpose: this group behaves as one connected
accordion, so the default flush shape (no gap, no rounded corners at the
shared edge) reads correctly. See "Independent" above for when the opposite
(separated, rounded) shape is the right call instead.
<div id="faq">
<eun-collapse heading="What is your return policy?" level="3"
>...</eun-collapse
>
<eun-collapse heading="How long does shipping take?" level="3"
>...</eun-collapse
>
<eun-collapse heading="Do you ship internationally?" level="3"
>...</eun-collapse
>
</div>
const items = document.querySelectorAll("#faq eun-collapse");
items.forEach((item) => {
item.addEventListener("eunchange", () => {
if (!item.open) return;
items.forEach((other) => {
if (other !== item) other.open = false;
});
});
});
Each item only closes the others when it is the one firing eunchange as
open, reacting to open becoming true rather than reading event.target
inside a shared handler, so a eunchange fired by one item closing (from
this very logic) never cascades into re-closing the others again.
Server-side rendering
grid-template-rows: 0fr)
only ever applies through eun-collapse's own Shadow DOM, which
doesn't exist yet in server-rendered HTML. A collapse meant to
start closed renders with its panel content fully visible first,
then snaps shut the instant the element upgrades.
Link collapse-ssr.css in the page's <head> to draw the final collapse
from the first paint:
<link
rel="stylesheet"
href="node_modules/@eunomia/elements/dist/ssr/collapse-ssr.css"
/>
A bundler that resolves package exports (Vite, webpack, Turbopack, or a
Sass @use/@import) can reach the same file through the package
specifier instead, which survives a hoisted or monorepo install the raw
path above doesn't:
@use "@eunomia/elements/collapse-ssr.css";
It draws the trigger with the heading, subheading, icon and tag
slots, hides the panel of a closed collapse, and shows the panel of one
whose open attribute is written in the server-rendered markup, under its
divider. It covers disabled and the --collapse-* spacing and color
properties, and keeps room for the chevron, which appears with the
upgrade. The text of the heading attribute and an icon set through
icon appear with the upgrade too, so prefer the slots. An open
independent collapse is drawn as a single box until then.
overlay-ssr.css also hides a closed panel, if it's already linked for
other components.
Content indexed by search engines
The heading and rich-text attributes only render in the shadow root, so
the server HTML carries them as attribute values, not as page content.
Crawlers that don't run JavaScript never see that text. When the content
matters for search (an FAQ, for example), pass the question through the
heading slot and the answer through the default slot instead, so both are
part of the server-rendered HTML:
<eun-collapse level="3">
<span slot="heading">How long does a project take?</span>
<p>Usually four to eight weeks, depending on its scope.</p>
</eun-collapse>
The slotted heading still renders inside the level heading element and the
trigger button, exactly like the attribute does.
subheading and tag have slots of their own for the same reason. Text
slotted into subheading keeps the subheading style, and a plain element
slotted into tag keeps the default tag style:
<eun-collapse level="3">
<span slot="heading">Design system lead</span>
<span slot="subheading">2022 to 2024</span>
<span slot="tag">Freelance</span>
<p>Built and rolled out a component library across four products.</p>
</eun-collapse>
Customization
Spacing and typography
--collapse-heading-font-size and --collapse-subheading-font-size set the
size of the heading and the subheading, and --collapse-heading-font-weight
the weight of the heading. The panel uses the trigger's padding
until --collapse-panel-padding gives it its own, for instance to drop the
space above the answer. --collapse-divider-width and
--collapse-divider-color style the line between the trigger and the panel,
and a width of 0 removes it.
<eun-collapse
heading="Do you deliver on Saturdays?"
style="
--collapse-heading-font-size: var(--eun-font-size-m);
--collapse-panel-padding: 0 var(--eun-space-m) var(--eun-space-m);
--collapse-divider-width: 0;
"
>...</eun-collapse
>
Custom colors
Every color is a CSS variable, with independent hooks for the closed, hovered, focused, and open states.
<eun-collapse
heading="What is your return policy?"
style="
--collapse-background: #f4effa;
--collapse-background-hover: #e9def5;
--collapse-background-open: #071621;
--collapse-color: #4a2e83;
--collapse-color-hover: #6b3fc0;
--collapse-color-open: #fff;
--collapse-content-color: #d7d9e0;
"
>...</eun-collapse
>
--collapse-content-color is what keeps the panel's body text readable here.
Without it, the body would inherit the default (dark) text color regardless
of --collapse-background-open going dark, same as any other text sitting
on a background it wasn't styled for.
Full list of custom properties in the API tab.
Parts
The trigger and panel parts reach what the variables don't cover, such
as a line along the panel of a mobile menu's submenu.
eun-collapse::part(panel) {
position: relative;
}
eun-collapse::part(panel)::before {
content: "";
position: absolute;
inset-block: 0 var(--eun-space-m);
left: var(--eun-space-m);
width: 3px;
border-radius: 3px;
background: var(--eun-color-grey-300);
}
The custom properties that change colors, borders, shadows and corners, at a glance. The API tab lists every property of the component.
| Variable | Sets |
|---|---|
--collapse-background |
Sets the background color of the trigger and panel while closed |
--collapse-background-hover |
Sets the background color of the trigger on hover, while closed |
--collapse-background-open |
Sets the background color of the trigger and panel while open |
--collapse-color |
Sets the heading and icon color while closed |
--collapse-color-hover |
Sets the heading and icon color of the trigger on hover, while closed |
--collapse-color-open |
Sets the heading and icon color while open |
--collapse-background-focus |
Sets the background color of the trigger on keyboard focus |
--collapse-color-focus |
Sets the heading and icon color of the trigger on keyboard focus |
--collapse-subheading-color |
Sets the color of the subheading |
--collapse-content-color |
Sets the color of the panel's content. Rich text and slotted content both inherit it by default, and either can still override it locally |
--collapse-rich-text-link-color |
Sets the color of links inside richText, independently of the trigger's open-state color |
--collapse-icon-color |
Sets the color of the leading and chevron icons, independently of the text color |
--collapse-focus-outline-color |
Sets the focus outline color of the trigger |
--collapse-disabled-opacity |
Sets the opacity while disabled |
--collapse-border-radius |
Sets the corner radius of the trigger and panel |
--collapse-divider-width |
Sets the width of the line between the trigger and the panel. Zero removes it |
--collapse-divider-color |
Sets the color of the line between the trigger and the panel |
Keyboard and focus
Keyboard interactions
| Key | Action |
|---|---|
Tab / Shift+Tab |
Moves focus to/from the trigger, like any other button |
Space / Enter |
Toggles the panel open/closed, native <button> behavior |
Tab off the trigger |
If the (still closed) panel has a focusable element, opens it and moves focus straight there, instead of skipping past it |
While collapsed, the panel is
Tab normally skips over its content entirely, exactly as if it weren't
in the page. The one exception is a panel holding at least one focusable
element (a link, a button, a form field): tabbing forward off the trigger
then opens the panel and lands focus on that first focusable element
instead of jumping past it, so keyboard users are never stranded outside
content they'd otherwise have no way to reach at all. Leaving the panel and
trigger entirely afterward, whether by tabbing past the last focusable
element or by tabbing back off the trigger itself, closes the panel again,
but only if it was opened this way. A panel opened by clicking the trigger
stays open no matter where focus goes next, exactly like any other
disclosure. Opening it any other way, through a click or programmatically,
makes it interactive immediately too, with no extra step needed.
Focus management
Closing the panel while focus is on something inside it (e.g. a link in the
rich-text or slotted content) moves focus back to the trigger automatically,
so keyboard focus is never dropped into now-inert, unreachable content,
satisfying
Browser find-in-page
On engines that support it, the browser's own find-in-page (Ctrl+F /
Cmd+F) can match text inside a closed panel and opens it automatically to
show the match, using the same
hidden="until-found"inert exclusion already used while disabled,
so no content ever becomes silently unsearchable-looking without actually
being searchable.
Semantics and screen readers
Trigger semantics
The trigger renders as a real, native <button type="button"> with
aria-expanded reflecting open and aria-controls pointing at the panel,
matching the
aria-controls="panel"
references an id scoped to the component's own shadow root, so it stays
correct and non-colliding even with several eun-collapse instances on the
same page.
Because the panel is genuinely inert, or hidden="until-found" on engines
that support find-in-page reveal (see "Browser find-in-page" above), rather
than merely hidden by CSS, screen readers skip it entirely, in both linear
reading and virtual-cursor/browse-mode navigation, not just for sighted
mouse/keyboard users. This also means the component never puts aria-hidden
on content that could still receive real focus, an anti-pattern most
automated audits (and screen readers) flag.
Accessible name includes the subheading and tag
heading, subheading, and a slotted/prop-driven tag all render as text
inside the trigger <button>, so all of it is folded into the button's
single accessible name. A screen reader announces them together as one
string, not as a heading with a separate description. Keep subheading
short for that reason. If it needs to be read as a distinct piece of
information rather than appended to the name, don't rely on eun-collapse
alone for that distinction.
No accessible name needed on the host
aria-label/aria-labelledby aren't needed on eun-collapse itself. The
trigger's own visible heading text already serves as its accessible name,
so there's nothing extra to wire up.
Heading level is opt-in
Set level (2-6) to wrap the trigger in a real <h2>-<h6> matching your
page's own outline (e.g. several FAQ entries at level="3") so screen
reader users can jump between them the same way they'd jump between any
other headings on the page. Left unset by default, since the component has
no way to know where it sits in your outline. Wrapping it in a heading
unconditionally could just as easily produce a wrong or duplicate level.
Don't slot interactive content into heading/icon/tag/chevron
heading, icon, tag, and chevron all render inside the trigger's own
<button>. A link, another button, or any focusable element slotted into
one of them becomes interactive content nested inside interactive content,
which is invalid per the HTML specification and unreliable across browsers
and assistive technology (most browsers simply make the nested control
unreachable by keyboard, while still visually present). Reserve those slots
for non-interactive content only (icons, eun-tag, plain text or images)
exactly like the built-in fallbacks they replace. Content in the default
slot (the panel body) has no such restriction: it renders inside #panel,
a plain sibling <div>, not inside any button. Real form controls, links,
or any other interactive element are all fine there (see "Custom content" in
the Examples tab). inert still reaches through the slot boundary onto that
content exactly as it does for the rest of the panel. Anything slotted in
is unfocusable and unclickable while closed, and becomes reachable again
immediately once open, with no extra wiring needed on your part.
independent is presentation-only, with one implication worth knowing
independent only ever changes CSS (:host([independent]) selectors). It
adds no ARIA state, no role, and no keyboard behavior, so it's safe to
toggle purely for layout without touching anything else in this page.
The one thing worth knowing goes the other way. The default flush shape
(no independent) visually reads as "these collapses are one connected
group", exactly the cue the "Exclusive (accordion)" example in the
Examples tab relies on, but eun-collapse has no matching structural
grouping of its own (no shared role="group", no <fieldset>). A sighted
user gets that relationship for free from the shape. A screen reader user
navigating by heading or by Tab only gets it if you also wrap the group in
a <fieldset>/role="group" with an accessible label (or rely on
consistent level headings to at least convey they sit at the same depth,
per independent set have no such relationship
to convey in the first place, so nothing extra is needed there.
Disabled state
disabled sets the native disabled attribute directly on the trigger
<button> (not a custom aria-disabled role), so it gets the browser's own
disabled semantics for free: removed from the tab order, unclickable, and
announced as disabled by every screen reader without any extra ARIA. Only
its opacity is themeable via --collapse-disabled-opacity. The underlying
semantics can't be overridden.
Visual and motion
Color contrast
The default background/text combination is built from this design system's
own color tokens, already chosen to meet contrast minimums. A custom
combination of --collapse-background/--collapse-color (closed, hover,
or open) must still be checked against
--collapse-content-color needs the same check,
independently. It controls the panel body's text against
--collapse-background-open, a separate background/foreground pair from
the trigger's own. Overriding one without the other is exactly how the
default "Custom colors" example above would fail contrast on a dark
--collapse-background-open if --collapse-content-color weren't also set.
Reduced motion
Respects prefers-reduced-motion. The open/close height animation, the
chevron rotation, and the color transitions are all skipped entirely for
users who've asked for reduced motion, satisfying
Reference links