Skip to content

Collapse

A row that opens. It holds no state, registers no key handler and measures no heights: <details> owns the toggle, <summary> owns the keyboard and the focus, and the open/close animation is a CSS transition on a pseudo-element the browser hands over.

  • Stable
  • React 19
  • Zero JS
  • Native details
General
Language, region and the login items.
Appearance
Themes, accent colour and text size.
General
Language, region and the login items.
Code
<Collapse title="General">
Language, region and the login items.
</Collapse>
<details class="wayra-collapse" data-variant="boxed" data-marker="chevron">
<summary>General</summary>
<div class="wayra-collapse-content">Language, region and the login items.</div>
</details>

title is the header and becomes the whole content of the <summary>; the children are the panel. There is no Collapse.Title and no Collapse.Content — the element pair already says which is which.

boxed
Language, region and the login items.
plain
Language, region and the login items.
Code
<Collapse title="boxed">…</Collapse>
<Collapse variant="plain" title="plain">…</Collapse>
<details class="wayra-collapse" data-variant="boxed" data-marker="chevron">
<summary>boxed</summary>
<div class="wayra-collapse-content">Language, region and the login items.</div>
</details>
<details class="wayra-collapse" data-variant="boxed" data-marker="chevron" data-variant="plain">
<summary>plain</summary>
<div class="wayra-collapse-content">Language, region and the login items.</div>
</details>

boxed is the settings-group card: hairline, --wayra-radius-box, and the surface painted in base-100. plain drops the box for a dense list whose container draws the separators — which is exactly what Accordion does.

chevron
Language, region and the login items.
plus
Language, region and the login items.
none
Language, region and the login items.
plus, open
Language, region and the login items.
Code
<Collapse title="chevron">…</Collapse>
<Collapse marker="plus" title="plus">…</Collapse>
<Collapse marker="none" title="none">…</Collapse>
<details class="wayra-collapse" data-variant="boxed" data-marker="chevron">
<summary>chevron</summary>
<div class="wayra-collapse-content">Language, region and the login items.</div>
</details>
<details class="wayra-collapse" data-variant="boxed" data-marker="chevron" data-marker="plus">
<summary>plus</summary>
<div class="wayra-collapse-content">Language, region and the login items.</div>
</details>
<details class="wayra-collapse" data-variant="boxed" data-marker="chevron" data-marker="none">
<summary>none</summary>
<div class="wayra-collapse-content">Language, region and the login items.</div>
</details>
<details class="wayra-collapse" data-variant="boxed" data-marker="chevron" data-marker="plus" open>
<summary>plus, open</summary>
<div class="wayra-collapse-content">Language, region and the login items.</div>
</details>

Two pseudo-elements on the summary, so the marker costs no DOM and no icon file. The chevron turns half a circle and the plus loses its upright bar; both move through a single compositable property (rotate, scale), the rule the whole library animates by.

Right to left, the marker moves to the inline end — the left — and the chevron still points down, then up. Its two strokes are drawn on physical sides on purpose: up and down do not mirror, and logical sides would turn the arrow to face along the line.

General
Language, region and the login items.
Code
<Collapse open title="General">…</Collapse>
<Collapse onToggle={(event) => console.log(event.currentTarget.open)}>…</Collapse>
<details class="wayra-collapse" data-variant="boxed" data-marker="chevron" open>
<summary>General</summary>
<div class="wayra-collapse-content">Language, region and the login items.</div>
</details>

These are not library props. open is the DOM attribute, onToggle is the DOM event, and name is what makes a group exclusive — all three come from ComponentProps<"details"> and are passed straight through. That is why there is no defaultOpen, and no onOpen / onClose pair: the element already reports its own state.

The panel does not jump: it grows. The whole thing is CSS, in one of the library’s five @supports blocks.

@supports selector(::details-content) {
.wayra-collapse { interpolate-size: allow-keywords; }
.wayra-collapse::details-content {
block-size: 0;
overflow: hidden;
transition: block-size …, opacity …, content-visibility … allow-discrete;
}
.wayra-collapse[open]::details-content { block-size: auto; }
}

interpolate-size is declared on the component and never on :root: it inherits into the pseudo-element from there, and a library has no business turning on keyword interpolation for the consumer’s whole document.

The class and the data-* attributes are the API. The stylesheet is written against them and the behaviour was the platform’s all along, so hand-authored markup gets the same row and the same keyboard:

Hand-written
No component anywhere.
Code
<Collapse title="Hand-written">No component anywhere.</Collapse>
<details class="wayra-collapse" data-variant="boxed" data-marker="chevron">
<summary>Hand-written</summary>
<div class="wayra-collapse-content">No component anywhere.</div>
</details>

The summary is styled by tag (.wayra-collapse > summary), so there is no class to remember for it; the panel wrapper carries .wayra-collapse-content, and it is a real element rather than ::details-content because the pseudo-element does not exist below the animation tier and the padding has to survive there too. unstyled drops both classes and keeps the data-* attributes.

CollapseProps extends ComponentProps<"details"> minus title, which here is the header rather than the native tooltip.

Prop Type Default Description
variant "boxed" | "plain" "boxed" Card with a hairline, or a bare row.
marker "chevron" | "plus" | "none" "chevron" The state indicator, drawn in CSS.
title ReactNode — The content of the <summary>.
headingLevel 2 | 3 | 4 | 5 | 6 — Wraps the title in a heading, inside the summary.
children ReactNode — The panel.
unstyled boolean false Drops the classes, keeps the data-*.

There is no color: a disclosure organizes content, it does not report state. There is no size either — it takes the width of its container and has one step, like Card.

  • The keyboard is the platform’s. <summary> is in the tab order, and Enter and Space toggle it. The component adds no tabindex, no aria-expanded and no key handler; adding them would only compete with what the browser already announces.
  • headingLevel puts a heading inside the summary, which is where the content model allows one. Screen reader users then reach each section through heading navigation — the thing the accordion pattern actually asks for. Use it whenever several collapses form a group.
  • The focus ring is the library’s, drawn on the summary from styles/states.css, and it falls back to a system outline under forced colours. There, the plus marker’s two bars, which are painted backgrounds that mode would turn into Canvas, take the summary’s system text colour.
  • Reduced motion re-points variables. Every transition in the file reads --wayra-collapse-duration, so prefers-reduced-motion sets it to 0s instead of trying to out-rank the marker rules with transition: none — a media query adds no specificity, and that rule would have lost. The panel then opens at once and the markers turn at once, while the content keeps a 160ms fade of its own: a cross-fade, never a cut.