DEV Community

venkatesh m
venkatesh m

Posted on

jsx-a11y has 36 rules. None of them catch these 6 patterns.

The Bug That Passed Lint

I was building a menu component. The trigger had aria-haspopup="menu". The content panel had role="dialog". Every element was individually valid. jsx-a11y gave zero warnings. The component rendered correctly. Keyboard navigation worked.

Then I tested with VoiceOver.

The trigger announced: "Actions, button, menu popup." The user expects a menu — arrow keys to navigate between items, no Tab key needed. What they got was a dialog with Tab navigation. Visually identical. Functionally identical for sighted users. Completely wrong for screen reader users. The trigger promised a menu. The content delivered a dialog.

jsx-a11y didn't catch this because it can't. It visits one JSX element at a time. It sees attributes on that element. It cannot see the parent, the sibling, or the child. The aria-haspopup="menu" was valid. The role="dialog" was valid. The mismatch between them is invisible to any tool that checks elements in isolation.

This is not a jsx-a11y bug. It's a structural limitation. Element-level analysis cannot validate relationships.


What Element-Level Linting Misses

jsx-a11y has 36 active rules. Every one answers the same type of question: "Does this element have the right attributes?" Does this <img> have alt? Does this <button> have content? Does this element with onClick have a keyboard handler?

These are important checks. They catch real bugs. But the accessibility failures that survive to production are rarely about missing attributes on a single element. They're about how elements relate to each other.

Six patterns. Each one passes jsx-a11y. Each one breaks for screen reader or keyboard users.


1. Dialog without aria-modal="true"

// Passes jsx-a11y. Breaks screen reader navigation.
<div role="dialog">
  <h2>Confirm deletion</h2>
  <p>Are you sure?</p>
</div>
Enter fullscreen mode Exit fullscreen mode

Without aria-modal="true", screen readers allow virtual cursor navigation outside the dialog. A VoiceOver user presses the down arrow and starts reading the page behind the modal overlay. They're interacting with content that's visually obscured. The dialog looks modal. It isn't.

What the user expects What happens
Virtual cursor stays inside dialog Virtual cursor reads page behind dialog
Escape closes, focus returns to trigger Escape may or may not work
"Confirm deletion, dialog" announced "dialog" announced, no context

The fix is aria-modal="true". One attribute. jsx-a11y doesn't check for it because the attribute belongs to the relationship between the dialog and the page — not to the dialog element alone.


2. aria-haspopup with an invalid value

// Passes jsx-a11y. Silently broken.
<button aria-haspopup="dropdown">Open</button>
Enter fullscreen mode Exit fullscreen mode

The ARIA spec allows seven values for aria-haspopup: menu, listbox, tree, grid, dialog, true, false. That's it. "dropdown" is not one of them. Neither is "tooltip" or "popup" or "select."

Browsers don't throw errors on invalid ARIA values. They silently treat them as false. The popup is never announced. The user clicks the button, something appears on screen, and the screen reader says nothing about it. The popup is invisible to assistive technology.

This isn't hypothetical. I've seen aria-haspopup="tooltip" in production codebases from developers who reasonably assumed the value should describe what appears. The spec disagrees. The browser silently drops it.


3. Interactive elements inside a tooltip

// Passes jsx-a11y. Keyboard users can never reach the link.
<div role="tooltip">
  <a href="/help">Learn more</a>
</div>
Enter fullscreen mode Exit fullscreen mode

Tooltips disappear on blur. A keyboard user cannot Tab into a tooltip because the moment focus leaves the trigger, the tooltip closes. The link inside it is unreachable. A mouse user can click it. A keyboard user cannot. A screen reader user cannot.

If you need interactive content in a popup, use role="dialog" with a focus trap. Tooltips are for text-only content — labels, descriptions, keyboard shortcuts. Putting a button or link inside one creates a feature that exists for mouse users and doesn't exist for everyone else.


4. Accordion trigger outside a heading

// Passes jsx-a11y. Invisible to heading navigation.
<div>
  <button aria-expanded="true" aria-controls="panel-1">
    Section title
  </button>
</div>
<div id="panel-1">Panel content</div>
Enter fullscreen mode Exit fullscreen mode

Screen reader users navigate pages by headings. In NVDA, pressing H jumps to the next heading. In VoiceOver, using the rotor filtered to headings gives an outline of the page. Accordion sections are page structure — they should appear in that outline.

Without a heading wrapper, the accordion section is invisible to heading navigation. The user has to read the entire page sequentially to find it. The fix is one element:

<h3>
  <button aria-expanded="true" aria-controls="panel-1">
    Section title
  </button>
</h3>
Enter fullscreen mode Exit fullscreen mode

The WAI-ARIA Accordion Pattern specifies this explicitly. jsx-a11y doesn't check it because the heading requirement is about the relationship between the trigger and its ancestor — not about the trigger element itself.


5. role="menuitem" on a <button>

// Passes jsx-a11y. Double announcement in some screen readers.
<button role="menuitem">Edit</button>
Enter fullscreen mode Exit fullscreen mode

<button> has an implicit role of "button." Adding role="menuitem" overrides it at the ARIA level, but NVDA with Firefox announces both: "button, menuitem, Edit." The user hears two conflicting roles for the same element. Is this a button or a menu item? The double announcement creates confusion about what the element does and how to interact with it.

The correct pattern for menu items:

<div role="menuitem" tabIndex={-1}>Edit</div>
Enter fullscreen mode Exit fullscreen mode

No implicit role conflict. One announcement. Programmatically focusable via roving tabindex. This is what the WAI-ARIA Menu Button Pattern specifies. jsx-a11y doesn't flag the conflict because the conflict is between the element's implicit role and its explicit role — a relationship, not an attribute.


6. Dialog without an accessible name

// Passes jsx-a11y. "dialog" announced with no context.
<div role="dialog" aria-modal="true">
  <h2>Confirm deletion</h2>
  <p>This action cannot be undone.</p>
  <button>Cancel</button>
  <button>Delete</button>
</div>
Enter fullscreen mode Exit fullscreen mode

When this dialog opens, VoiceOver announces: "dialog." That's it. The user doesn't know what the dialog is about until they navigate through its contents. Compare with:

<div role="dialog" aria-modal="true" aria-labelledby="dialog-title">
  <h2 id="dialog-title">Confirm deletion</h2>
  ...
</div>
Enter fullscreen mode Exit fullscreen mode

Now VoiceOver announces: "Confirm deletion, dialog." The user knows the purpose immediately. The difference is aria-labelledby pointing to the heading — a relationship between two elements.


Why This Gap Exists

ESLint rules operate on AST nodes. When jsx-a11y visits a JSXOpeningElement, it receives the attributes of that element. It can check if role is valid. It can check if aria-label exists. It cannot walk up to the parent and check if the parent is a heading. It cannot walk down to the children and check if any of them are focusable.

Some of these checks are structurally possible within ESLint's visitor pattern. You can walk the parent chain by following the parent property that ESLint sets on every AST node. You can inspect children through the JSXElement.children array. jsx-a11y doesn't do this — its rules are designed to be fast, single-element checks. The performance trade-off is reasonable for a plugin with 12 million weekly downloads.

But the consequence is that composition-level accessibility bugs — the ones where every element is individually correct but the combination is wrong — pass lint and ship to production.


What Microsoft Built (and Didn't Build)

Microsoft ships eslint-plugin-fluentui-jsx-a11y for their FluentUI component library. It checks that FluentUI-specific components have the right ARIA attributes — a DialogBody needs a DialogTitle, an accordion header needs an accessible name.

It's a good plugin. It's also tied to FluentUI. It doesn't check that a generic <button aria-haspopup="menu"> connects to a panel with role="menu". It doesn't check that a <div role="tooltip"> doesn't contain interactive children. The composition problem in framework-agnostic React code was still unsolved.


eslint-plugin-a11y-enforce

I built a plugin that checks these relationships. 10 rules, divided into two categories.

Component pattern rules validate ARIA relationships in compound components — Dialog, Menu, Accordion, Tooltip:

  • dialog-requires-modalrole="dialog" must have aria-modal="true"
  • dialog-requires-titlerole="dialog" must have aria-labelledby or aria-label
  • haspopup-role-matcharia-haspopup must be a valid ARIA value
  • tooltip-no-interactiverole="tooltip" must not contain focusable children
  • accordion-trigger-heading — accordion triggers must be inside headings
  • menuitem-not-buttonrole="menuitem" should not be on <button> elements

General interaction rules catch common patterns every developer writes:

  • focusable-has-interactiontabIndex={0} requires a keyboard handler
  • input-requires-label — form inputs must have accessible labels (placeholder is not a label)
  • radio-group-requires-grouping — radio buttons must be inside <fieldset> or role="radiogroup"
  • no-positive-tabindextabIndex greater than 0 breaks natural tab order

How the rules work

The composition rules use ancestor traversal — walking up the AST's parent chain to check if an element's ancestor has the right role or tag name. For accordion-trigger-heading, when the visitor encounters a <button> with aria-expanded, it walks up the parent chain looking for <h1>-<h6> or role="heading". For tooltip-no-interactive, when the visitor encounters a focusable element, it walks up looking for role="tooltip".

This is stateless — no mutable flags like insideTooltip = true that would break with nested components, conditional rendering, or interleaved elements. Each check walks the tree from the current node, every time. The trade-off is performance (O(n*d) where d is tree depth), but for the file sizes ESLint processes, this is negligible.

What the error messages say

Every error message explains three things: what is wrong, why it matters for the user, and how to fix it. Not "violation" — a specific audio experience.

Tooltip (role="tooltip") must not contain interactive elements. Tooltips are non-interactive by design. Users cannot Tab to content inside a tooltip because it disappears on blur. If you need interactive content in a popup, use a Popover or Dialog instead.

If a developer reads this message and still doesn't understand the issue, the message failed, not the developer.


Install

npm install --save-dev eslint-plugin-a11y-enforce
Enter fullscreen mode Exit fullscreen mode
// eslint.config.js (ESLint 9+)
import a11yEnforce from 'eslint-plugin-a11y-enforce';

export default [
  a11yEnforce.configs.recommended,
];
Enter fullscreen mode Exit fullscreen mode

ESLint 8 is also supported via legacy config.

Use both jsx-a11y and a11y-enforce. They complement each other. No rule overlap. jsx-a11y checks elements. a11y-enforce checks relationships.


Design Decisions

Zero runtime dependencies. The plugin uses only ESLint's built-in AST APIs. No aria-query, no axe-core, no axobject-query. The full rule set is 1,065 lines of TypeScript.

Educational over terse. Every rule's error message explains the user impact, not just the spec violation. Developers who understand why a rule exists are less likely to disable it.

Conservative on spreads. <div role="dialog" {...props}> fires the rule even though the spread might contain aria-modal. Static analysis cannot see through spreads. If you set role statically, set aria-modal statically too.

Single recommended preset. All 10 rules as errors. No recommended/strict split until real-world usage data justifies one. If a rule fires, it's a real problem.


What This Doesn't Catch

Static analysis has limits. This plugin cannot verify that aria-labelledby points to an element that actually exists — that requires cross-file or runtime analysis. It cannot check that a focus trap is implemented correctly — that's behavior, not structure. It cannot validate screen reader announcement order — that varies by browser and AT combination.

For rendered DOM testing, use @axe-core/react in development. For real-world validation, test with an actual screen reader. Linting catches the structural errors. Testing catches the behavioral ones. Both matter.


Why This Matters Now

Accessibility enforcement is accelerating. The European Accessibility Act started enforcement on June 28, 2025, across all 27 EU member states. In the US, over 5,000 ADA digital accessibility lawsuits were filed in 2025 across federal and state courts — up from roughly 4,000 in 2024 (UsableNet 2025 Year-End Report). In India, the Supreme Court declared digital access a fundamental right under Article 21 in April 2025, and SEBI mandated WCAG compliance for the entire financial sector in July 2025.

Your linter should catch these before they ship. jsx-a11y catches the element-level issues. a11y-enforce catches the composition-level issues. Install both.

10 rules. 207 tests. Zero dependencies. ESM + CJS. ESLint 8 and 9.

Top comments (0)