If your React app uses Ant Design, your accessibility tooling is probably giving you a false sense of safety.
This passes eslint-plugin-jsx-a11y without a single warning:
import { Button, Select, Modal, Tooltip } from 'antd';
import { DeleteOutlined } from '@ant-design/icons';
<Button icon={<DeleteOutlined />} onClick={remove} />
<Select options={countries} placeholder="Country" />
<Modal open={open} onCancel={close}>
Are you sure?
</Modal>
<Tooltip title="You don't have permission">
<Button disabled>Publish</Button>
</Tooltip>
Every line here is a real accessibility bug:
- The icon-only button has no accessible name. A screen reader announces "button" and nothing else.
- The Select has only a placeholder as its label. Placeholders aren't reliable accessible names.
- The Modal has no title, so the dialog is unnamed.
- The Tooltip wraps a disabled button. Keyboard users can never focus it, so they never see why it's disabled.
Why the usual tools miss these
jsx-a11y only understands plain JSX elements. It knows <button> needs a name. It has no idea that antd's <Button> renders a <button>, or that icon without children means there's no visible text.
axe does see the problem, because it scans the rendered DOM. But it reports a selector like .ant-btn.ant-btn-icon-only, not the file and line you wrote. On a big app that's a scavenger hunt, and it only runs if something starts your app and clicks through it.
So the antd-specific bugs slip through: too high-level for jsx-a11y, too far from the source for axe.
antd A11y Guard
I built antd A11y Guard to close that gap. It's a free, MIT-licensed GitHub Action that checks pull requests for accessibility problems in React + Ant Design code.
# .github/workflows/a11y.yml
name: Accessibility
on: pull_request
permissions:
contents: read
pull-requests: write
security-events: write
jobs:
antd-a11y:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: cstayyab/antd-a11y-action@v0
There's nothing to install or configure. It ships its own parser and rules, and it reads only the files your PR changed.
What it checks
Ten antd-aware rules, plus a tuned version of jsx-a11y's recommended set:
| Rule | Catches |
|---|---|
icon-button-has-name |
Icon-only Button with no aria-label
|
picker-has-name |
Unlabelled Select, DatePicker, Cascader, TreeSelect… |
form-control-has-name |
Unlabelled Input, Switch, Slider, bare Checkbox/Radio
|
form-item-has-label |
Form.Item with name but no label |
modal-has-title |
Modal / Drawer without a title |
table-column-has-title |
Table columns with empty titles |
image-has-alt |
antd Image without alt
|
tooltip-no-disabled-child |
Tooltip/Popover around a disabled button |
popup-trigger-focusable |
Dropdown/Tooltip on a non-focusable span, icon or Avatar
|
auth-input-autocomplete |
autoComplete="off" or paste blocking on login fields (WCAG 3.3.8) |
Every finding names the WCAG 2.2 success criterion it fails. Results show up as inline PR annotations, in GitHub Code Scanning (via SARIF), and in one sticky PR comment.
Built to stay quiet when unsure
A linter that cries wolf gets turned off, so the rules are conservative:
- A rule only fires on components it can trace back to an
antdimport (named, aliased, namespaced,antd/es/*, or destructured likeconst { Item } = Form). - Spread props,
ids and custom children count as "may be labelled". - Each rule's claim about antd's markup is checked in CI by rendering real antd 5 and 6 and inspecting the DOM.
If your team wraps antd in its own components, declare them as aliases and the rules follow through:
- uses: cstayyab/antd-a11y-action@v0
with:
aliases: |
AppButton: Button
AppSelect: Select
Adopt it gradually on an existing codebase
Per-rule severity lets you ratchet instead of fixing everything on day one:
rules: |
modal-has-title: error # already clean, keep it that way
picker-has-name: warn # backlog, visible but not blocking
Same check, locally
As of v0.10 the engine also ships as an ESLint plugin, so you catch issues before you push:
npm install --save-dev eslint-plugin-antd-a11y
// eslint.config.js
import antdA11y from 'eslint-plugin-antd-a11y';
import tseslint from 'typescript-eslint';
export default [
...tseslint.configs.recommended,
...antdA11y.config(),
];
It reads the same config file as the action, so eslint fails exactly when the PR check would.
Runtime mode
Static analysis can't see everything: portals, client state, and markup inside third-party components. An optional runtime sub-action starts your app, crawls your routes with Playwright + axe, and can open modals and tabs through an interactions script.
In Next.js (15.3+) it goes one step further. It injects a guard that patches the JSX runtime, so axe findings are traced back to the line in your code that rendered the offending component, not a CSS selector.
I need your feedback before 1.0
The action is in beta (v0.10). Before I lock the inputs for 1.0, I'd love feedback from people running antd in real codebases:
- False positives or misses. Wrapper components and design-system layers are where I expect gaps.
- Baseline adoption. A baseline file is planned for 1.0 so large apps can start without fixing everything first. How would you want it to work?
- Config naming. If an input or option confuses you, now is the time to tell me.
- Runtime setups beyond Next.js: Vite, Remix, apps behind a sign-in.
👉 github.com/cstayyab/antd-a11y-action
Open an issue or leave a comment here. "It flagged something it shouldn't have" is exactly the kind of report I'm after. And if it's useful, a ⭐ helps other antd teams find it.
Top comments (0)