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
eun-modaleun-sliding-cardGuidance
- 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 |
| subheading | string | — | Optional secondary text shown under the heading |
| 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. Use the tag slot instead for a custom colored badge |
| 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 |
| icon | Replaces the leading icon entirely with any content, not just an icon element. Falls back to icon when empty |
| tag | Replaces the trigger's tag entirely, for example with a custom colored tag. Falls back to the plain text 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-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 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 |
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>
Open by default
<eun-collapse heading="What is your return policy?" open>...</eun-collapse>
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. It
replaces the prop-driven one entirely.
<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="expand_more"
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="expand_more" size="20"></eun-icon>
...
</eun-collapse>
collapse.addEventListener("eunchange", () => {
icon.name = collapse.open ? "expand_less" : "expand_more";
});
Disabled
<eun-collapse heading="What is your return policy?" disabled>...</eun-collapse>
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";
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.
Custom colors
Every color is a CSS variable, with independent hooks for the closed, hovered, 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.
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.
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.
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.
Don't slot interactive content into icon/tag/chevron
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.
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.
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.
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
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.
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
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.
Reference links