Dev 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.

Highlight
Fig 1.1 · Disclosure · Dev view

Implementations

How specific libraries realise the canonical anatomy. Each entry records the deltas between the canon and the library's surface.

headlessui 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.

radix 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.

react-aria 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.

Dev

Code anatomy

Slot Code slot Semantic
trigger trigger button
icon icon presentational
panel panel region
Both

Variants, properties, states

Variants

Structurally different versions of the component.

inline standalone

Properties

The same component, parameterised.

PropertyType
defaultExpanded boolean
density comfortable | compact
hasIcon boolean

States

Browser/user-driven (interactive) vs. app-driven (data).

KindStates
interactive
hoverfocus-visibleactivedisabled
data
closedexpandingexpandedcollapsing
Both

State transitions

FromToTrigger
closedexpandingUser activates the trigger (Enter / Space / click). `aria-expanded` flips to true; the panel begins its enter animation.
expandingexpandedThe expand animation completes (or, under prefers-reduced-motion reduce, immediately). The panel is fully visible and reachable by keyboard via Tab.
expandedcollapsingUser activates the trigger again (toggle is symmetric on Disclosure — unlike single-mode Accordion which may have non-collapsible behaviour).
collapsingclosedThe 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`.
Dev

Cross-framework expression

FrameworkStructure mechanismVariant mechanism
Web Components A `<ui-disclosure>` host with named slots for `trigger`, `panel`, optional `icon`; trigger is a real `<button>` with `aria-expanded` reflected from the host's open state attributes (`variant="standalone"`, `default-expanded`, `density="comfortable"`); `data-state="closed|expanded|expanding|collapsing"` for CSS
React Compound components (Radix uses `Collapsible.Root` / `Collapsible.Trigger` / `Collapsible.Content`; React Aria `useDisclosure` plus a custom collection composer; Headless UI ships `<Disclosure>` / `<DisclosureButton>` / `<DisclosurePanel>`) props with class-variance-authority for variant; `defaultOpen` boolean; `data-state` exposed for styling
Angular (signals) Angular CDK `cdk-accordion` plus `cdk-accordion-item` (single-item case) or a custom directive plus signal-based open state input<'inline' | 'standalone'>(); `[defaultExpanded]` host binding; `[(open)]` two-way binding
Vue Headless UI `<Disclosure>` / `<DisclosureButton>` / `<DisclosurePanel>`; or a custom composable `useDisclosure` plus per-item `useToggle` defineProps with literal-union types; `:default-open` boolean
Both

Events

  1. openChange
    Payload
    Boolean. `true` when the panel finishes expanding, `false` when it finishes collapsing. Mirrors `aria-expanded` on the trigger.
    Web Components
    `openChange` CustomEvent on the host with `event.detail = { open: boolean }`.
    React
    `onOpenChange(open: boolean)` controlled-pattern callback (Radix Collapsible, Headless UI Disclosure exposes `open` render-prop instead of event but consumers may wrap).
    Angular Signals
    `output<boolean>('openChange')`; pair with `[(open)]` two-way binding.
    Vue
    `@update:open` for `v-model:open`.
Dev

Performance thresholds

  • lazyMountThresholdpanel-payload-size100kb

    Disclosure panels above ~100kb of rendered DOM payload should lazy-mount on first expand. Below the threshold, eager-render and use `[hidden]` toggle (allows CSS-only show/hide and avoids per-expand mount latency). Above the threshold, the per-mount cost dominates and lazy-mount with `aria-busy` during async loading is the canonical pattern. Threshold matches Tabs's panel threshold; higher than Accordion's (50kb) because a single Disclosure does not compete with siblings for memory budget.

Both

Accessibility

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.
Both

Accessibility acceptance

Keyboard walk

KeysExpected
TabFocus enters the trigger. Subsequent Tab moves to the next focusable inside the open panel; Tab continues through the panel content then exits the disclosure into the rest of the page. Closed panels skip from focus order entirely.
Shift+TabReverse direction — moves backwards through panel contents and trigger.
Enter or Space (focus on trigger)Toggles the disclosure's expanded state. Symmetric — unlike single-mode Accordion which may have non-collapsible behaviour, Disclosure's toggle always works in both directions.

Screen-reader announcements

TriggerExpected
Focus enters trigger (closed)SR announces the trigger's accessible name followed by "button, collapsed". `aria-expanded` provides the state portion.
Trigger activated, panel expandsSR re-announces the trigger as "<accessible name>, button, expanded". The panel content becomes part of the document; subsequent Tab into the panel announces its content normally.
Anchor-link auto-expands a closed disclosureOn hashchange + auto-expand, focus should move to the target inside the panel (or to the trigger if no specific target). The user is not surprised by invisible expansion.

axe-core rules to assert

  • aria-allowed-role
  • aria-required-attr
  • aria-valid-attr-value
  • color-contrast
  • region

Same data as JSON for direct ingestion into Playwright + @axe-core/playwright or Jest + jest-axe: /api/components/disclosure/a11y-fixture.json

Both

Contracts

Non-negotiable contracts

  1. 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.

  2. 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.

  3. 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`.
Dev

Common mistakes

Blocker

#disclosure-no-aria-expanded

Trigger missing `aria-expanded` toggle

Problem

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.

Fix

`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.

Blocker

#disclosure-icon-only-state-cue

Expansion state shown only via icon rotation

Problem

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.

Fix

`aria-expanded` is the source of truth. The icon visualises the state. Style icon rotation from `[aria-expanded="true"]`. The icon is `aria-hidden`.

Major

#disclosure-no-aria-controls

`aria-controls` not wired to the panel

Problem

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.

Fix

`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.

Figma↔Code mismatches
  1. 01
    Figma

    A "Show more" link drawn for inline disclosure in body prose

    Code

    A `<button>` toggling visibility of inline content

    Consequence

    Designers 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.

    Correct

    Use `<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.

  2. 02
    Figma

    Disclosure drawn with chevron rotation but no panel-height transition

    Code

    Both chevron rotation AND panel height-transition animate together

    Consequence

    Designers 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.

    Correct

    Both 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`).

  3. 03
    Figma

    Standalone disclosure drawn without heading semantics

    Code

    Standalone disclosure with the trigger wrapped in a heading element

    Consequence

    Designers 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.

    Correct

    For 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.

  4. 04
    Figma

    Disclosure used for a single Accordion-of-one-item

    Code

    A standalone Disclosure component, not Accordion

    Consequence

    Designers 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).

    Correct

    A 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).