Disclosure
Surfaces
Open in Storybook: Disclosure (opens in a new tab)
Overview
A disclosure shows and hides a block in place. It is details with summary: the browser supplies the role, the name, the expanded state, Enter and Space, and keeps closed content out of the Tab order and the accessibility tree. Script and ARIA only duplicate it.
The rule. details with summary as its first child and only control. No role, no aria-expanded, no aria-controls, no tabindex, no wrapper around the content. Focus stays on the summary when it opens. name on every member makes a one-open group; leave it off where people compare. A hidden marker owes a visible, aria-hidden replacement. Motion only under prefers-reduced-motion: no-preference.
Anatomy
- Disclosure
detailsThe group.
openis the state, andnameon every member makes a one-open group. - Summary
summaryFirst child, the only control and its name.
- Marker
::markerorsvg aria-hiddenThe native marker, or a replacement that changes shape and is hidden from assistive technology.
- Panel
::details-contentThe panel box: its children are direct, with no wrapper.
Usage
Use cases
Seven recipes, each the real component and each working. They are the same two elements: only the name, the layout and the content change.
FAQ
One answer open at a time
Can I change my delivery date?
Yes, until the order is packed. Open the order in your account and choose a new date.
How do I return a machine?
Book a pickup from your account. We collect it within 3 working days, in the original box.
Do capsules expire?
The best-before date is printed on every sleeve. Sealed, they keep for a year.
details name="faq"·summarywith anh3· a markerOpening one closes the others, with no script. Focus stays on the question.
Inline “read more”
A detail folded into the sentence
Grown at 1,800 m on volcanic soil, this Arabica is picked by hand and washed the same dayRead more about the farm
The beans dry on raised beds for twelve days and are turned by hand, which the family calls café de altura.display: inlineondetails,summaryand[open]::details-contentThe name says what opens. Foreign words in the panel get their own
lang.Spoiler · show solution
An answer behind an explicit reveal
Puzzle 4: which capsule has intensity 12?
Show the solution
Ristretto Intenso, the only sleeve printed with a 12.
Closed content is out of view and out of the tree
Nothing leaks to screen readers until it opens.
Filter group
Several groups, open to compare
Intensity
Cup size
Aromatic notes
detailsper group, noname·fieldset aria-labelledbythe summarySeveral stay open so people can compare.
Spec in a card
The spec sheet behind the price
Vertuo Pop
€99.00
Technical specifications
Water tank 0.6 l · 1260 W · 2.4 kg
Cup sizes: 40, 80, 150, 230 ml
No
name· the summary is the only control in the cardCard and disclosure stay separate: no stretched link over the card.
Code or log fold
A long block folded behind a count
Changes in this order (2)
- capsules: 10+ capsules: 12 intensity: 8A monospace panel in
::details-content·insanddelDiff lines with
insanddel; the summary counts the changes.Narrow-screen groups
The groups of a menu on a phone
Tools
One
details[name]per group · native keys onlyThe menu panel’s narrow structure: same data, no arrow keys.
Props
| Prop | Does | Default |
|---|---|---|
title | The name: what opens, the same open and closed. Never “More” | Required |
heading | The name is a heading, inside the summary, so the outline keeps it | false |
level | The level of that heading. Left out, it is the one the page set with DisclosureLevel | 3 |
name | The same on every member: one open at a time. Left out, it is the name of the DisclosureList around it | — |
open | Starts expanded | false |
inline | Folds into the sentence: no marker, and the name is underlined | false |
titleId | The id of the summary, for a fieldset in the panel that names itself by it | — |
children | The panel: direct children, with no wrapper | — |
DisclosureList | Three or more in a row: a ul role="list" of members, with a name for a group and a level for their headings | — |
Example
import { Disclosure, DisclosureList } from '@/components/Disclosure'
<DisclosureList name="faq">
<Disclosure heading title="Can I change my delivery date?">
<p>Yes, until the order is packed.</p>
</Disclosure>
<Disclosure heading title="How do I return a machine?">
<p>Book a pickup from your account.</p>
</Disclosure>
</DisclosureList>Specs
Markup
<details>
<summary>Shipping and returns</summary>
<p>Free delivery from €40. Returns within 30 days, in the original box.</p>
<p>Collection points close at 7 pm.</p> <!-- direct children, no wrapper -->
</details>
<!-- A heading in summary keeps its level in the tree (DisclosureTriangle > heading 3) -->
<details>
<summary><h3>Shipping and returns</h3></summary>
<p>…</p>
</details>
<!-- Three or more in a row: a list, so the count is announced -->
<ul role="list">
<li><details name="faq"><summary>Can I change my delivery date?</summary><p>…</p></details></li>
<li><details name="faq"><summary>How do I return a machine?</summary><p>…</p></details></li>
<li><details name="faq"><summary>Do capsules expire?</summary><p>…</p></details></li>
</ul>Structure
| Rule | Detail |
|---|---|
| Summary | First child, the only control. Text, phrasing content or one h1–h6; the level follows the outline, never the size |
| Nothing interactive in it | A link stays its own focusable link (measured): a second stop inside the one control |
| No ARIA | No role, aria-expanded, aria-controls or tabindex on either element: they duplicate or contradict the browser |
open | In the markup: starts expanded. Toggle the attribute; never hide the children yourself |
| Name | What opens: “Shipping and returns”, never “More”. The same open and closed: the state is announced |
| Lists | Three or more in a row: ul role="list" > li > details |
Groups
The same name on every member: opening one closes the others, and closing the open one leaves none open (measured in Chromium 149 and WebKit 26.4). Exclusive hides one answer to show another, so it is for a FAQ and for the groups of a narrow menu. Leave it off where people compare, such as filters and specs.
Marker
| Rule | Detail |
|---|---|
| Restyle | ::marker for colour and size, or list-style-type |
| Hide | display: flex on the summary, or list-style: none. ::-webkit-details-marker alone no longer hides it in WebKit 26.4 (measured), so it is not needed |
| Replace | A hidden marker owes a visible replacement: nothing else says it expands. aria-hidden="true" and no text; an emoji or a glyph is its own aria-hidden element |
| Animate | From details[open], with transform or rotate. No script |
| Forced colors | Borders, an SVG in currentColor or the native ::marker stay. A marker drawn with box-shadow or background vanishes (measured) |
| Contrast | The marker and the ring 3:1 against their background (1.4.11) |
Panel and motion
details::details-content {
block-size: 0;
padding-block: 0;
padding-inline: 2.5rem 0.5rem; /* the same open and closed */
overflow: clip;
opacity: 0;
}
details[open]::details-content { block-size: auto; padding-block: 0.85rem; opacity: 1; }
details > summary + * { margin-block-start: 0; }
details > :last-child { margin-block-end: 0; }
@media (prefers-reduced-motion: no-preference) {
details { interpolate-size: allow-keywords; }
details::details-content { /* closing */
transition: block-size 0.25s var(--ease), padding-block 0.25s var(--ease),
opacity 0.12s ease-out, content-visibility 0.25s allow-discrete;
}
details[open]::details-content { /* opening */
transition: block-size 0.3s var(--ease), padding-block 0.3s var(--ease),
opacity 0.2s ease-out 0.05s, content-visibility 0.3s allow-discrete;
}
summary .marker { transition: rotate 0.25s var(--ease); }
}
details[open] > summary .marker { rotate: 90deg; }
/* --ease: cubic-bezier(0.4, 0, 0.2, 1) */| Rule | Detail |
|---|---|
Reset block-size on [open] | Without it the closed 0 still applies: the open panel stays clipped (measured: 0px) |
| Only the block padding changes | The padding along the line is the same open and closed. Dropped when it closes, the text jumps wider at the first frame and may re-wrap while the panel is still collapsing (measured) |
| Closing | A little shorter than opening, and the text fades out before the box is gone: a box that shrinks over text slices it line by line. The box and the marker share one curve, without the long tail of ease |
| Opening | The text fades in as the box has room for it |
interpolate-size | Lets block-size animate to auto; without it the height snaps. WebKit 26.4 has ::details-content but not interpolate-size (measured), so it opens without animation, which is fine |
Everything in no-preference | Measured: the transition is 0s under reduce (2.3.3) |
| Focus timing | With the content-visibility transition, the content takes focus about 20 ms after opening; instant under reduce |
| Margins | Zero the block margins of the first and last child: no stray gap. ::details-content > :first-child does not match, so they are selected from the details |
States
| State | Look |
|---|---|
| Closed | The chevron points right, and a 1px divider is under the row |
| Hover | A row tint, only under @media (hover: hover) |
| Focus | The site ring on summary:focus-visible, not clipped: no overflow on details |
| Open | The chevron points down, and the panel is under the summary |
| Open and focus | Focus stays on the summary |
| Forced colors | A currentColor marker, the system ring and an underline |
Tokens
Component tokens
| Part | Property | Token | Value |
|---|---|---|---|
| Summary | Minimum height · padding | — | 44px · 12px 8px |
| Summary | Text colour · weight | ink | #16161a · 600 |
| Marker | Size · stroke | currentColor | 20px · 2px |
| Hover | Fill | fill | #f3f3f5 |
| Divider | Colour · width | line | #d7d7dc · 1px |
| Panel | Text colour · size | ink-soft | #4b4b55 · 15px |
| Panel | Padding | — | 0.85rem · its text lines up with the name |
| Ring | Colour · width | focus-ring-primary | #0066ff · 4px |
| Motion | Opening · closing · marker | — | 0.3s · 0.25s · 0.25s |
Guidelines
Disclosure or something else?
| Need | Use |
|---|---|
| Show or hide a block in place | details |
| One answer open at a time (FAQ) | details with the same name |
| Groups people compare or combine (filters, specs) | details, no name: several stay open |
| A control whose only job is choosing which content shows | One details per option, never radios |
| A trigger apart from its content (a nav item, a table row) | button with aria-expanded and aria-controls: details needs its panel right after summary |
| Commands, a dialog, a tooltip | Not a disclosure: a menu, a dialog, a tooltip |
Controls by scenario and context
Each row is a use case. A disclosure keeps its structure at every size: only spacing and wrapping change.
| Scenario | Desktop · 640 px and up | Smaller viewport · 400–639 px | Zoom 400% · 320 CSS px |
|---|---|---|---|
| FAQ | List of details, name="faq"; summary with h3 and marker | Same; summary full width, 44px tall | Questions wrap; the marker stays at the start; no clipping |
| Read more | Inline summary at the end of the paragraph | Same; wraps with the text | Same; the open panel continues inline |
| Filter groups | Side column of details, all open by default | Above the results, closed by default | Closed by default; one column; checkboxes 24px |
| Spec in a card | Under the price, closed | Same | Card full width; the summary wraps |
| Code fold | The panel scrolls inside if lines are long | Same | Only the pre scrolls sideways (the 1.4.10 exception for code) |
| Sticky header | scroll-margin on the summary = the header height | Same | Sticky header off at 400%; scroll-margin stays |
Accessibility
Keyboard
| Key | Does |
|---|---|
| Tab · Shift+Tab | The summary is the stop. Open content’s controls follow in DOM order; closed content is skipped (measured) |
| Enter · Space | Toggles. Focus stays on the summary, open or closed (measured) |
| Arrows | Nothing: not tabs, not a menu |
| A link to an id inside | Opens the panel (measured in Chromium) |
Focus
| Moment | Where focus goes |
|---|---|
| Open or close | Stays on the summary. The person chose to expand, not to enter |
| A sticky header above | scroll-margin-block-start on the summary equal to the bar, or Tab leaves it hidden (2.4.11) |
| An animated open | The content takes focus about 20 ms after opening: a person cannot press that fast, but a test waits a frame |
Screen readers
From the post, on real devices. The state is always read: never add it to the name.
| Pair | Reads |
|---|---|
| VoiceOver + Safari | name, collapsed, summary |
| VoiceOver + Chrome, Firefox | name, collapsed, disclosure triangle, group |
| NVDA + Chrome, Firefox | name, button, collapsed |
| TalkBack + Chrome | collapsed, name, disclosure triangle |
| TalkBack + Firefox | collapsed, name, button, name, Space |
| Chromium accessibility tree | summary = DisclosureTriangle, expanded true or false (measured) |
Support
| Browser | Checked |
|---|---|
| Chromium | Yes |
| WebKit (Safari) | Yes |
| Firefox | No |
WCAG criteria
| Criterion | Met by |
|---|---|
| 1.3.1 Info and Relationships (A) | Native role and state |
| 1.4.1 Use of Color (A) | The marker changes shape, not only colour |
| 1.4.4 Resize Text (AA) | rem; no fixed height on the panel |
| 1.4.10 Reflow (AA) | No horizontal scroll at 320px; only code may scroll |
| 1.4.11 Non-text Contrast (AA) | The marker and the ring 3:1 |
| 2.1.1 Keyboard (A) | Enter and Space on the summary |
| 2.3.3 Animation from Interactions (AAA) | Motion only under prefers-reduced-motion: no-preference |
| 2.4.7 Focus Visible (AA) | The ring on summary:focus-visible, not clipped by overflow |
| 2.4.11 Focus Not Obscured, Minimum (AA) | scroll-margin on the summary under a sticky bar |
| 2.5.3 Label in Name (A) | The visible text is the name |
| 2.5.8 Target Size, Minimum (AA) | The whole row, at least 24px tall (44px here) |
| 4.1.2 Name, Role, Value (A) | Native; nothing overridden |
Related
This section is still in progress.