Eunomia v1.0.0-beta.2
AllAngularReactNext.jsJavaScriptVue
EunomiaSalmonForestVioletOceanGoldFireCustom…
🇬🇧 English🇫🇷 Français

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

eun-icon eun-tag · if using the tag prop
Overview API Examples Accessibility
You can return any item within 30 days of delivery for a full refund, as long as it's unused and in its original packaging.

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.

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-collapse to 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 independent on 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-text for 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/chevron content non-interactive. See the Accessibility tab for why
  • Expecting several eun-collapse to 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 of eunchange wiring needed to get that behavior
  • Slotting a link, button, or other interactive element into icon, tag, or chevron. 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-collapse for 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

NameTypeDefaultDescription
headingstringThe trigger's heading text
subheadingstringOptional secondary text shown under the heading
level2 | 3 | 4 | 5 | 6Wraps 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
iconEunomiaIconNameAn optional leading icon for the trigger. Ignored once the icon slot has content
tagstringAn 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-iconEunomiaIconName'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-textstringLightweight formatted content rendered above the default slot. Supports simple lists, links, bold, and italic text
openbooleanfalseWhether the panel is currently expanded
disabledbooleanfalseWhether the trigger is disabled
independentbooleanfalseRenders 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 Types.

Slots

NameDescription
(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
iconReplaces the leading icon entirely with any content, not just an icon element. Falls back to icon when empty
tagReplaces the trigger's tag entirely, for example with a custom colored tag. Falls back to the plain text tag when empty
chevronReplaces 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

NameTypeDescription
eunchangeChangeEventFired whenever open changes

Every event above follows the same naming convention, covered in Events.

CSS custom properties

NameDescription
--collapse-backgroundSets the background color of the trigger and panel while closed
--collapse-background-hoverSets the background color of the trigger on hover, while closed
--collapse-background-openSets the background color of the trigger and panel while open
--collapse-colorSets the heading and icon color while closed
--collapse-color-hoverSets the heading and icon color of the trigger on hover, while closed
--collapse-color-openSets the heading and icon color while open
--collapse-subheading-colorSets the color of the subheading
--collapse-content-colorSets 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-sizeSets the font size of the panel's content, inherited by richText and slotted content alike
--collapse-rich-text-link-colorSets the color of links inside richText, independently of the trigger's open-state color
--collapse-icon-colorSets the color of the leading and chevron icons, independently of the text color
--collapse-chevron-rotationSets 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-colorSets the focus outline color of the trigger
--collapse-disabled-opacitySets the opacity while disabled
--collapse-border-radiusSets the corner radius of the trigger and panel
--collapse-paddingSets the padding of the trigger and panel
--collapse-gapSets the gap between the trigger's leading icon, heading, and actions
--collapse-panel-gapSets the gap between the trigger and the panel while open, with independent set
--collapse-transition-durationSets the duration of the open, close, and color transitions

Basic

You can return any item within 30 days of delivery for a full refund, as long as it's unused and in its original packaging.
<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

You can return any item within 30 days of delivery for a full refund, as long as it's unused and in its original packaging.
<eun-collapse heading="What is your return policy?" open>...</eun-collapse>

Subheading

Standard shipping takes 3-5 business days ; express shipping takes 1-2 business days.
<eun-collapse heading="Shipping" subheading="Delivery times and carriers">
  ...
</eun-collapse>

Leading icon

Standard shipping takes 3-5 business days ; express shipping takes 1-2 business days.
<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.

Standard shipping takes 3-5 business days ; express shipping takes 1-2 business days.
<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.

Yes, we ship to most countries. International orders may be subject to customs fees.
<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.

Limited Yes, we ship to most countries. International orders may be subject to customs fees.
<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.

You can return any item within 30 days of delivery for a full refund. Standard shipping takes 3-5 business days ; express shipping takes 1-2 business days.
<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.

Yes, we ship to most countries. International orders may be subject to customs fees.
<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

You can return any item within 30 days of delivery for a full refund.
<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 eun-textarea's own formatting toolbar. See Markdown-lite for the full syntax reference. Set it from JavaScript rather than as a plain HTML attribute once it spans more than one line. A blank line inside an HTML tag's attribute value trips up this page's own Markdown-to-HTML build step, not 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

You can return any item within 30 days of delivery for a full refund, as long as it's unused and in its original packaging. Standard shipping takes 3-5 business days ; express shipping takes 1-2 business days. Yes, we ship to most countries. International orders may be subject to customs fees.

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.

You can return any item within 30 days of delivery for a full refund, as long as it's unused and in its original packaging.
<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.

You can return any item within 30 days of delivery for a full refund, as long as it's unused and in its original packaging. Standard shipping takes 3-5 business days ; express shipping takes 1-2 business days. Yes, we ship to most countries. International orders may be subject to customs fees.
<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.

You can return any item within 30 days of delivery for a full refund, as long as it's unused and in its original packaging.
<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 inert, so 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" mechanism browsers use for native disclosure widgets. Fragment navigation to an anchor inside a closed panel reveals it the same way. Engines without that support keep closed panels fully out of find-in-page's reach instead, falling back to the same 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 WAI-ARIA APG Disclosure pattern exactly, and satisfying WCAG 4.1.2 Name, Role, Value without any manual ARIA wiring on your part. 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 WCAG 1.3.1 Info and Relationships). Purely side-by-side collapses with 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 WCAG 2.4.3 Focus Order. If focus is already somewhere else on the page when the panel closes (e.g. closed programmatically, or from a different control entirely), focus is left untouched rather than being redirected.

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 WCAG 1.4.3 Contrast (Minimum). See Color for the tokens that already meet it. --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 WCAG 2.3.3 Animation from Interactions.

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

WAI-ARIA APG: Disclosure Pattern
WCAG 4.1.2: Name, Role, Value
WCAG 1.3.1: Info and Relationships
WCAG 2.4.3: Focus Order
WCAG 1.4.3: Contrast (Minimum)
WCAG 2.3.3: Animation from Interactions
MDN: inert
MDN: hidden="until-found"