squirrelscan
GitHub

Focusable elements inside aria-hidden: the tab trap

A hidden element that still takes focus lets keyboard users tab into nothing. squirrel finds them, and tells anti-spam honeypots apart.

What the conflict is

aria-hidden="true" removes a subtree from the accessibility tree. It does not remove it from the tab order. A link or button inside a hidden region can still be reached with Tab, and when it is, a screen reader user has focus on something their screen reader refuses to describe.

The two states have to agree. Either the region is available, in which case it should not be aria-hidden, or it is not, in which case nothing inside it should be focusable.

What squirrel checks

Three checks, at rule severity error. The rule walks every [aria-hidden="true"] element and tests the element itself first. A focusable container is reported and its descendants are not examined; otherwise every descendant is tested.

  • aria-hidden-focus fails with N focusable element(s) inside aria-hidden. Items read a#archive, or div (self is focusable) when the hidden container is itself focusable. Up to ten are listed with a count of the rest.
  • aria-hidden-focus-unconfirmed warns with N focusable element(s) inside aria-hidden may be hidden by a style (unconfirmed). These are elements a style may hide in a way squirrel cannot settle (see What squirrel can see). On a page where an earlier audit reported aria-hidden-focus failing and this one reports the same elements as unconfirmed, the earlier failure is closed: the check ran on the page and found nothing it could confirm.
  • aria-hidden-focus-honeypot warns with N focusable element(s) inside aria-hidden appear to be an anti-spam honeypot, carrying the value Add tabindex="-1" (and autocomplete="off") to remove the honeypot from the tab order. An element qualifies only when all three hold: it is an <input>, its id or name contains hp, honeypot or trap as a hyphen- or underscore-delimited token, and it sits inside a <form>. Requiring all three keeps a genuine bug on an unrelated hidden control from being quietly downgraded.
  • With nothing focusable found, the check passes as No focusable elements inside aria-hidden regions, or returns info with No aria-hidden elements found.

What counts as focusable

Focusable means reachable with the Tab key. The test follows axe-core’s rule of the same name as far as static HTML allows:

  • A tabindex of 0 or more makes an element focusable, and a negative one (-1) takes it out of the tab order. An unparseable value is ignored.
  • Without a tabindex, an element is focusable when it matches a[href], button, input, select, textarea, [contenteditable="true"], audio[controls], video[controls] or details > summary.
  • A button, input, select or textarea with disabled is never focusable, and neither is <input type="hidden">.
  • Nothing inside an inert element is focusable, and neither is anything inside a hidden element (except hidden="until-found"). That covers inert or hidden on the aria-hidden container itself, on an ancestor of it, or on a wrapper inside it.
  • Nothing display: none hides is focusable, whether it is on the element or an ancestor, and neither is an element whose computed visibility is hidden (or collapse), set on itself or inherited. Both count when set inline or by a <style> rule squirrel can decide (see What squirrel can see). On a page that loads an external stylesheet, hiding from a <style> rule and inherited visibility are reported as unconfirmed, because an external rule can make a descendant visible again. A closed <dialog> and an unopened popover count as hidden too, because the browser’s own stylesheet hides them.
  • Content that is only moved off-screen, clipped, shrunk to nothing or made transparent (opacity: 0) can still be reached with Tab, so it is still reported.

Attribute lookups are case-insensitive, so React’s tabIndex in server-rendered markup is read.

What squirrel can see

squirrel reads the markup, each element’s own inline style attribute and the page’s <style> blocks. A declaration counts only when it plainly sets display: none or visibility: hidden (upper case and a trailing !important are fine).

From <style> blocks, squirrel applies only the rules it can decide exactly: a rule outside any @media, @supports, @layer or other at-rule, whose every selector is a single compound of a tag, .class, #id, [attr] or [attr=value], such as .modal { display: none }. It orders them the way a browser does: an !important declaration first, then the inline style, then the more specific selector, then the later rule. A rule it cannot decide, such as one with a combinator (.nav .menu), a pseudo-class (:hover, :not()), an at-rule around it (other than the width-only @media described below), a selector a browser rejects (.123), or a value it cannot read, does not hide anything: it makes the elements it could reach unconfirmed. A <style> block whose type is anything but text/css is ignored, as a browser ignores it, and so is one with media="print". Every rule is undecided in a block that applies only sometimes or not as written: one with another media, one with a title (an alternate stylesheet can be switched off), one inside <svg>, or one with an @namespace. When an @keyframes on the page sets visibility, an element a rule or its inline style animates is unconfirmed too, because a running animation outranks every declaration but an !important one. A block inside <template> or <noscript>, nested CSS, an escaped at-rule name and a non-ASCII space in the CSS are not read, and count as a stylesheet squirrel did not read.

A display: none rule inside width-only @media queries is read for the widths it covers (the rule’s selectors follow the same single-compound limit as above). squirrel reads min-width, max-width and width, in the colon form or the range syntax ((width >= 990px), (400px <= width < 990px)), in px, em or rem, joined by and or a comma list, with an optional only and the screen or all media type, nested @media rules included. Where those queries do not cover every width, the element is shown at the others, so an unnamed control inside it is reported as unconfirmed with its own reason, hidden-only-at-some-widths, and the widths where it shows (“shown below 990px”), not with the generic reason. It stays unconfirmed rather than failing because a static read cannot tell which viewport a visitor has. A theme’s mobile menu, hidden on wide screens, is the usual case. Queries that together cover every width (min-width: 990px with max-width: 989.98px, in either syntax, in em or rem) leave the element hidden at every width, so it is not reported. A gap of up to 0.02px between them counts as covered, one of a whole pixel does not. The tolerance is a trade-off: a viewport at a fractional width (browser zoom, a fractional device pixel ratio) can land in such a gap and show the element, which squirrel then treats as hidden. A query pair that can never both match ((min-width: 990px) and (max-width: 989px)) hides nothing, so the element counts as shown there. Any other media feature (orientation, height, prefers-*, hover, print, resolution), not, @container, @supports or @layer around the rule, a <style media>, a hide outside the media query, a page that also loads an external stylesheet, or a visibility hide keeps the generic reason.

A page past squirrel’s style limits (more than 5,000 style rules, a <style> block over the size limit, a very long selector, a no-break or other non-ASCII space in a style block, or a rule nested inside another) is read as unconfirmed for every element a style might hide: the rules past the limit could hide or show any of them, so an unnamed control is reported as unconfirmed, not as a failure. CSS nesting is not read: a nested rule that sets display or visibility (b { @media (min-width: 0) { display: none } }, .a { & .b { display: none } }) leaves the elements it could reach unconfirmed, and a nested rule that sets neither changes nothing.

External stylesheets are not read. On a page that loads one (<link rel="stylesheet">, a <link> preloaded as="style", or an @import), hiding from a <style> rule and visibility inherited from an ancestor are reported as unconfirmed, because an external rule could show the element again. An inline display: none still counts. A control hidden only by the site’s external CSS, such as a closed modal whose .modal rule lives in a stylesheet, is still reported. Check those findings in a browser, or add inert to the hidden container, which clears the finding and is the better fix anyway.

A control that carries a no-js class, itself or on an ancestor other than <html>, is reported as unconfirmed on a page with an external stylesheet or an @import, with the reason that it may be hidden when JavaScript runs. A theme usually hides that fallback with a rule such as .js .no-js { display: none } in a stylesheet squirrel does not read. The same control on a page whose styles were all read keeps its verdict, and a class such as no-js-hidden is not a marker. A “hide until JavaScript runs” class on a page that was not rendered stays unconfirmed: a static read cannot settle it, and a rendered crawl does.

A <style> rule that shows an element again is read: a display rule on an element the browser hides by default (one with the hidden attribute, a closed <dialog> or a popover), or an !important rule over an inline display: none. On a page that also loads an external stylesheet, showing an element the browser hides by default is unconfirmed, whether a <style> rule or an inline style shows it, because a stylesheet rule such as [hidden] { display: none !important } keeps it hidden. Only an inline display with !important settles it. The same show in an external stylesheet is missed, and that element is treated as hidden.

squirrel’s parser can also build a different tree from a browser’s around some elements: a <p>, list items, headings, buttons, forms, links and other inline formatting elements, table parts, <select>, <svg>, and tags a browser ignores or treats as empty where they appear. When a hidden element is one of those, or sits inside one, what is inside it is reported as unconfirmed rather than skipped.

Only a display, visibility or all declaration can hide, so every other declaration is skipped, whatever its value holds: a custom property such as --font-display: swap or a :root block of design tokens changes nothing. A display or visibility declaration (inline or in a <style> rule) written in any other form is reported by aria-hidden-focus-unconfirmed rather than as a failure, because squirrel will not guess at it. That covers comments, escapes, var() and other functions, non-ASCII characters, the all shorthand, revert, a ! anywhere but a trailing !important, and an unlisted keyword. A vendor-prefixed display (-webkit-box, -webkit-flex, -ms-flexbox and the like) is read as a value that shows the element, including the old fallback chain that ends in display: flex; a -ms- or -moz- value that no later standard display overrides is reported as unconfirmed, because engines differ on it. An attribute selector with an operator ([id$="-drawer"], [href^="#"], [data-a*="x"], [class~="a"], [lang|="en"]) in a rule squirrel cannot decide reaches only elements that have that attribute with a value that could satisfy it, so it no longer makes every element on the page unconfirmed.

How to fix it

<div aria-hidden="true">
  <a href="/archive" tabindex="-1">Archive</a>
</div>

Either drop aria-hidden from the container, add inert to it, or set tabindex="-1" on every focusable descendant. inert is the shortest fix for a region that should be skipped entirely, such as a honeypot block or an off-screen menu, because it takes the whole subtree out of focus at once:

<div aria-hidden="true" inert>
  <input name="hp" autocomplete="off">
</div>

Where the region is meant to be invisible as well, display: none or the hidden attribute is the better fix. Neither leaves the element focusable, so the two states cannot drift apart again. squirrel sees display: none only in an inline style attribute, so a region hidden by a stylesheet rule is still reported; hidden or inert in the markup clears it.

Rule IDa11y/aria-hidden-focus
CategoryAccessibility
ScopePer-page
Severityerror
Weight7/10

Enable / disable

Disable this rule

[rules]
disable = ["a11y/aria-hidden-focus"]

Disable all Accessibility rules

[rules]
disable = ["a11y/*"]

Enable only this rule

[rules]
enable = ["a11y/aria-hidden-focus"]

enable replaces the default ["*"], so this is the only rule selected. Leave disable out: a rule that matches any disable pattern is skipped even when enable names it. Only an enabled setting under a rule’s own [rule_options] table overrides both lists.

Accessibility findings ship in every audit next to the SEO, performance and agent experience rules. See Website security scan with AI for how an agent works through a report.

References

Check your site

Run squirrel audit https://example.com and open the Accessibility section of the report. Honeypot fields and unconfirmed candidates are reported separately, as warnings rather than errors. Signed-out local audits need no account and cost nothing. Signed-in audits spend credits.

Type to search…

↑↓ navigate↵ openesc close