useDisclosure sets `aria-hidden` on a panel that also has `hidden="until-found"`, which HTML-ARIA forbids
- Dominant language
- TypeScript
- Stars
- 15.9k
- Forks
- 1.6k
- Avg merge
- 3d 9m
- Merged PRs (30d)
- 59
Description
### 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:
```html
```
Source, `packages/react-aria/src/disclosure/useDisclosure.ts` on `main`:
```ts
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:
```diff
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:
```tsx
const panelRef = useRef(null);
const state = useContext(DisclosureStateContext);
useLayoutEffect(() => {
panelRef.current?.removeAttribute('aria-hidden');
}, [state?.isExpanded]);
return {children};
```
Happy to open a PR for the one-line removal if you'd like.
### 🖥️ Steps to Reproduce
```tsx
import {Button, Disclosure, DisclosurePanel, Heading} from 'react-aria-components';
Trigger
Panel content
```
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_
Contributor guide
Research direction
Start in packages/react-aria/src/disclosure/useDisclosure.ts at the panelProps definition around line 163, then reproduce the Disclosure and DisclosurePanel example in the issue. Remove the aria-hidden entry and verify with the Nu HTML Checker that a collapsed panel no longer combines aria-hidden with hidden="until-found" while disclosure and find-in-page behavior remain intact.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- react, typescript
- Domain
- accessibility, frontend
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 86/100