Semantics Before ARIA
The first rule of ARIA is not to use ARIA. A native element brings role, focusability, keyboard behaviour and default actions; ARIA brings a label in a tree and nothing else.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
Why is a native element better than a div plus ARIA, when the accessibility tree ends up looking the same?
A person wants to press the thing that says Save — with a mouse, with a thumb, with the Tab and Enter keys, with a switch, or by speaking its name — and have it behave like every other button they have ever pressed.
Native elements are hard to style, so build the control from a div and add role="button". The accessibility tree ends up with the same role, so the outcome is the same and the CSS is much easier.
The tree is the same and the component is not. role="button" does not make the div focusable, so it never appears in the tab order and a keyboard user cannot reach it at all.
- The tree is the same and the component is not.
role="button"does not make thedivfocusable, so it never appears in the tab order and a keyboard user cannot reach it at all. - Enter and Space do nothing. The browser dispatches activation for a
button; for adivwith a role, there is no activation behaviour to dispatch (preventDefault vs stopPropagation). - A click handler alone fires for a mouse and for Enter on a link-shaped thing, but not for Space, not for a switch device's activation, and not always for voice control's "click Save" (Keyboard Events).
- The control does not participate in a form: no submit on Enter, no
disabledpropagation, no association with afieldset(Submission: Method, Encoding and Doing It Once). - Windows high-contrast and forced-colours modes restyle native controls automatically and leave your
divlooking like whatever your CSS said, which in forced colours is frequently invisible (Contrast, Colour and Motion). - A link built the same way loses the entire browser context menu: open in new tab, copy address, middle-click, and the status bar preview all come from
href, not fromrole="link".
What is actually happening
In the browser, not in the framework.
- A native interactive element is a bundle: implicit role, membership of the tab order, keyboard activation, a default action, form participation, browser-managed state, platform styling hooks, and a set of screen-reader heuristics tuned over decades for that exact element.
- ARIA is an override of the accessibility tree projection only. It changes what the browser reports. It adds no event handling, no focusability, no default action, no state tracking, and no styling.
- That asymmetry is the whole lesson:
role="button"is a *promise* that the thing behaves like a button. The browser believes you and tells the screen reader so. Whether the promise is true is entirely up to code you now have to write. - Screen readers additionally apply element-specific heuristics that ARIA equivalents do not always trigger — table navigation mode, form mode on native inputs, list item counting, heading navigation. Matching a role is not the same as matching the treatment.
- The native element also participates in things you did not ask for and would not have thought of:
Ctrl+Fbrowser find, reader mode, translation tooling, password managers, autofill, print styles, and the browser's own accessibility repairs.
What this makes the browser do
And which of it is avoidable.
- Native controls are implemented inside the engine, so their keyboard handling, activation, focus ring and state changes cost essentially nothing on the main thread. A re-implementation costs a listener per instance, plus whatever the handler does (Event Delegation).
- A
div-based control usually needs at leasttabindex,keydown,click, and state attributes kept in sync — four things per instance that can drift out of agreement with each other. - Every ARIA attribute set from JavaScript is an attribute mutation, which invalidates the node's accessibility entry and can invalidate style if you selected on it. Toggling six attributes where one native state change would do is real work in both trees (Style Invalidation).
What you inherit, and what you take on
Write the two versions side by side and count the obligations. The native version has one line and no obligations. The ARIA version has a role and a full specification's worth of behaviour you are now responsible for implementing and maintaining.
The list below is not exhaustive and is already long. Every row is something a user relies on, that the browser was doing for free, and that now lives in your codebase where it can regress.
<div role="button" tabindex="0"
class="btn"
onclick="save()"
onkeydown="if (event.key === 'Enter' || event.key === ' ') save()">
Save
</div>
<!-- Still owed: preventDefault on Space so the page does not scroll,
activation on key-up not key-down to match platform behaviour,
aria-disabled plus event suppression when disabled,
:focus-visible styling because there is no native ring to keep,
form submission, forced-colors styling, and the click that
voice control and switch devices synthesise. --><button type="submit" class="btn">Save</button>
Not because it is shorter. Because focusability, Enter, Space with correct key-up timing and scroll suppression, the disabled state including event suppression, form submission, the platform focus indicator, forced-colours restyling and screen-reader forms-mode behaviour are implemented inside the browser, tested by the browser vendors, and identical on every site the user has ever visited.
| Concern | `<button>` | `<div role="button">` | Who implements it |
|---|---|---|---|
| Role in the accessibility tree | Implicit | From the role attribute | Browser, either way |
| In the tab order | Yes | Only with tabindex="0" | You |
| Enter activates | Yes | No | You |
| Space activates, on key-up, without scrolling | Yes | No | You |
| Fires a click for switch and voice activation | Yes | Not reliably | You |
disabled removes from tab order and blocks events | Yes | No — aria-disabled does neither | You |
| Submits the enclosing form | Yes | No | You |
| Restyled in forced-colours mode | Yes | No | You |
| Works before JavaScript has run | Yes, inside a form | No | Nobody |
When ARIA is the right answer
"Do not use ARIA" is shorthand, and taken literally it produces its own failures — pages with no live regions, no aria-expanded, no aria-current, and disclosure widgets whose state is invisible in the tree. ARIA exists because HTML does not model everything.
The useful formulation is: ARIA is for information the platform has no element for, added on top of elements that are already correct. Tabs, comboboxes, tree views and live regions have no HTML equivalent, so a specified implementation with ARIA is the only option. Expanded state, current page, described-by relationships and set positions are additive facts about native elements.
The failure to watch for is the third category — ARIA used to *repair* a wrong element. role="button" on a div, role="heading" on a styled paragraph, role="list" on a container whose CSS removed its list semantics. The last of those is a real and legitimate repair; the first two are the element chosen wrongly.
I need a control the platform does not seem to offer. What am I actually looking at?
when Buttons, links, checkboxes, radios, selects, text inputs, dialogs, disclosure widgets. Nearly always this one.
cost A design conversation, and accepting some per-platform visual difference. Cheapest option by a wide margin.
when A button that opens a panel needs aria-expanded; a nav link that is the current page needs aria-current; an input needs its hint and error connected with aria-describedby.
cost A handful of attributes you must keep in sync with the real state. Low cost, high value, easy to let go stale.
when Tabs, combobox with filtering, tree view, listbox with multi-select, live status announcements, toolbar with roving focus.
cost A full pattern specification — role, name, state, every key, focus rules, announcements — plus manual screen-reader testing on each release (Accessible Component Patterns).
when list-style: none drops list semantics in some engines; display: contents and display: grid on tables and lists can flatten structure out of the tree.
cost Restoring the role explicitly (role="list") — a legitimate repair, and one you only find by inspecting the tree.
The bill for one custom checkbox
Concretely: what does it cost to rebuild the simplest possible form control? A checkbox is a single boolean. The native one is one tag. The specification below is what an equivalent div owes, and each line is a defect if it is missing.
Read it as an estimate rather than as discouragement. Sometimes the answer really is to build it — but the estimate should be this list, not "it is just a div with a click handler", and the result belongs in a shared component that gets tested once (Drawing Component Boundaries).
semantics role="checkbox" with aria-checked="true" | "false" | "mixed", plus tabindex="0", plus an accessible name — from wrapping text, aria-labelledby, or a label that no longer associates automatically because the element is not a form control.
| Space | Toggles. Must fire on key-up, and must call preventDefault() on key-down or the page scrolls underneath. |
| Enter | Does *not* toggle a checkbox — it submits the form. Getting this wrong is a difference users feel immediately. |
| Tab / Shift+Tab | Moves in and out. Requires tabindex="0", and requires removing it when the control is disabled. |
| Click on the label | Native label for forwards the click and the focus. A custom control must forward both by hand. |
- — Focus must be visible on the custom control, not on a hidden real input somewhere else in the DOM.
- — A disabled control must leave the tab order —
aria-disabledalone keeps it focusable, sotabindexhas to change too. - — Focus must not move on toggle; the user is mid-form and expects to stay where they are.
- — On focus: name, role "checkbox", and current state — "Subscribe to updates, checkbox, not checked".
- — On toggle: the new state, announced by the AT because
aria-checkedchanged. No live region belongs here. - — Grouped checkboxes need the group name too, which native markup gets from
fieldsetandlegendand adivgets fromrole="group"plus a label.
usually broken by The visually-hidden real input plus a styled sibling — a common and mostly sound technique — done with display: none on the input. That removes it from the accessibility tree entirely, so the styled thing has no semantics and the real thing does not exist. Use the clipping technique, or appearance: none on the input itself, and keep the element in the tree.
How to build it
Most important first.
- Start from the element list, not the CSS. If there is a native element for the job —
button,a[href],input,select,textarea,details/summary,dialog,label,table,fieldset— use it, and negotiate the visual design around it. - Reach for ARIA when there is no native equivalent: tabs, a combobox with a filtered listbox, a tree view, a live region, an expanded/collapsed state on a custom disclosure,
aria-currentfor the active navigation item, a landmark that needs a name. - Reach for ARIA to add information to a correct native element —
aria-describedbyfor hint and error text,aria-expandedon a realbuttonthat opens a panel,aria-controlswhere the relationship is genuinely useful. This is the most common legitimate use and it is additive, not corrective. - When you genuinely must rebuild a control, write its specification first — role, name, state, every key, focus behaviour, every announcement — and treat it as the component's contract (What a Component Owes Its Caller).
- Prefer restyling the native element to replacing it.
appearance: none,accent-color, custom::file-selector-buttonstyling,<summary>markers and<dialog>backdrops cover far more visual ground than they did a few years ago. - If a native element is unstylable for a real design reason, say so in the code, in a comment, next to the re-implementation. The next person needs to know it was a decision.
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- Everything a native element gives you is an accessibility feature: tab order membership, activation keys, state exposure, disabled semantics, form association, forced-colours styling and the AT's own element-specific behaviour.
- ARIA never adds behaviour. If you write a role, you owe every interaction that role implies, on the keyboard, forever (The Rules of ARIA).
- A clickable
divwith no role is worse than adivwith a role, and both are worse than the element. Adding a role to something that still cannot be reached converts a silent omission into an active lie. - Custom controls need testing with more than one screen reader, because the difference between native and ARIA is exactly where their heuristics diverge (Accessibility Testing).
What can go wrong
- The
divbutton that works, until keyboard testing: focusable via an addedtabindex="0", activated by Enter because someone wrote a handler, and completely dead on Space. - Two sources of truth for state: a
classfor the styling and anaria-*for the tree, updated in different code paths, drifting apart under any interesting sequence of events. - Re-implementing a
selectand losing the platform picker on mobile — the native one is a full-screen OS control on many devices, and no web replacement matches it for one-handed use. - The mitigation failing: a shared
<Button>component in the design system that renders adivinternally, so the fix has to happen in one place — which is good — but the bug is now in a hundred places at once (Design Systems). - Nested interactives: a
buttoninside ana, or a clickable card wrapping a real button. The tree exposes something no user can operate cleanly and every screen reader announces differently.
- A native
buttoninside aformsubmits with the browser's own navigation and cookie handling; a JavaScript re-implementation submits with whatever your code does, which is where CSRF tokens get forgotten (Cross-Site Request Forgery). - A re-implemented link that navigates with
location.href = userSuppliedUrlacceptsjavascript:anddata:URLs that a realhrefin a CSP-protected page would not execute (Cross-Site Scripting). - Client-side
disabled, native or ARIA, is a usability signal. The server is the only place a permission is enforced (Authorization-Aware UI).
- "ARIA makes it accessible." ARIA describes. It never implements. A role with no behaviour behind it is a false statement the browser is now repeating to the user.
- "Adding
tabindex="0"fixes thediv." It fixes reachability. It does not add Enter or Space activation, does not add the role, and does not add form participation. - "Native elements cannot be styled." Far less true than it was.
appearance,accent-color,::backdrop,::file-selector-button,<summary>marker control and<dialog>styling cover most real designs. - "No ARIA in this file means no accessibility." The opposite is usually true. Correct HTML with no ARIA at all is the target state; a file dense with ARIA is more often a file full of re-implemented controls (Div Soup: How It Happens and What It Costs).
Measuring it, and what changes in the field
- The keyboard. Tab to the control, press Enter, press Space, press Escape where it applies. This takes seconds and finds the majority of
div-button defects. - DevTools accessibility pane: check that the role is what you meant and that the name is non-empty and matches the visible text.
- Automated rules catch a subset here —
roleon a non-focusable element, missing names, invalid role values — and cannot tell you that Space does nothing (Accessibility Testing). - Search the codebase for
onClickon non-interactive elements. It is a crude signal and it is a remarkably productive one.
- On touch devices the gap widens: native controls get the platform's own picker, keyboard, and selection behaviour, and the on-screen keyboard adapts to
inputmodeandtypeon real inputs (Input Types, Inputmode and Autocomplete). - Under a screen reader's browse mode, native elements are enumerated in the elements list — buttons, links, form fields, headings, landmarks — and a role-only
divis enumerated inconsistently. - On a slow device, an interactive
divis dead until its JavaScript has loaded and run; abuttoninside aformand ana[href]work from the first paint (Hydration). - With JavaScript failed, blocked or still downloading, native form controls and links still work. This is not a hypothetical: it is the first several seconds of every page load on a slow connection.
- Native elements constrain visual design, sometimes severely, and some of the constraints are genuinely frustrating —
selectoption styling being the standing example. The honest answer is a design negotiation, not adiv. - Custom controls are sometimes correct: a combobox with rich options, a date picker with domain rules, a multi-select the platform does not model. The cost is a full specification and continuous testing, and it should be paid deliberately once, in a shared component, not incidentally in a feature branch.
- Using platform behaviour means accepting platform *differences* — the focus ring, the activation timing, the mobile picker. Consistency across browsers is a thing you give up in exchange for correctness on each of them.
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALThat ARIA alters semantics without altering behaviour is definitional across every engine — it is what the specification says ARIA is. No browser has ever attached keyboard handling to a role.
- PLATFORM-SPECIFICWhich native affordances you lose varies by platform: on Android and iOS a
selectopens an OS picker with no web equivalent, while on Windows native controls are restyled automatically in high-contrast mode and a customdivcontrol is not. Screen readers also treat native form controls differently from ARIA equivalents — NVDA and JAWS switch into forms mode on real inputs more reliably than on custom widgets. - BROWSER-SPECIFICStyling ceilings differ: Chromium and Firefox allow more of
selectandprogressto be restyled than WebKit, and<dialog>and::backdropsupport landed at different times, so "what can be styled natively" is a question with a per-browser answer that keeps moving.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Software Design — the general form of this argument: a platform primitive carries invariants that a re-implementation has to restate, and every restatement is a place they can drift apart.
- — Testing & Reliability Engineering — component tests that query by role and accessible name rather than by class or test id, so that replacing a native element with a
divfails the test rather than passing it silently.