ARIA Best Practices: When to Use It, and When to Delete It
Most ARIA in the wild makes accessibility worse, not better. A practical guide to the five rules of ARIA, the traps in React, and how to know when to reach for a native element instead.
Open the dev tools on almost any component library and search the DOM for role=. You will find dozens of them. A div with role="button". A span with aria-label doing the work a <label> should do. A custom dropdown wired up with role="listbox", aria-expanded, and aria-activedescendant, most of it subtly wrong.
Here is the uncomfortable part: a lot of that ARIA is making things worse. WebAIM's annual Million report has found, year after year, that home pages with ARIA present average more detected errors than pages without it. That is not because ARIA is bad. It is because ARIA is a set of promises you make to a screen reader, and most of those promises get broken the moment the component's state changes and nobody updates the attribute.
If you are shipping React or plain HTML and you want your accessibility work to actually help people, the goal is not "more ARIA." It's the right amount, in the right places, and native elements everywhere else.
The first rule of ARIA is: don't use ARIA
This sounds like a joke. It is the actual first rule from the W3C's own ARIA Authoring Practices. The full version: if you can use a native HTML element or attribute with the semantics and behavior you need already built in, do that instead of repurposing an element and adding an ARIA role.
A <button> is focusable, announces itself as a button, fires on Enter and Space, and participates in forms. A <div role="button"> gives you the announcement and nothing else. You now owe the browser tabindex="0", a keydown handler for both Enter and Space, and a visible focus style, and you will forget at least one of them. Every native element you replace is a list of behaviors you have signed up to reimplement by hand.
The same goes for <a href> for navigation, <input type="checkbox"> for checkboxes, <select> for simple dropdowns, <nav>, <main>, <header>, and <footer> for landmarks. These are not old-fashioned. They are the fastest accessibility wins available, and they cost zero maintenance.
The five rules, in plain terms
The ARIA spec ships with five rules of use. Stripped of jargon, they are:
First, prefer native HTML. Covered above, and it is the one that matters most.
Second, do not change native semantics unless you truly have to. Putting role="heading" on a <button> tells the screen reader it is a heading and hides that it is a button. If you find yourself overriding what an element already is, you have usually picked the wrong element.
Third, all interactive ARIA controls must be usable with the keyboard. If you build a role="menuitem", a keyboard user has to be able to reach it, move between items with arrow keys, and activate it. A role without matching keyboard behavior is a label on an empty box.
Fourth, do not put a role="presentation" or aria-hidden="true" on a focusable element. You will create a control that a sighted mouse user can reach but a screen reader user cannot see, or worse, a focusable element that announces nothing. This is a common and painful bug.
Fifth, every interactive element needs an accessible name. A button that reads as "button, button, button" down the page is useless. The name can come from visible text, aria-label, or aria-labelledby, in roughly that order of preference.
If you only remember two of these, remember one and three: prefer native, and never ship a role without the keyboard behavior it implies.
Where React quietly makes this worse
JSX makes it very easy to build the wrong thing cleanly. A few patterns show up again and again in the scans we run.
The clickable div. Someone needs a card that navigates on click, reaches for <div onClick={...}>, and ships it. It works with a mouse and is invisible to the keyboard and to assistive tech. The fix is almost never ARIA. It is a real <button> or <a>, styled to look like a card.
The label that isn't. A form field with placeholder text and no <label>. Placeholders vanish on input and are not reliably announced. If you must avoid a visible label, use aria-label on the input, but a real <label htmlFor> is better for everyone, including sighted users who miss what a field wants.
The icon button with no name. An icon-only button, <button><TrashIcon /></button>, announces as "button" and nothing else. Add aria-label="Delete item". If the icon is decorative and sits next to text, mark the icon aria-hidden="true" instead so it is not announced twice.
Stale aria-expanded. This is the classic. A disclosure or menu sets aria-expanded="true" on open, and the close path forgets to set it back to false. Now the screen reader announces "expanded" on a collapsed panel forever. In React the fix is to derive the attribute from state, never to toggle it imperatively:
function Disclosure({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false)
return (
<>
<button aria-expanded={open} onClick={() => setOpen((o) => !o)}>
Details
</button>
{open && <div>{children}</div>}
</>
)
}
Because aria-expanded is bound to open, it can never drift out of sync. That is the general principle: ARIA state should be a function of your component state, not a separate thing you remember to update.
When ARIA is exactly the right tool
None of this means avoid ARIA. There is a whole class of problems where native HTML has no answer and ARIA is the correct, intended solution.
Live regions are the clearest example. When content updates without a page reload, a screen reader has no way to know unless you tell it. Wrap the updating area in aria-live="polite" for status messages, or aria-live="assertive" for genuinely urgent ones, and new content gets announced. Toast notifications, form validation summaries, and async loading states all need this, and there is no native equivalent.
Relationships are another. aria-describedby connects an input to its help text or error message so both get read together. aria-labelledby lets several visible elements combine into one accessible name. Landmarks like role="search" on a form give screen reader users a way to jump straight to the search box.
And genuinely custom widgets, comboboxes, tab panels, tree views, do require ARIA because HTML has nothing built in for them. The move here is not to invent the pattern yourself. Open the ARIA Authoring Practices Guide, find the pattern, and copy the roles, states, and keyboard interactions exactly. These patterns are tested against real screen readers, which your first draft is not.
You cannot eyeball this
The reason ARIA bugs are so common is that they are invisible in a normal browser. The page looks fine. It clicks fine with a mouse. The aria-expanded that is stuck on true, the icon button with no name, the focusable element hidden from assistive tech, none of it shows up until someone turns on a screen reader or runs an automated check.
Automated tooling catches a real and useful slice of this. axe-core, the engine we build on, flags missing accessible names, roles without required parent roles, invalid ARIA attribute values, and aria-* referring to IDs that do not exist. Those are exactly the mechanical mistakes that pile up as a codebase grows. It will not catch everything, a wrong-but-valid role still needs a human, but it turns the invisible bugs into a list you can act on. If you want to see how the raw engine compares to a workflow that also fixes what it finds, we wrote that up in our axe DevTools comparison.
That is the gap inklu is built to close. We scan your HTML, CSS, JSX/TSX, and SCSS against WCAG 2.2 using axe-core v4.11 plus our own rules, and instead of handing you a report to triage, we open a GitHub pull request with the fix. A missing aria-label, a div that should be a button, an aria-expanded bound to nothing, the fix arrives as a diff you review and merge, never a commit to your default branch. Source code is purged after each scan, so nothing lingers on our side.
A short checklist before you ship
Before a component with any ARIA on it goes out, ask four questions.
Could a native element do this instead? If yes, use it and delete the ARIA.
Does every interactive element have an accessible name? Tab through and listen, or read the accessibility tree in dev tools.
Does every role have its keyboard behavior? A role="button" needs Enter and Space. A role="tab" needs arrow keys. No exceptions.
Does every ARIA state track your component state? If you are ever calling setAttribute('aria-expanded', ...) by hand in a React app, that is a bug waiting to happen. Bind it.
Get those four right and you will be ahead of most of the web, ARIA-heavy component libraries included.
If you want the mechanical layer handled automatically, so these mistakes get caught and fixed as pull requests instead of surfacing in an audit six months later, book a demo at inklu.io or email hello@inklu.io. You can also see how the plans and token costs work on our pricing page.