Skip to content

useDisclosure sets aria-hidden on a panel that also has hidden="until-found", which HTML-ARIA forbids #10552

Description

@ansonwonggg

Provide a general summary of the issue here

useDisclosure returns 'aria-hidden': !state.isExpanded in panelProps, while its layout effect sets hidden="until-found" on the same element when the panel is collapsed. HTML-ARIA has a normative prohibition on that exact pairing, added in w3c/html-aria#507:

Authors SHOULD NOT use the aria-hidden="true" attribute on any element which also has a hidden attribute.

Authors MUST NOT use aria-hidden="true" on any element which also has the hidden attribute specified in the until-found state.

The Nu HTML Checker flags this, and so do the accessibility scanners built on it — we hit it on an enterprise audit (SortSite rule W3cHtml5AriaHiddenNotAllowed-hidden-until-found, reported against WCAG 4.1.2 / Section 508 4.1.2).

🤔 Expected Behavior?

A collapsed DisclosurePanel carries hidden="until-found" and no aria-hidden. hidden in any state already removes the subtree from the accessibility tree, so aria-hidden adds nothing that isn't already true.

😯 Current Behavior

A collapsed panel renders both:

<div id="react-aria-3" role="group" aria-labelledby="react-aria-2"
     hidden="until-found" aria-hidden="true" data-rac=""></div>

Source, packages/react-aria/src/disclosure/useDisclosure.ts on main:

panelProps: {
  id: panelId,
  // This can be overridden at the panel element level.
  role: 'group',
  'aria-labelledby': triggerId,
  'aria-hidden': !state.isExpanded,   // ← line 163
  hidden: isSSR ? !state.isExpanded : undefined
}

Two smaller consequences of the same line:

  1. During the collapse animation the panel is still visible but already aria-hidden="true". The hidden attribute is deliberately deferred until panel.getAnimations() resolve (line ~124), but aria-hidden flips synchronously with the state. For the duration of the transition, on-screen content is hidden from assistive technology.
  2. aria-hidden="false" on the expanded panel. APG discourages the "false" value — it can't force exposure if an ancestor is hidden, so it reads as a guarantee it doesn't provide.

To be clear about what is not broken: the beforematch handler works. When find-in-page reveals the panel, flushSync(() => state.toggle()) clears aria-hidden in the same commit, so the "content stays hidden from AT after being found" failure the spec warns about doesn't actually reproduce. The issue is conformance and the two side effects above, not a dead end for screen reader users.

💁 Possible Solution

Drop the aria-hidden entry from panelProps. The hidden prop on the line below already covers the SSR and no-until-found-support paths, and the layout effect covers the client path:

 panelProps: {
   id: panelId,
   // This can be overridden at the panel element level.
   role: 'group',
   'aria-labelledby': triggerId,
-  'aria-hidden': !state.isExpanded,
   hidden: isSSR ? !state.isExpanded : undefined
 }

🔦 Context

We use Disclosure for the accordion component in an enterprise application that is contractually audited for WCAG 2.2 AA and Section 508. The finding is raised on every page containing an accordion, and the audit tooling isn't ours to configure, so a conformance error can't be dismissed as a false positive on our side.

DisclosurePanel merges as mergeProps(DOMProps, renderProps, panelProps, focusWithinProps), so panelProps wins and the attribute can't be overridden from userland. Our workaround is a layout effect that strips it, which needs a DisclosureStateContext subscription because DisclosurePanel re-renders as a context consumer while the parent bails out:

const panelRef = useRef<HTMLDivElement>(null);
const state = useContext(DisclosureStateContext);

useLayoutEffect(() => {
  panelRef.current?.removeAttribute('aria-hidden');
}, [state?.isExpanded]);

return <DisclosurePanel ref={panelRef}>{children}</DisclosurePanel>;

Happy to open a PR for the one-line removal if you'd like.

🖥️ Steps to Reproduce

import {Button, Disclosure, DisclosurePanel, Heading} from 'react-aria-components';

<Disclosure>
  <Heading><Button slot="trigger">Trigger</Button></Heading>
  <DisclosurePanel>Panel content</DisclosurePanel>
</Disclosure>
  1. Render the above in Chrome (or any browser supporting hidden=until-found) and leave it collapsed.
  2. Inspect the panel element — it has both hidden="until-found" and aria-hidden="true".
  3. Run the page through the Nu HTML Checker (validator.w3.org/nu) to see the conformance error.

Version

react-aria-components 1.18.0 / react-aria 3.49.0. Still present on main at packages/react-aria/src/disclosure/useDisclosure.ts:163.

What browsers are you seeing the problem on?

Chrome

If other, please specify.

No response

What operating system are you using?

MacOS

🧢 Your Company/Team

No response

🕷 Tracking Issue

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions