AccessibilityGENERALPLATFORM-SPECIFICBROWSER-SPECIFIC

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.

The question

Why is a native element better than a div plus ARIA, when the accessibility tree ends up looking the same?

The user intent

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.

The obvious build

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.

Why it breaks

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.

How it breaks in a real browser
  • 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.
  • Enter and Space do nothing. The browser dispatches activation for a button; for a div with 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 disabled propagation, no association with a fieldset (Submission: Method, Encoding and Doing It Once).
  • Windows high-contrast and forced-colours modes restyle native controls automatically and leave your div looking 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 from role="link".
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

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+F browser 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 least tabindex, 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.

A button, two ways
div plus ARIA
<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. -->
the element
<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 treeImplicitFrom the role attributeBrowser, either way
In the tab orderYesOnly with tabindex="0"You
Enter activatesYesNoYou
Space activates, on key-up, without scrollingYesNoYou
Fires a click for switch and voice activationYesNot reliablyYou
disabled removes from tab order and blocks eventsYesNo — aria-disabled does neitherYou
Submits the enclosing formYesNoYou
Restyled in forced-colours modeYesNoYou
Works before JavaScript has runYes, inside a formNoNobody

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.

Which of these is the situation?

I need a control the platform does not seem to offer. What am I actually looking at?

A native element exists and I do not like how it looks

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.

A native element exists but is missing one fact

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.

No native element models this

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).

CSS removed semantics the element had

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).

accessibility specCustom checkbox built from a divEverything `<input type="checkbox">` was already doing

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.

SpaceToggles. Must fire on key-up, and must call preventDefault() on key-down or the page scrolls underneath.
EnterDoes *not* toggle a checkbox — it submits the form. Getting this wrong is a difference users feel immediately.
Tab / Shift+TabMoves in and out. Requires tabindex="0", and requires removing it when the control is disabled.
Click on the labelNative label for forwards the click and the focus. A custom control must forward both by hand.
Focus
  • 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-disabled alone keeps it focusable, so tabindex has to change too.
  • Focus must not move on toggle; the user is mid-form and expects to stay where they are.
Announces
  • 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-checked changed. No live region belongs here.
  • Grouped checkboxes need the group name too, which native markup gets from fieldset and legend and a div gets from role="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-current for the active navigation item, a landmark that needs a name.
  • Reach for ARIA to add information to a correct native elementaria-describedby for hint and error text, aria-expanded on a real button that opens a panel, aria-controls where 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-button styling, <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 div with no role is worse than a div with 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

Failure modes
  • The div button that works, until keyboard testing: focusable via an added tabindex="0", activated by Enter because someone wrote a handler, and completely dead on Space.
  • Two sources of truth for state: a class for the styling and an aria-* for the tree, updated in different code paths, drifting apart under any interesting sequence of events.
  • Re-implementing a select and 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 a div internally, 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 button inside an a, or a clickable card wrapping a real button. The tree exposes something no user can operate cleanly and every screen reader announces differently.
Security
  • A native button inside a form submits 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 = userSuppliedUrl accepts javascript: and data: URLs that a real href in 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).
Misreads
  • "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 the div." 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

How you would see this
  • 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 — role on a non-focusable element, missing names, invalid role values — and cannot tell you that Space does nothing (Accessibility Testing).
  • Search the codebase for onClick on non-interactive elements. It is a crude signal and it is a remarkably productive one.
Slow device, slow network, large data, old tab
  • 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 inputmode and type on 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 div is enumerated inconsistently.
  • On a slow device, an interactive div is dead until its JavaScript has loaded and run; a button inside a form and an a[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.
What this costs
  • Native elements constrain visual design, sometimes severely, and some of the constraints are genuinely frustrating — select option styling being the standing example. The honest answer is a design negotiation, not a div.
  • 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 select opens an OS picker with no web equivalent, while on Windows native controls are restyled automatically in high-contrast mode and a custom div control 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 select and progress to be restyled than WebKit, and <dialog> and ::backdrop support 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.

Domains that do not exist yet
  • 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 div fails the test rather than passing it silently.