# Never aria-hidden Something Focusable

**NEVER** · **ID:** `interactions-aria-hidden-focusable` · **Category:** interactions
**Source:** [ARIA](https://www.w3.org/WAI/ARIA/apg/)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/interactions-aria-hidden-focusable

> NEVER: Put `aria-hidden="true"` (or `role="presentation"`) on a visible focusable element or a subtree that is still tabbable — it strips the a11y tree but NOT the tab order, so focus walks into a ghost: the ring travels off screen and the screen reader announces nothing. Use `inert` on the container (removes it from tab order, a11y tree and hit-testing at once) or unmount it.

Use inert or unmount to hide a subtree, so the tab order and the a11y tree agree

aria-hidden="true" removes an element and its whole subtree from the accessibility tree. It does NOT remove anything from the tab order — those are two different trees, and this is the single most common way to desynchronise them. An off-canvas drawer hidden with aria-hidden plus opacity: 0 and a transform is still fully tabbable: keyboard focus walks into it, the focus ring travels off screen, and a screen reader announces nothing whatsoever, because the element it is focused on does not exist as far as the accessibility tree is concerned. Focusing on "nothing" is a dead end no user can recover from. The correct tools remove the subtree from BOTH trees at once: unmount it, or set the inert attribute on the container — inert takes the subtree out of the tab order, out of the accessibility tree, and out of hit-testing in one attribute, which is why it also shows up in this corpus as the way to freeze the rest of the page during a drag. If for some reason you must keep aria-hidden, you have to strip tabindex and disable every focusable descendant by hand, and keep doing so as the subtree changes — which is precisely the bookkeeping inert exists to delete.

## Rule snippet

```tsx
// ghost: <div aria-hidden="true" className="opacity-0"><button>Close</button></div>
<div inert={!open} className="opacity-0"><button>Close</button></div>
```

## Bad — do not do this

`interactions-aria-hidden-focusable-bad`

```tsx
import { useState } from 'react';

const ITEMS = ['Profile', 'Billing', 'Sign out'];

export function AriaHiddenFocusableBad() {
  const [open, setOpen] = useState(false);
  const [focused, setFocused] = useState('nothing');
  const [focusIsVisible, setFocusIsVisible] = useState(true);

  const trackFocus = (label: string, visible: boolean) => () => {
    setFocused(label);
    setFocusIsVisible(visible);
  };

  return (
    <div className="w-full max-w-sm">
      <div className="relative overflow-hidden rounded-lg border border-border bg-card p-3">
        <div className="flex gap-2">
          <button
            type="button"
            onFocus={trackFocus('“Before” button', true)}
            className="rounded border border-border px-2 py-1 text-xs text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
          >
            Before
          </button>
          <button
            type="button"
            onFocus={trackFocus('“Toggle menu” button', true)}
            onClick={() => setOpen((v) => !v)}
            className="rounded border border-border px-2 py-1 text-xs text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
          >
            {open ? 'Close menu' : 'Toggle menu'}
          </button>
          <button
            type="button"
            onFocus={trackFocus('“After” button', true)}
            className="rounded border border-border px-2 py-1 text-xs text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
          >
            After
          </button>
        </div>

        {/* aria-hidden + opacity-0 + translate hides the menu from EYES and from the
            accessibility tree — but not from the tab sequence. The buttons inside are
            still focusable, so keyboard focus walks into content that is announced as
            nothing and rendered as nothing. */}
        <div
          aria-hidden={!open}
          className={`mt-3 rounded border border-border bg-muted p-2 transition-transform duration-200 ${
            open ? 'translate-x-0 opacity-100' : '-translate-x-full opacity-0'
          }`}
        >
          <ul className="space-y-1">
            {ITEMS.map((item) => (
              <li key={item}>
                <button
                  type="button"
                  onFocus={trackFocus(`“${item}” (inside the aria-hidden menu)`, open)}
                  className="w-full rounded px-2 py-1 text-left text-xs text-foreground hover:bg-accent focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
                >
                  {item}
                </button>
              </li>
            ))}
          </ul>
        </div>
      </div>

      <p className="mt-3 rounded border border-border bg-muted px-3 py-2 text-xs text-foreground">
        <span className="text-muted-foreground">Focus is on:</span>{' '}
        <span className="font-medium">{focused}</span>
        <br />
        <span className="text-muted-foreground">Can you see it?</span>{' '}
        <span className={`font-medium ${focusIsVisible ? '' : 'text-destructive'}`}>
          {focusIsVisible ? 'yes' : 'NO — focus is on an invisible element'}
        </span>
      </p>

      <p className="mt-3 text-xs text-muted-foreground">
        Leave the menu closed. Click <em>Before</em>, then press Tab four times. Presses
        3, 4 and 5 land inside the closed menu: the focus ring vanishes, the readout
        names an element you cannot see, and a screen reader announces nothing at all,
        because <code>aria-hidden</code> removed it from the accessibility tree while
        leaving it in the tab order.
      </p>

      <p className="mt-2 text-xs text-destructive">
        aria-hidden=&quot;true&quot; on a parent of focusable children — a Rules of ARIA violation
      </p>
    </div>
  );
}
```

## Good — do this

`interactions-aria-hidden-focusable-good`

```tsx
import { useState } from 'react';

const ITEMS = ['Profile', 'Billing', 'Sign out'];

export function AriaHiddenFocusableGood() {
  const [open, setOpen] = useState(false);
  const [focused, setFocused] = useState('nothing');
  const [focusIsVisible, setFocusIsVisible] = useState(true);

  const trackFocus = (label: string, visible: boolean) => () => {
    setFocused(label);
    setFocusIsVisible(visible);
  };

  return (
    <div className="w-full max-w-sm">
      <div className="relative overflow-hidden rounded-lg border border-border bg-card p-3">
        <div className="flex gap-2">
          <button
            type="button"
            onFocus={trackFocus('“Before” button', true)}
            className="rounded border border-border px-2 py-1 text-xs text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
          >
            Before
          </button>
          <button
            type="button"
            onFocus={trackFocus('“Toggle menu” button', true)}
            onClick={() => setOpen((v) => !v)}
            className="rounded border border-border px-2 py-1 text-xs text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
          >
            {open ? 'Close menu' : 'Toggle menu'}
          </button>
          <button
            type="button"
            onFocus={trackFocus('“After” button', true)}
            className="rounded border border-border px-2 py-1 text-xs text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
          >
            After
          </button>
        </div>

        {/* One attribute, both effects: `inert` removes the subtree from the tab
            order AND from the accessibility tree, and blocks pointer events too.
            No aria-hidden, so the two never disagree. Unmounting the panel is the
            other correct answer. */}
        <div
          inert={open ? undefined : ''}
          className={`mt-3 rounded border border-border bg-muted p-2 transition-transform duration-200 ${
            open ? 'translate-x-0 opacity-100' : '-translate-x-full opacity-0'
          }`}
        >
          <ul className="space-y-1">
            {ITEMS.map((item) => (
              <li key={item}>
                <button
                  type="button"
                  onFocus={trackFocus(`“${item}” (in the open menu)`, true)}
                  className="w-full rounded px-2 py-1 text-left text-xs text-foreground hover:bg-accent focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
                >
                  {item}
                </button>
              </li>
            ))}
          </ul>
        </div>
      </div>

      <p className="mt-3 rounded border border-border bg-muted px-3 py-2 text-xs text-foreground">
        <span className="text-muted-foreground">Focus is on:</span>{' '}
        <span className="font-medium">{focused}</span>
        <br />
        <span className="text-muted-foreground">Can you see it?</span>{' '}
        <span className={`font-medium ${focusIsVisible ? 'text-success' : ''}`}>
          {focusIsVisible ? 'yes — focus is always on something visible' : 'no'}
        </span>
      </p>

      <p className="mt-3 text-xs text-muted-foreground">
        Leave the menu closed and Tab from <em>Before</em>: the third press lands on{' '}
        <em>After</em>. The hidden items are simply not in the sequence. Open the menu
        and Tab again — now they are, and they announce normally. This is the same{' '}
        <code>inert</code> used to freeze the page during a drag, applied to the case it
        was really designed for.
      </p>

      <p className="mt-2 text-xs text-success">
        inert takes the subtree out of the tab order and the accessibility tree together
      </p>
    </div>
  );
}
```

## References

- [Using ARIA: Fifth rule of ARIA use](https://www.w3.org/TR/using-aria/)
- [MDN: inert](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/inert)
