Accessibility
A collapsible panel is only half of the disclosure pattern — the other half is the trigger, and that half is yours. This page covers the trigger contract, the dev-mode check that backs it up, and what collapsed content looks like to assistive technology.
The contract
Sighted users see the connection between a button and the panel it opens — it's right there on screen. A screen reader user gets none of that for free. The disclosure pattern closes the gap with a programmatic link: the trigger declares which element it controls and whether it's currently expanded.
Collapsible owns the panel half of that link — the id prop lands on the container, and collapsed content is kept out of the accessibility tree (more on that below). You own the trigger half. Your trigger must be a real button element carrying two attributes:
aria-controls— set to the same string as the Collapsible'sidaria-expanded— set to the same boolean as the Collapsible'sopen
Both come from state you already have, so the wiring is one line each:
import { useState } from 'react' import { Collapsible } from '@westopp/react-collapsible' const Details = () => { const [open, setOpen] = useState(false) return ( <> <button type="button" aria-controls="accessibility-details" aria-expanded={open} onClick={() => setOpen(!open)} > {open ? 'Hide details' : 'Show details'} </button> <Collapsible id="accessibility-details" open={open}> <p>Hidden from assistive tech until opened.</p> </Collapsible> </> ) }
With that in place, a screen reader announces the trigger as something like "Show details, button, collapsed" — and "expanded" after activation. The user knows the button discloses content and whether that content is currently visible, without ever seeing the screen.
aria-controls="accessibility-wired-panel" aria-expanded="false"Why a real button
The contract says button element, not "something clickable" — and that's load-bearing. A native button gives you three things a div with an onClick never will:
- Keyboard activation. Enter and Space both fire the click handler. No
onKeyDownre-implementation. - Focusability. It's in the tab order by default — keyboard users can reach it at all.
- Role. Assistive tech announces it as a button, which is what makes the
aria-expandedannouncement coherent.
Recreating those on a generic element is possible but it's three extra concerns you take on for no benefit. Style the button however you like — the element, not its appearance, is what matters.
The dev-mode guardrail
Because the trigger half lives in your code, it's the half that gets forgotten. The component checks for you: in non-production builds, the first time an instance opens, it queries the document for an element with a matching aria-controls. If none exists, it warns once — per instance, with the exact attributes to add:
Collapsible: Could not find an element with aria-controls="accessibility-details". If you have a trigger element for this collapsible, ensure it has aria-controls="accessibility-details" and aria-expanded for accessibility.
The check waits until the panel first opens — a panel that mounts closed isn't checked — so triggers that render late (or conditionally) don't false-positive. Production builds skip it entirely — it costs nothing where it matters.
Collapsed content and assistive tech
Either hide mode keeps collapsed content out of the accessibility tree — a screen reader never announces what's behind a closed panel. They get there differently:
remove(the default) collapses todisplay: none. The content is out of the document flow, the tab order, and the accessibility tree — the strongest form of "not there".hidekeeps the content rendered (visibility: hidden) so form state, iframes, and measurements survive the collapse. The component managesaria-hiddenon the inner wrapper for you —truewhile collapsed,falsewhile expanded — so the rendered-but-invisible content is explicitly excluded.
You never set aria-hidden yourself; choose a mode and the right exclusion comes with it.
Focus management
The library moves no focus — and for the disclosure pattern, that's correct. Two rules cover almost every case:
- Never place initial focus inside a collapsed panel. No
autoFocuson inputs behind a closed Collapsible — in both hide modes the content is unfocusable while collapsed, and focus that lands nowhere disorients keyboard users. - When a panel opens from user action, leave focus on the trigger. The user just pressed it; the announcement of "expanded" plus the newly-reachable content is the expected experience. Move focus into the panel only for modal-like flows — an inline edit form where the next action is unambiguously inside, say — and do it deliberately in your own
onClick.
Recap
The component handles the panel: id, hidden-content semantics, and a dev-time check that the link exists. You handle the trigger: a real button with aria-controls matching the id and aria-expanded matching open. Wire those two attributes and the disclosure pattern is complete — everything else on this page is the reasoning behind them.