Designer view
Disclosure
A single collapsible region with one toggle — a button that expands or collapses an associated content panel. The atomic unit of disclosure: Accordion is a list of disclosures grouped with shared keyboard navigation, but a standalone Disclosure has no peers and no group semantics. Used for "show more" patterns in prose, expandable details rows, optional advanced settings, inline help expansion, and any single named region whose content is disclosable on demand.
Also called Collapse Details
When to use
Use
For a single collapsible region — "show more" patterns in prose, expandable details rows, optional advanced settings, inline help expansion, expandable filter sections. The user toggles the disclosure to reveal or hide content that is not always needed.
Avoid
For a list of disclosures grouped with shared keyboard navigation — that is `Accordion`. For mutually-exclusive parallel views — that is `Tabs`. For navigation between independent pages — that is `SidebarNav`. For blocking content reveals — that is `Modal` or `Drawer`.
Versus related
- accordion
`Accordion` is a list of disclosures grouped as a unit with shared keyboard navigation (ArrowDown / Up between items, Home / End to first / last). `Disclosure` is a single collapsible region with no peers and no group semantics. Accordion-of-one-item is anti-pattern; use Disclosure when there's only one region.
- tabs
`Tabs` always shows one panel at a time, replaces on selection, and has visible peer labels for unselected panels. `Disclosure` shows zero or one panel and has no peers. Tabs are lateral; Disclosure is hierarchical (the trigger is a heading or sentence-fragment in the document outline).
- modal
`Modal` blocks the page and demands explicit response; `Disclosure` is non-blocking and reveals inline. Modal severs spatial relationship to the underlying content; Disclosure preserves it.
Disclosure is the smallest collapsible-region pattern in the canon — a single trigger that expands or collapses one associated panel. Standalone Disclosure has no peers and no group semantics; Accordion is conceptually a list of Disclosures with shared keyboard navigation. The pattern lives in show-more sections, expandable detail rows, optional advanced settings, and inline help. The reference documents the three-slot anatomy, the aria-expanded contract that drives both visual and assistive-tech state, and the anchor-link-into-collapsed-content edge case.
Implementations
How specific libraries realise the canonical anatomy. Each entry records the deltas between the canon and the library's surface.
Disclosure / DisclosureButton / DisclosurePanel import { Disclosure, DisclosureButton, DisclosurePanel,} from '@headlessui/react';import { ChevronDownIcon } from '@heroicons/react/20/solid';
function ShippingDetails() { return ( <Disclosure defaultOpen={false}> <DisclosureButton className="flex w-full items-center justify-between py-2 text-left"> <span>Shipping details</span> <ChevronDownIcon className="size-5 data-[open]:rotate-180 transition-transform" /> </DisclosureButton> <DisclosurePanel className="mt-2 text-sm text-gray-600"> Free standard shipping on orders over $50. Delivery in 3–5 business days. </DisclosurePanel> </Disclosure> );}Divergence
| From | Type | → To | Rationale |
|---|---|---|---|
anatomy[icon] | omitted | — | Headless UI ships no icon or chevron slot. The icon is entirely consumer-composed — typically a `<ChevronDownIcon>` (from @heroicons/react) placed as a child of `DisclosureButton`. Rotation on expand is consumer CSS driven by the `data-open` attribute that `DisclosureButton` emits when the disclosure is open (e.g. `data-[open]:rotate-180`). This matches Headless UI's unstyled-by-design philosophy applied consistently across all its primitives. Source: https://headlessui.com/react/disclosure (example: "Showing and hiding the panel"). |
anatomy[panel] | reshaped | DisclosurePanel (div by default) with optional CloseButton / useClose for imperative close | The canonical panel slot is a single region controlled by `aria-expanded` on the trigger plus `aria-labelledby` referencing the trigger. Headless UI's `DisclosurePanel` automatically receives an `id` that is referenced by `aria-controls` on `DisclosureButton`, and is shown/hidden by toggling its presence in the DOM (unmount=true, default) or via `hidden` (unmount=false). However, no `aria-labelledby` is applied to `DisclosurePanel` — the panel carries no `role="region"` or labelling by default. The panel is extended with two imperative-close affordances not present in the canonical: `CloseButton` (a `<button>` that closes the nearest ancestor disclosure when clicked) and the `useClose()` hook (closes imperatively from any descendant component). The `transition` boolean prop (default false) enables CSS transition data attributes (`data-closed`, `data-enter`, `data-leave`) on `DisclosurePanel` for CSS-driven open/close animations. The `static` prop (default false) bypasses internal state management for external control. The `unmount` prop (default true) switches between DOM removal and `hidden` attribute toggling. Source: https://headlessui.com/react/disclosure (DisclosurePanel props table; CloseButton section; useClose section). |
axes.variants[inline] | omitted | — | Headless UI ships no variant system. The inline vs standalone distinction — which in the canonical drives heading-wrapping and density defaults — is a design-system concern. Headless UI is unstyled; consumers apply heading wrappers and spacing independently. The `data-open` / `data-closed` state attributes on `DisclosureButton` and `DisclosurePanel` are the styling hooks. Source: https://headlessui.com/react/disclosure. |
axes.variants[standalone] | omitted | — | Same reason as the inline variant — Headless UI has no variant prop on `Disclosure`. All visual distinctions (standalone block style vs inline link style) are consumer CSS. Source: https://headlessui.com/react/disclosure. |
axes.properties[defaultExpanded] | renamed | defaultOpen | The canonical boolean prop is `defaultExpanded`; Headless UI names it `defaultOpen` (default `false`) on the root `Disclosure` component. The semantics are identical — uncontrolled initial open state — but the naming follows Headless UI's consistent `open`/`defaultOpen` vocabulary across all disclosure-pattern primitives (Popover, Menu, etc.). Source: https://headlessui.com/react/disclosure (Disclosure props table: "defaultOpen"). |
axes.properties[density] | omitted | — | No density prop. Headless UI is unstyled; padding and spacing inside `DisclosureButton` and `DisclosurePanel` are consumer CSS. Canonical density (comfortable / compact) is a design- system concern above the accessibility primitive. Source: https://headlessui.com/react/disclosure. |
axes.properties[hasIcon] | omitted | — | No `hasIcon` boolean. Whether an icon appears is purely a consumer composition choice — the icon is not managed by the Headless UI primitive at all (see anatomy[icon] divergence). Source: https://headlessui.com/react/disclosure. |
axes.states.data[expanding] | omitted | — | Headless UI does not expose an intermediate `expanding` state. The `transition` prop on `DisclosurePanel` emits `data-enter` and `data-leave` during CSS transitions, but these are not a discrete `expanding` machine state — they are CSS class- triggering hooks for the transition primitive, not states the component tracks in its own reducer. Without `transition={true}` on `DisclosurePanel`, the panel mounts/unmounts immediately with no intermediate state. Source: https://headlessui.com/react/disclosure (section: "Transitions"). |
axes.states.data[collapsing] | omitted | — | Same as the `expanding` omission. No `collapsing` intermediate state is surfaced. `data-leave` on `DisclosurePanel` (when `transition={true}`) signals the leave phase for CSS, but Headless UI's internal state is binary: open or closed. Source: https://headlessui.com/react/disclosure (section: "Transitions"). |
events[openChange] | omitted | — | Headless UI's `Disclosure` ships no `onOpenChange` or `onChange` callback prop. The only imperative close surface is the `close(ref?)` function exposed via the `Disclosure` render prop (and mirrored through `useClose()`). There is no open-state event bus; consumers needing to react to open/close must either use the `open` render-prop boolean reactively or wrap the primitive in their own state and toggle it via `CloseButton` / `useClose`. This is the sharpest design divergence from the canonical `openChange(boolean)` contract. Source: https://headlessui.com/react/disclosure (Disclosure render props: "open", "close"; no onChange in Disclosure props table). |
anatomy[trigger] | extended | + CloseButton component and useClose() hook close the disclosure imperatively from any descendant | The canonical trigger slot is a toggle-only button — it opens and closes the disclosure. Headless UI adds two imperative- close affordances that the canonical does not model: (1) `CloseButton` — a `<button>` (or any `as=` element) that, when clicked, closes the nearest `Disclosure` ancestor and refocuses `DisclosureButton`; (2) `useClose()` — a React hook that returns the same close function for use in arbitrary descendant components. These are useful for "done" or "dismiss" actions inside a panel that should collapse the disclosure. The canonical trigger-only model does not account for in-panel close affordances. Source: https://headlessui.com/react/disclosure (sections: "Closing disclosures", "Using the useClose hook"). |
Why this audit reads the way it does
Headless UI React's Disclosure is the closest library match to the canonical Disclosure by naming (it ships `Disclosure` / `DisclosureButton` / `DisclosurePanel` rather than Radix's `Collapsible.*`). The divergences are fewer and less structural than the modal audit but follow the same library-level pattern: 1. Unstyled by design. Variants (inline / standalone), density, and the icon slot are all omitted — styling is consumer CSS driven by `data-open` / `data-closed` attributes. 2. Uncontrolled-only root. `Disclosure` only ships `defaultOpen`; there is no controlled `open` + `onOpenChange` pair. Consumers needing controlled state must lift state up and use `static={true}` on `DisclosurePanel` or rely on `useClose()` for imperative close. 3. No intermediate animation states. Canonical models four states (closed / expanding / expanded / collapsing). Headless UI's binary open/closed state plus `data-enter` / `data-leave` on `DisclosurePanel` (when `transition={true}`) provides animation hooks without discrete intermediate states in the component's own reducer. 4. No openChange callback. The absence of a boolean change event is the sharpest API gap. In-panel close is handled instead via the extended `CloseButton` / `useClose()` affordances — a pragmatic workaround but not equivalent to an event-driven open/close contract. 5. aria-labelledby gap. Canonical requires the panel to carry `aria-labelledby` referencing the trigger. Headless UI wires only `aria-controls` (button → panel) but not the reverse (`aria-labelledby` on the panel). Consumers applying `role="region"` to `DisclosurePanel` must add `aria-labelledby` manually.
Collapsible import * as Collapsible from '@radix-ui/react-collapsible';
<Collapsible.Root defaultOpen={false} onOpenChange={setOpen}> <Collapsible.Trigger>Toggle</Collapsible.Trigger> <Collapsible.Content> {/* panel content */} </Collapsible.Content></Collapsible.Root>Divergence
| From | Type | → To | Rationale |
|---|---|---|---|
anatomy[trigger] | renamed | Collapsible.Trigger | One-to-one role match. Collapsible.Trigger renders a `<button>` with `aria-expanded` and `aria-controls` wired automatically to the content region's generated id. The canonical slot contract is fully honoured; only the export name differs. Source: https://github.com/radix-ui/primitives/blob/main/packages/react/collapsible/src/collapsible.tsx |
anatomy[icon] | omitted | — | Collapsible ships no icon or chevron slot. The canonical icon is a decorative expansion-direction glyph; Radix leaves icon placement entirely to the consumer (typically an SVG inside Collapsible.Trigger). The `data-state="open|closed"` attribute on both Root and Content allows consumers to rotate or swap an icon via CSS without Radix owning the slot. Source: https://www.radix-ui.com/primitives/docs/components/collapsible |
anatomy[panel] | renamed | Collapsible.Content | Same purpose: the collapsible content region. Collapsible.Content receives `id={context.contentId}` (referenced by Trigger's `aria-controls`) and toggles visibility via a `hidden` attribute when closed. The canonical slot name "panel" is replaced by "Content" in Radix's naming scheme. Source: https://github.com/radix-ui/primitives/blob/main/packages/react/collapsible/src/collapsible.tsx |
axes.variants[inline] | omitted | — | Radix Collapsible has no variant prop and makes no structural distinction between inline and standalone disclosure. The difference (whether the trigger is heading-wrapped) is a consumer concern layered above the primitive. Radix's unstyled, composable model intentionally omits variant classification. Source: https://www.radix-ui.com/primitives/docs/components/collapsible |
axes.variants[standalone] | omitted | — | Same as inline — no variant axis exists on Collapsible.Root. Standalone heading-wrapping and inline prose placement are consumer composition choices outside Radix's scope. Source: https://www.radix-ui.com/primitives/docs/components/collapsible |
axes.properties[defaultExpanded] | renamed | defaultOpen | Identical semantics: the uncontrolled initial open state. Radix uses `defaultOpen` (matching React's convention of `default` prefix for uncontrolled defaults and aligning with the `open` controlled-prop name). The canonical uses `defaultExpanded` to align with ARIA terminology (`aria-expanded`). Both represent the same boolean gate. Source: https://www.radix-ui.com/primitives/docs/components/collapsible |
axes.properties[density] | omitted | — | Radix ships no density prop. Comfortable / compact spacing is a design-system concern layered above the unstyled primitive; consumers apply it via className or CSS custom properties. Radix intentionally owns no spacing tokens. Source: https://www.radix-ui.com/primitives/docs/components/collapsible |
axes.properties[hasIcon] | omitted | — | Radix has no hasIcon prop because it has no icon slot. Whether a chevron or plus-minus glyph appears is purely consumer markup inside Collapsible.Trigger. Radix's `data-state` attribute is the hook for icon-rotation CSS. Source: https://www.radix-ui.com/primitives/docs/components/collapsible |
axes.properties[defaultExpanded] | extended | + open: boolean (controlled) + disabled: boolean + forceMount: true on Collapsible.Content | Radix adds three properties the canon does not model as first-class. `open` enables fully controlled usage (paired with `onOpenChange`), following the React controlled/uncontrolled pattern. `disabled` on Root propagates `data-disabled` to Trigger and Content, blocking interaction without removing from the DOM. `forceMount` on Content keeps the DOM subtree mounted even when closed, required for exit-animation libraries (Framer Motion, GSAP) that need the element to exist during the closing animation — canonical motion assumes a `[hidden]` toggle, which Radix also uses by default. Source: https://www.radix-ui.com/primitives/docs/components/collapsible |
axes.states.transitions | reshaped | data-state: open | closed on Root and Content; no expanding/collapsing intermediate states | The canonical state graph has four data states (closed, expanding, expanded, collapsing) representing the animation lifecycle. Radix Collapsible exposes only two: `open` and `closed` via the `data-state` attribute. Intermediate states (expanding, collapsing) are not surfaced as named states — consumers drive animation through CSS transitions on `[data-state]` attribute changes, with `--radix-collapsible-content-width` and `--radix-collapsible-content-height` CSS custom properties enabling height/width animations. The full four-state lifecycle exists at the CSS layer but is not observable programmatically. Source: https://github.com/radix-ui/primitives/blob/main/packages/react/collapsible/src/collapsible.tsx |
events[openChange] | renamed | onOpenChange (Collapsible.Root prop) | One-to-one match in semantics and payload: `onOpenChange(open: boolean)` fires when the open state changes. The canonical event name follows a framework-neutral camelCase convention (`openChange`); Radix places it as a React prop with the `on` prefix on Root, per React's synthetic event naming convention. Source: https://www.radix-ui.com/primitives/docs/components/collapsible |
motion.durations | reshaped | --radix-collapsible-content-width and --radix-collapsible-content-height CSS custom properties on Collapsible.Content | Radix ships no duration or easing tokens. Instead, Collapsible.Content exposes two CSS custom properties — `--radix-collapsible-content-width` and `--radix-collapsible-content-height` — that reflect the measured dimensions during open/close transitions. Consumers write their own CSS keyframes that animate from `0` to these custom property values, supplying their own duration tokens. The canonical `motion.duration.base` / `motion.duration.fast` are achievable but entirely consumer-side. Source: https://www.radix-ui.com/primitives/docs/components/collapsible |
motion.reducedMotionFallback | omitted | — | Radix does not implement a reduced-motion fallback. The canonical `instant` fallback (skip animation under `prefers-reduced-motion: reduce`) must be authored by the consumer via a `@media (prefers-reduced-motion: reduce)` rule that zeroes the transition duration. Radix's `data-state` toggles unconditionally regardless of motion preference. Source: https://www.radix-ui.com/primitives/docs/components/collapsible |
Why this audit reads the way it does
Radix Collapsible is the closest Radix primitive to the canonical Disclosure. The accessibility contract is fully implemented: Collapsible.Trigger ships a real `<button>` with `aria-expanded` and `aria-controls` auto-wired; the content region carries the matching `id`. The ARIA Disclosure pattern (W3C APG) is honoured at the core. Divergence is concentrated in three areas. First, the naming layer: Radix calls this component Collapsible (not Disclosure), calls the state prop `defaultOpen` (not `defaultExpanded`), and uses `onOpenChange` for the event. Second, the variant and styling surface: Radix deliberately ships nothing — no icon slot, no density, no variant classification — because these are design-system concerns above the primitive. Third, the motion surface: Radix replaces the canonical four-state transition graph with a two-state `data-state` attribute plus CSS custom properties for dimension-based animations, offloading all timing and easing to the consumer. The overall pattern matches other Radix audits: the primitive owns the accessibility wiring; design-system specifics (variants, density, motion timing, icon composition) are a layer that consumers compose above Radix.
Disclosure import { Disclosure, DisclosureHeader, DisclosurePanel, Button,} from 'react-aria-components';
// Uncontrolled, starts collapsed<Disclosure> <DisclosureHeader> <Button slot="trigger"> Show shipping details <ChevronRight aria-hidden /> </Button> </DisclosureHeader> <DisclosurePanel> <p>Estimated delivery: 3–5 business days.</p> </DisclosurePanel></Disclosure>
// Controlled with defaultExpanded<Disclosure defaultExpanded onExpandedChange={(open) => setOpen(open)}> <DisclosureHeader> <Button slot="trigger">Advanced settings</Button> </DisclosureHeader> <DisclosurePanel role="region"> {/* panel content */} </DisclosurePanel></Disclosure>
// Inside a DisclosureGroup (accordion behaviour)<DisclosureGroup defaultExpandedKeys={['shipping']}> <Disclosure id="shipping"> <DisclosureHeader><Button slot="trigger">Shipping</Button></DisclosureHeader> <DisclosurePanel>{/* … */}</DisclosurePanel> </Disclosure> <Disclosure id="returns"> <DisclosureHeader><Button slot="trigger">Returns</Button></DisclosureHeader> <DisclosurePanel>{/* … */}</DisclosurePanel> </Disclosure></DisclosureGroup>Divergence
| From | Type | → To | Rationale |
|---|---|---|---|
anatomy[trigger] | reshaped | DisclosureHeader wrapping <Button slot="trigger"> | The canonical `trigger` slot is a single named slot on the component (e.g. `slot="trigger"`). React Aria splits this into two layers: `DisclosureHeader` (a structural heading wrapper that renders a `<h3>` by default) and an inner `<Button slot="trigger">` child. The heading wrapper is optional but idiomatic — the `slot="trigger"` attribute on the Button is what wires the press handler and `aria-expanded`; the `DisclosureHeader` wrapper provides optional heading semantics around it. Consumers who don't need heading semantics can omit `DisclosureHeader` and place `<Button slot="trigger">` directly inside `<Disclosure>`. Source: https://react-aria.adobe.com/Disclosure.html (fetched 2026-05-05) |
anatomy[icon] | reshaped | Consumer-composed children inside <Button slot="trigger"> | The canonical `icon` slot is a discrete anatomy slot for the chevron or plus-minus glyph, placed and managed by the component. React Aria has no dedicated icon slot — icons are composed as children inside the trigger Button (e.g. `<ChevronRight aria-hidden />`). Rotation on expand is the consumer's CSS responsibility via `[data-expanded]` on the parent Disclosure or `[data-expanded]` on the button. This matches React Aria's philosophy: the library owns behaviour and ARIA; visual composition is consumer-defined. Source: https://react-aria.adobe.com/Disclosure.html (fetched 2026-05-05) |
anatomy[panel] | reshaped | DisclosurePanel with role defaulting to 'group' not 'region' | The canonical panel calls for `role="region"` on standalone disclosures with regional content, citing APG and the requirement that SR users can navigate to the region by landmark. React Aria's `DisclosurePanel` defaults `role` to `'group'`, not `'region'`. The `'group'` role does not create a landmark; SR users cannot jump to it via landmark navigation. Consumers must pass `role="region"` explicitly when regional semantics are needed. The choice of `'group'` default is intentional — `role="region"` requires an accessible name and creates a landmark that can clutter the landmark tree on pages with many disclosures; `'group'` is lighter and avoids landmark pollution for inline disclosure patterns. Source: https://github.com/adobe/react-spectrum/blob/main/packages/react-aria-components/src/Disclosure.tsx (fetched 2026-05-05) |
axes.variants[inline] | omitted | — | React Aria Disclosure has no `variant` prop and no `inline` / `standalone` distinction. The library is an unstyled primitive; variant-level visual treatment is entirely the consumer's CSS concern. The heading-wrapping distinction that drives the canonical `standalone` variant (trigger inside a heading) is available via `DisclosureHeader`, but it is not coupled to a variant enum — consumers opt in by including or excluding `DisclosureHeader`. Source: https://react-aria.adobe.com/Disclosure.html (fetched 2026-05-05) |
axes.variants[standalone] | omitted | — | Same as `inline` — no `variant` prop. The `standalone` visual distinction is irrelevant to the unstyled primitive; heading semantics are provided by `DisclosureHeader` which wraps the trigger in a heading element, but this is not a named variant. Source: https://react-aria.adobe.com/Disclosure.html (fetched 2026-05-05) |
axes.properties[defaultExpanded] | extended | + React Aria pairs `defaultExpanded` (uncontrolled) with `isExpanded` (controlled) and `onExpandedChange` (change handler). The canonical property axis documents only `defaultExpanded`; React Aria adds the fully-controlled pattern via `isExpanded` boolean prop. This is a React-idiomatic controlled/uncontrolled pattern the canon does not specify at the property-axis level. | The canonical `defaultExpanded` property is present in React Aria under the same name. React Aria extends it with `isExpanded` for controlled state — consumers can fully own the open/closed state from outside the component, matching the React controlled-component pattern. Source: https://react-aria.adobe.com/Disclosure.html (fetched 2026-05-05) |
axes.properties[density] | omitted | — | React Aria Disclosure has no `density` prop. Spacing is fully the consumer's CSS concern; the library renders a structural wrapper with no padding or spacing opinions. The canonical `density` axis (comfortable / compact) maps to consumer class names or CSS custom properties. Source: https://react-aria.adobe.com/Disclosure.html (fetched 2026-05-05) |
axes.properties[hasIcon] | omitted | — | No `hasIcon` prop. The presence or absence of a chevron icon is a consumer decision made by including or excluding an icon element inside `<Button slot="trigger">` children. The library places no constraint on trigger children. Source: https://react-aria.adobe.com/Disclosure.html (fetched 2026-05-05) |
axes.states.data[expanding] | omitted | — | React Aria does not expose an `expanding` intermediate state. The Disclosure transitions directly from closed to expanded (`data-expanded` appears); the expanding animation duration is the consumer's CSS concern driven by `--disclosure-panel-height` CSS custom property on `DisclosurePanel`. The canonical `expanding` / `collapsing` intermediate states (for `data-state`) have no equivalent data attribute in React Aria. Source: https://github.com/adobe/react-spectrum/blob/main/packages/react-aria-components/src/Disclosure.tsx (fetched 2026-05-05) |
axes.states.data[collapsing] | omitted | — | Same as `expanding` — no `collapsing` intermediate state attribute. The `data-expanded` attribute is removed immediately when the trigger is activated to close; consumers animate collapse by detecting when `data-expanded` disappears and using the `--disclosure-panel-height` CSS variable in a CSS transition. There is no animation-completion callback or intermediate attribute. Source: https://github.com/adobe/react-spectrum/blob/main/packages/react-aria-components/src/Disclosure.tsx (fetched 2026-05-05) |
events[openChange] | renamed | onExpandedChange | The canonical event is named `openChange`; React Aria names it `onExpandedChange`. Both fire with a boolean payload (`true` when expanded, `false` when collapsed). The semantic difference is naming only: `onExpandedChange` aligns with React Aria's vocabulary used consistently across other disclosure-like components (`DisclosureGroup` also uses `onExpandedChange`). The canonical `open` / `closed` vocabulary is replaced by `isExpanded` / `onExpandedChange` throughout the React Aria API surface. Source: https://react-aria.adobe.com/Disclosure.html (fetched 2026-05-05) |
anatomy[panel] | extended | + `DisclosurePanel` exposes two CSS custom properties: `--disclosure-panel-width` (intrinsic width) and `--disclosure-panel-height` (intrinsic height). These enable height/width-based CSS transitions without JavaScript measurement. The canonical panel anatomy has no equivalent mechanism — canon does not specify CSS custom properties on the panel. This is a production-grade animation primitive that fills the gap left by the omission of `expanding` / `collapsing` intermediate states. | CSS height transitions from `0` to `auto` are not directly possible in CSS without JavaScript measurement. React Aria solves this by exposing the panel's intrinsic height as a CSS variable updated before each transition, enabling pure-CSS `height: 0 → var(--disclosure-panel-height)` animations. The canonical motion model specifies duration tokens but does not address the `0 → auto` height animation problem. Source: https://react-aria.adobe.com/Disclosure.html (fetched 2026-05-05) |
axes.variants[standalone] | extended | + React Aria ships `DisclosureGroup` — a container that groups multiple `<Disclosure>` components with shared expanded-key state (`expandedKeys`, `defaultExpandedKeys`, `allowsMultipleExpanded`, `onExpandedChange`, `isDisabled`). This enables accordion behaviour (multiple disclosures with optionally mutually-exclusive expansion) without a separate Accordion component. The canonical disclosure anatomy has no equivalent grouping primitive — the canon separates Accordion from Disclosure as distinct components. | React Aria deliberately collapses the canonical Disclosure / Accordion boundary: a single-item Disclosure is `<Disclosure>`; an accordion is `<DisclosureGroup>` wrapping multiple `<Disclosure>` items. This avoids a separate Accordion component in the library. The canonical model treats these as distinct components with distinct anatomy and ARIA roles; React Aria unifies them via the group wrapper. Consumers who need APG Accordion keyboard navigation (Arrow keys between triggers) must implement it on top of `DisclosureGroup`, as it provides only shared-state management, not group keyboard navigation. Source: https://react-aria.adobe.com/DisclosureGroup.html (fetched 2026-05-05) |
Why this audit reads the way it does
React Aria Disclosure is a behaviour-first primitive that wires the aria-expanded / aria-controls / aria-labelledby contract and provides controlled + uncontrolled state management, leaving all visual composition (icon placement, density, variant styling, animation timing) to the consumer. The deepest structural divergences are: 1. The canonical `trigger` slot is split across two React Aria layers: an optional `DisclosureHeader` wrapper (heading semantics) and a mandatory `<Button slot="trigger">` (ARIA wiring). Consumers opt into heading semantics explicitly. 2. `DisclosurePanel` defaults `role="group"` rather than `role="region"`. This avoids landmark pollution on pages with many disclosures but means consumers must opt into landmark semantics for regional content. 3. The canonical `expanding` / `collapsing` intermediate states are absent; React Aria substitutes CSS custom properties (`--disclosure-panel-height`) to enable pure-CSS height transitions, shifting animation-state responsibility to consumer CSS. 4. `DisclosureGroup` collapses the canonical Accordion component into a grouping layer on top of Disclosure, unifying the two separate canon components into one composable primitive.
Figma anatomy
| Slot | Figma type | Hint |
|---|---|---|
trigger | instance | Button instance; visual treatment varies by variant (inline link-style, standalone block) |
icon | instance | Icon component instance; rotation or swap bound to expanded state |
panel | frame | Auto-layout vertical frame; visibility bound to expanded state |
Token usage per slot
trigger- spacing
- padding
spacing.compact - gap
spacing.compact
- padding
- color
- foreground
color.text.accent - ring
color.border.focus
- foreground
- typography
- size
text.md - weight
weight.medium
- size
icon- color
- foreground
color.text.muted
- foreground
panel- spacing
- padding
spacing.compact
- padding
- color
- foreground
color.text.primary
- foreground
- typography
- size
text.md - lineHeight
leading.normal
- size
Figma ↔ Code property map
| Figma | Kind | Code | Notes |
|---|---|---|---|
Variant | Enum | variant | Maps inline / standalone. Drives heading-wrapping (standalone wraps trigger in heading; inline does not). |
Default Expanded | Boolean | defaultExpanded | Initial open state. False for "show more" patterns; true for content that should be visible by default but collapsible for users who want to reduce noise. |
Density | Enum | density | comfortable / compact. At and below `breakpoint.sm` density compact is the canonical default for inline variant. |
Has Icon | Boolean | hasIcon | Toggles the chevron / plus-minus icon. Default true; rare to disable. |
Trigger Label | Text | triggerLabel | The button's accessible name. "Show more" / "Read details" / heading text per use case. |
Panel Content | Slot | children | Swap the panel content slot — prose, form, list, custom layout. |
Icon | Slot | icon | Swap the chevron glyph; defaults vary per design system. |
Motion
| Transition | Duration token |
|---|---|
expand | motion.duration.base |
collapse | motion.duration.base |
chevronRotate | motion.duration.fast |
Responsive behaviour
| Breakpoint | Change |
|---|---|
breakpoint.sm | At and below, density compact becomes the canonical default for inline-variant disclosures (narrow viewports cannot accommodate comfortable density inline). Standalone-variant disclosures retain comfortable density unless explicitly authored as compact. |
breakpoint.md | Above this width, density and variant render as authored. No layout transformation. |
Internationalisation
RTL · mirroring
Trigger inline-content order reverses logically — the icon moves from inline-end (visual right in LTR) to inline-end (visual left in RTL) via logical positioning. Panel content inherits document direction. Chevron rotation is direction-neutral (down/up arrows are symmetric); plus-minus glyphs are also direction-neutral. Standalone-variant heading-wrapping is direction-neutral.
Text expansion
Trigger label wraps to additional lines under heavy expansion (DE / RU / FI). Panel content follows its own text-flow. Density compact risks crowding long-text trigger labels; density comfortable is the safer default in long-text locales. Multi-line trigger labels behave correctly with the icon — chevron aligns with the first line by canonical convention.
Variants, properties, states
Variants
Structurally different versions of the component.
inline standalone Properties
The same component, parameterised.
| Property | Type |
|---|---|
defaultExpanded | boolean |
density | comfortable | compact |
hasIcon | boolean |
States
Browser/user-driven (interactive) vs. app-driven (data).
| Kind | States |
|---|---|
interactive | hoverfocus-visibleactivedisabled |
data | closedexpandingexpandedcollapsing |
State transitions
| From | To | Trigger |
|---|---|---|
closed | expanding | User activates the trigger (Enter / Space / click). `aria-expanded` flips to true; the panel begins its enter animation. |
expanding | expanded | The expand animation completes (or, under prefers-reduced-motion reduce, immediately). The panel is fully visible and reachable by keyboard via Tab. |
expanded | collapsing | User activates the trigger again (toggle is symmetric on Disclosure — unlike single-mode Accordion which may have non-collapsible behaviour). |
collapsing | closed | The collapse animation completes (or immediately under reduced motion). The panel is removed from the accessibility tree and from keyboard tab order via `hidden` or `display: none`. |
Figma↔Code mismatches
- 01 Figma
A "Show more" link drawn for inline disclosure in body prose
CodeA `<button>` toggling visibility of inline content
ConsequenceDesigners may use link-styling for "Show more" affordances (looks like other inline links in prose). Implementations following the Figma file ship `<a>` with click handlers — semantically wrong (anchors navigate, disclosures toggle), breaks middle-click expectations, and SR users hear "link" with no toggle-state cue.
CorrectUse `<button>` with disclosure semantics (`aria-expanded`, `aria-controls`). Visual styling may converge with link styling (underlined, accent colour) but the underlying element is a button. Document the visual-vs-semantic distinction in mismatches.
- 02 Figma
Disclosure drawn with chevron rotation but no panel-height transition
CodeBoth chevron rotation AND panel height-transition animate together
ConsequenceDesigners animate the chevron in mocks but the panel appears instantly (Figma cannot easily mock smooth height-transitions). Developers shipping faithful-to-mock get jumpy panel transitions; SR users get no announcement while the visible content shifts.
CorrectBoth the chevron rotation and the panel height-transition share the same duration token. Implementations using `[hidden]` toggle without animation are valid for `prefers-reduced-motion: reduce` (the canonical reducedMotionFallback is `instant`).
- 03 Figma
Standalone disclosure drawn without heading semantics
CodeStandalone disclosure with the trigger wrapped in a heading element
ConsequenceDesigners draw a "section" with a click-to-expand affordance but treat it as plain UI rather than as a document structural element. Implementations skip the heading wrapper; SR users navigating by heading miss the collapsible region's existence.
CorrectFor standalone disclosures representing document content regions (an FAQ entry, a settings section, a CSV import step), wrap the trigger in a heading element of the appropriate level. For inline disclosures in prose ("Show more" inside a paragraph), the trigger is not heading- wrapped. Document the variant-driven structural choice.
- 04 Figma
Disclosure used for a single Accordion-of-one-item
CodeA standalone Disclosure component, not Accordion
ConsequenceDesigners compose an Accordion with a single item for visual consistency with multi-item accordions elsewhere on the page. Developers ship an Accordion with one item; the user sees a collapsible region but the surrounding ARIA structure is wrong (no list semantics for a single item, no shared keyboard navigation that Accordion provides for multiple items).
CorrectA single collapsible region is a Disclosure, not an Accordion-of-one. Use Disclosure when there's only one collapsible region; use Accordion when there are multiple disclosures grouped with shared keyboard navigation (ArrowDown / Up between triggers).
Contracts
Non-negotiable contracts
APGAPG: Disclosure pattern — aria-expanded `aria-expanded` on the trigger is the single source of truth for the open/closed state. CSS modifiers and icon rotation derive from `[aria-expanded="true"]`; never from a parallel `data-expanded` attribute or visual cue alone.
When the icon rotates but `aria-expanded` is missing or stuck, sighted users perceive the state but SR users hear "button" with no state cue. The icon is decorative; the attribute carries the meaning.
HTML specHTML button vs anchor semantics The trigger is a `<button type="button">`, never an `<a>` element. Visual treatment may match link styling; the underlying element is a button.
Anchors navigate; disclosures toggle. Middle-click on an anchor-as-trigger opens an empty page; copy-link captures `#`. The semantic distinction matters for keyboard, AT, and browser-feature parity.
APGAPG: Disclosure pattern — aria-controls `aria-controls` on the trigger references the panel's id; the panel's `aria-labelledby` references the trigger's id. The bidirectional relationship is wired even when trigger and panel are visually adjacent.
Without `aria-controls`, SR users know the state but cannot navigate from trigger to disclosed content directly. The relationship becomes implicit (visual proximity) instead of explicit (programmatic).
Vocabulary drift
- Radix
Collapsible- Radix splits the disclosure pattern into `Collapsible` (single, this canon's Disclosure) and `Accordion` (list, the Accordion canon). The canonical separation here matches that split, anchored on APG's pattern definitions.
- Headless UI
Disclosure- Headless UI ships `<Disclosure>` matching this canonical name; it does not ship a separate Accordion primitive, leaving the list-grouping to the consumer.
- HTML
details / summary- Native `<details>` plus `<summary>` is the HTML-spec equivalent of the canonical anatomy; the toggle is the `<summary>` element and the disclosed region is the rest of `<details>`. Browser-rendered triangle is the UA's icon; consumers replace it via `details::-webkit-details-marker` or `summary::marker`.
Common mistakes
#disclosure-no-aria-expanded
Trigger missing `aria-expanded` toggle
The button has no `aria-expanded` attribute. Visually content reveals; SR users hear "button" with no state cue. Icon rotation alone is invisible to non-sighted users.
`aria-expanded="true"` on the button when the panel is open, `false` when closed. Always pair with `aria-controls` referencing the panel's id. Style the icon from `[aria-expanded="true"]` rather than introducing a parallel `data-expanded` attribute.
#disclosure-icon-only-state-cue
Expansion state shown only via icon rotation
The chevron rotates on expand, but `aria-expanded` is missing or stuck. Sighted users see the state; SR users do not. The icon is decorative; without `aria-expanded` it carries the entire state-meaning load.
`aria-expanded` is the source of truth. The icon visualises the state. Style icon rotation from `[aria-expanded="true"]`. The icon is `aria-hidden`.
#disclosure-as-link
Disclosure trigger implemented as `<a>`
The trigger is an anchor element (because it looks like a link, or because the developer copy-pasted from another "Show more" link). Middle-click opens an empty page; copy-link captures `#`; semantically wrong because anchors navigate, disclosures toggle.
Use `<button type="button">` for disclosure triggers. The visual treatment may match link styling; the underlying element is a button. The semantic distinction (link navigates, button performs in-page action) applies here as it does for Button vs Link generally.
#disclosure-no-aria-controls
`aria-controls` not wired to the panel
Trigger has `aria-expanded` but no `aria-controls`. SR users know the trigger is expanded/collapsed but cannot navigate to the disclosed content directly. The relationship between trigger and panel is implicit (visual proximity) rather than explicit.
`aria-controls` references the panel's id. Pair with the panel's `aria-labelledby` referencing the trigger's id for the bidirectional relationship. Mature primitives (Radix, React Aria) wire this automatically.
#disclosure-anchor-link-no-expand
Page anchor link to disclosed content does not expand
A link elsewhere in the document points at content inside a closed disclosure (e.g. `href="#shipping-policy"`). The page scrolls to the panel but the panel is collapsed. Anchor navigation without auto-expand is a dead-end for the user.
On hashchange, expand the disclosure containing the target. Listener finds the disclosure containing the target id and toggles it open before scrolling. Same-pattern fix as `accordion-anchor-link-no-expand`; the canonical contract spans both Accordion and Disclosure.
Accessibility hints
| Slot | Accessibility hint | |
|---|---|---|
trigger | Real `<button>` — never a `<div>` with click handler nor an `<a>` (anchors navigate; disclosures toggle in-page state). Carries `aria-expanded="true|false"` and `aria-controls` referencing the panel's id. Standalone disclosures may be wrapped in a heading element when the disclosure represents a content region; inline disclosures (within prose) are not heading-wrapped. | |
icon | Decorative — `aria-hidden="true"`. State is communicated through `aria-expanded` on the trigger; the icon is visual reinforcement only. | |
panel | Apply `aria-labelledby` referencing the trigger's id (or `aria-label` if the trigger has no useful text content for labelling). For standalone disclosures with regional content, `role="region"` is appropriate; for inline disclosures in prose, no role needed (the content retains its native semantics). Hide via `hidden` attribute or `display: none` when collapsed (not just zero-height) so SR users do not encounter ghost content. |