react-collapsible/Concepts/Accessibility
Concepts~4 min

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's id
  • aria-expanded — set to the same boolean as the Collapsible's open

Both come from state you already have, so the wiring is one line each:

TSXdetails.tsx
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"

This panel is programmatically linked to the button above. A screen reader announces the button as expanded or collapsed, and while the panel is closed its content is out of the accessibility tree entirely — nothing in here is read until you open it.

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 onKeyDown re-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-expanded announcement 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:

console
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 to display: none. The content is out of the document flow, the tab order, and the accessibility tree — the strongest form of "not there".
  • hide keeps the content rendered (visibility: hidden) so form state, iframes, and measurements survive the collapse. The component manages aria-hidden on the inner wrapper for you — true while collapsed, false while 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 autoFocus on 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.