Skip to content

Semantic HTML vs ARIA

intermediate20 min read

The First Rule of ARIA: Don't Use ARIA

This sounds like a joke, but it's the official W3C recommendation. The first rule of ARIA use is: if you can use a native HTML element with the semantics and behavior you need already built in, use that instead of adding ARIA.

Why? Because native HTML elements come with accessibility for free. A button element is focusable, responds to Enter and Space, announces itself as "button" to screen readers, and handles disabled states — all without a single line of ARIA. The moment you reach for a div with role="button", you've just signed up to manually recreate every one of those behaviors.

Mental Model

Think of HTML elements like appliances that come fully assembled. A button is a dishwasher — plug it in and it works. A div with role="button" is a pile of parts with an instruction manual in a language you barely speak. You can build a working dishwasher from parts, but why would you when there's one sitting right there in the box?

Most accessibility bugs in production aren't caused by missing ARIA. They're caused by unnecessary ARIA that was applied incorrectly. You'll see role="button" on a div that doesn't handle keyboard events. Or aria-label that contradicts the visible text. Or aria-hidden="true" that accidentally hides content from screen reader users who need it.

ARIA is a power tool. Power tools are great — when you actually need them. But most of the time, a screwdriver (semantic HTML) does the job better and safer.

Quiz
What is the first rule of ARIA according to the W3C?

Implicit Roles: What HTML Gives You for Free

Every semantic HTML element carries an implicit ARIA role — a role the browser assigns automatically without you writing a single attribute. This is the magic that makes semantic HTML accessible out of the box.

HTML ElementImplicit ARIA RoleWhat Screen Readers Announce
buttonrole=button"Submit, button" — focusable, activatable with Enter/Space
a (with href)role=link"Home page, link" — focusable, activatable with Enter
input type=checkboxrole=checkbox"Accept terms, checkbox, not checked"
input type=textrole=textbox"Username, edit text"
selectrole=combobox or listbox"Country, combobox, collapsed"
navrole=navigation"Navigation" landmark — users can jump to it
mainrole=main"Main" landmark — the primary content area
headerrole=banner"Banner" landmark (when direct child of body)
footerrole=contentinfo"Content info" landmark (when direct child of body)
ul / olrole=list"List, 5 items" — announces item count
tablerole=table"Table, 3 columns, 5 rows" — enables table navigation
formrole=form"Form" landmark (when it has an accessible name)
asiderole=complementary"Complementary" landmark for side content
h1-h6role=heading (level 1-6)"Heading level 2" — enables heading navigation

Here's the key insight: when you write <button>Submit</button>, you're not just styling text to look clickable. You're communicating semantic meaning to every assistive technology. The browser tells the screen reader "this is a button called Submit." The screen reader tells the user. No ARIA required.

But when you write <div>Submit</div> and style it to look like a button, the browser tells the screen reader "this is a generic text container with the word Submit." The screen reader has no idea it's interactive. The user can't Tab to it. Pressing Enter does nothing.

Quiz
What implicit ARIA role does a nav element have?

When ARIA Is Actually Necessary

So if native HTML is so great, why does ARIA exist at all? Because native HTML doesn't cover every UI pattern modern applications need. There are legitimate cases where ARIA is the right — and only — tool.

Custom Widgets Without Native Equivalents

HTML has no native tab panel, tree view, combobox with autocomplete, carousel, or dialog (well, dialog exists now, but older patterns predate it). When you build these custom widgets, ARIA is how you communicate their behavior to assistive technology.

<!-- Tab interface — no native HTML equivalent -->
<div role="tablist" aria-label="Account settings">
  <button role="tab" aria-selected="true" aria-controls="panel-1">
    Profile
  </button>
  <button role="tab" aria-selected="false" aria-controls="panel-2">
    Security
  </button>
</div>
<div role="tabpanel" id="panel-1" aria-labelledby="tab-1">
  <!-- Profile content -->
</div>

Dynamic Content Updates

When content changes without a page reload — a toast notification, a live chat message, a form validation error — the screen reader has no way to know something changed unless you tell it.

<!-- Live region — screen reader announces new content automatically -->
<div aria-live="polite" aria-atomic="true">
  3 items added to cart
</div>

<!-- Assertive for urgent messages -->
<div role="alert">
  Your session expires in 2 minutes
</div>

Relationships Between Elements

Sometimes an element's label or description lives in a completely different part of the DOM. ARIA lets you create those relationships.

<h2 id="billing-heading">Billing Address</h2>
<p id="billing-desc">Enter the address associated with your payment method.</p>
<form aria-labelledby="billing-heading" aria-describedby="billing-desc">
  <!-- The form is now named and described by those elements -->
</form>

State That HTML Can't Express

Native HTML can express "checked" and "disabled." But what about "expanded," "pressed," "current page," or "sorted column"? These states require ARIA.

<button aria-expanded="false" aria-controls="dropdown-menu">
  Menu
</button>

<a href="/dashboard" aria-current="page">Dashboard</a>

<th aria-sort="ascending">Date</th>
Quiz
Which of these scenarios genuinely requires ARIA rather than native HTML?

The Five Rules of ARIA

The W3C defines five rules for using ARIA correctly. These aren't suggestions — they're the difference between making your app accessible and making it worse.

Rule 1: Don't Use ARIA If Native HTML Works

We've covered this. Always prefer semantic HTML. Use ARIA only when there's no native equivalent.

Rule 2: Don't Change Native Semantics Unless You Really Have To

<!-- Bad — h2 already has heading semantics, don't override them -->
<h2 role="tab">Settings</h2>

<!-- Good — use the right element for the role -->
<button role="tab">Settings</button>

<!-- Also acceptable when design constraints require it -->
<div role="tab" tabindex="0">Settings</div>

Changing the semantics of a native element confuses assistive technology. A heading that announces itself as a tab breaks heading navigation.

Rule 3: All Interactive ARIA Controls Must Be Keyboard Accessible

If you give something role="button", it must respond to Enter and Space. If you give something role="link", it must respond to Enter. No exceptions.

<!-- BROKEN — role=button but no keyboard handling -->
<div role="button" onclick="save()">Save</div>

<!-- FIXED — keyboard support + focus -->
<div
  role="button"
  tabindex="0"
  onclick="save()"
  onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();save()}"
>
  Save
</div>

<!-- BEST — just use a button -->
<button onclick="save()">Save</button>

Rule 4: Don't Use role="presentation" or aria-hidden="true" on Focusable Elements

Hiding an element from the accessibility tree while leaving it focusable creates a "ghost" — the user can Tab to something they can't perceive. This is deeply confusing.

<!-- BROKEN — focusable but invisible to screen readers -->
<button aria-hidden="true">Close</button>

<!-- BROKEN — role removes semantics but element is still focusable -->
<a href="/home" role="presentation">Home</a>

<!-- If you truly need to hide it from everyone -->
<button aria-hidden="true" tabindex="-1">Close</button>

Rule 5: All Interactive Elements Must Have an Accessible Name

Every interactive element needs a name that assistive technology can announce. No name = the screen reader says "button" with no indication of what the button does.

<!-- BAD — screen reader says "button" with no name -->
<button>
  <svg><!-- icon --></svg>
</button>

<!-- GOOD — aria-label provides the name -->
<button aria-label="Close dialog">
  <svg><!-- icon --></svg>
</button>

<!-- ALSO GOOD — visually hidden text -->
<button>
  <svg><!-- icon --></svg>
  <span class="sr-only">Close dialog</span>
</button>
Key Rules
  1. 1Use native HTML elements before reaching for ARIA — they provide keyboard handling, focus management, and semantics for free
  2. 2Never change the semantics of a native element unless the design absolutely requires it
  3. 3Every element with an interactive ARIA role must handle the expected keyboard interactions
  4. 4Never hide a focusable element from the accessibility tree — it creates ghost elements users can reach but not perceive
  5. 5Every interactive element must have an accessible name via visible text, aria-label, or aria-labelledby

aria-label vs aria-labelledby vs aria-describedby

These three attributes sound similar but serve very different purposes. Confusing them is one of the most common ARIA mistakes.

aria-label: Inline Name Override

aria-label provides an accessible name directly as a string. It overrides any visible text content. Use it when there's no visible text to serve as the name.

<!-- Icon-only button — no visible text, so we need aria-label -->
<button aria-label="Search">
  <svg><!-- magnifying glass icon --></svg>
</button>

<!-- The screen reader announces "Search, button" -->

When to use: Icon buttons, icon links, inputs without visible labels, navigation landmarks that need distinction (e.g., you have two nav elements and need to tell them apart).

Gotcha: aria-label is invisible to sighted users. If the visible text says "Submit" but aria-label says "Send form," screen reader users and sighted users experience different labels. This violates WCAG 2.5.3 (Label in Name).

aria-labelledby: Name From Another Element

aria-labelledby points to another element whose text content becomes the accessible name. It also overrides visible text content but uses existing visible text instead of creating invisible text.

<h2 id="cart-heading">Shopping Cart</h2>
<section aria-labelledby="cart-heading">
  <!-- Screen reader announces this section as "Shopping Cart, region" -->
</section>

Key differences from aria-label:

  • References visible text, so sighted and screen reader users see the same label
  • Can reference multiple elements: aria-labelledby="first-name-label required-marker"
  • Can reference the element itself (useful for combining text): aria-labelledby="self other"
  • Takes priority over everything — even aria-label and native text content
<!-- aria-labelledby wins over aria-label -->
<span id="btn-text">Save Draft</span>
<button aria-label="Submit" aria-labelledby="btn-text">
  <!-- Screen reader says "Save Draft" — aria-labelledby wins -->
</button>

aria-describedby: Supplementary Description

aria-describedby provides additional context — it does NOT set the name. The screen reader announces the name first, pauses, then announces the description.

<label for="password">Password</label>
<input
  id="password"
  type="password"
  aria-describedby="password-hint"
/>
<p id="password-hint">Must be at least 8 characters with one number.</p>

<!-- Screen reader: "Password, edit text. Must be at least 8 characters with one number." -->

When to use: Form field hints, error messages, tooltips, additional context that supplements (but doesn't replace) the name.

AttributePurposeOverrides Name?Visibility
aria-labelSets the accessible name directlyYes — replaces visible textInvisible to sighted users
aria-labelledbySets the name from another visible elementYes — highest priority of all naming methodsUses existing visible text
aria-describedbyAdds supplementary description after the nameNo — announced after the nameUses existing visible text
Quiz
A button has visible text 'Delete', aria-label='Remove item', and aria-labelledby pointing to a span containing 'Discard changes'. What does the screen reader announce?

The Accessible Name Computation

When a screen reader needs to announce an element's name, the browser follows a specific priority order. Understanding this prevents a whole class of bugs.

  1. aria-labelledby — always wins, even over aria-label
  2. aria-label — used if no aria-labelledby
  3. Native label mechanismlabel for inputs, text content for buttons/links, alt for images, caption for tables
  4. title attribute — last resort, and not reliably announced by all screen readers
<!-- Priority demo -->
<label for="email">Your Email</label>
<input
  id="email"
  type="email"
  aria-label="Email address"
  aria-labelledby="custom-label"
  title="Enter email"
/>
<span id="custom-label">Work Email</span>

<!-- Name = "Work Email" (aria-labelledby wins everything) -->
Don't stack naming methods

Using multiple naming attributes on the same element doesn't combine them — only one wins. This often leads to bugs where a developer adds aria-label thinking it supplements the visible text, but it actually replaces it. Pick one naming method and use it consistently.

Common Mistakes That Make Accessibility Worse

These aren't theoretical — they show up in production codebases constantly. Each one makes the experience worse for assistive technology users than if the developer had done nothing at all.

What developers doWhat they should do
Using div with role=button but no keyboard handling or tabindex
role=button tells screen readers it is a button, but without tabindex the user cannot Tab to it, and without keyboard event handlers they cannot activate it. The element looks interactive but is unreachable — worse than having no role at all.
Use a native button element, or add tabindex=0 and handle Enter and Space keydown events
Adding aria-label that contradicts the visible text (visible says Submit, aria-label says Send form)
WCAG 2.5.3 (Label in Name) requires that the accessible name includes the visible text. Voice control users say what they see — if the visible text says Submit but the accessible name is Send form, the voice command Submit will not activate the button.
Keep aria-label consistent with visible text, or use aria-labelledby to reference the visible text element
Adding role=button to an anchor tag that navigates to another page
Buttons perform actions, links navigate. Changing an anchor to role=button breaks screen reader users' expectations — they expect Enter to activate it (correct) but also expect Space to activate it (which scrolls the page for links). It also breaks link-specific features like open in new tab.
Use a native anchor tag for navigation — it already has role=link and the correct keyboard behavior
Putting aria-hidden=true on a parent container while it contains focusable children
aria-hidden removes elements from the accessibility tree, but focus management is separate. Users can Tab into an aria-hidden region and interact with ghost elements they cannot perceive. The inert attribute properly handles both at once.
If hiding from screen readers, also add tabindex=-1 to all focusable children, or use the inert attribute on the container
Adding redundant ARIA to native elements (role=button on button, role=navigation on nav)
Redundant ARIA is not harmless — it adds maintenance burden, signals that the developer does not understand implicit roles, and in rare cases can cause double-announcement bugs in certain screen reader and browser combinations.
Let native elements use their implicit roles — remove redundant ARIA attributes

When to Reach for ARIA: A Decision Framework

Before adding any ARIA attribute, run through this checklist:

  1. Is there a native HTML element that does this? If yes, use it. button, a, input, select, dialog, details/summary — these cover the vast majority of interactive patterns.

  2. Am I modifying semantics or adding new ones? If you're overriding a native element's role, you're probably using the wrong element. Change the element, not its role.

  3. Am I prepared to handle all keyboard interactions? If you're assigning an interactive role, you must implement the full keyboard interaction pattern from the WAI-ARIA Authoring Practices.

  4. Will this work across screen reader and browser combinations? ARIA support varies. Test in at minimum NVDA + Firefox, VoiceOver + Safari, and JAWS + Chrome.

  5. Does this pass the "remove ARIA" test? If removing the ARIA attribute makes no difference to the user experience, you didn't need it.

The inert attribute: the modern alternative to aria-hidden

The inert attribute is a relatively new HTML attribute that does what many developers wish aria-hidden did — it removes an element from both the accessibility tree AND the tab order in one shot. No more ghost elements.

<!-- Old way: manually managing aria-hidden + tabindex -->
<div aria-hidden="true">
  <button tabindex="-1">Can't reach this</button>
  <a href="/link" tabindex="-1">Or this</a>
</div>

<!-- New way: inert handles everything -->
<div inert>
  <button>Can't reach this</button>
  <a href="/link">Or this</a>
</div>

inert is supported in all modern browsers. Use it when you need to hide entire regions — like background content behind a modal, or inactive tab panels.

Quiz
You need an icon-only close button for a modal. What is the most accessible approach?

ARIA Roles in Practice: A Quick Reference

Not all ARIA roles are created equal. Some you'll use regularly, others are edge cases.

Roles You'll Actually Use

  • role="tablist", role="tab", role="tabpanel" — for tab interfaces
  • role="dialog" / role="alertdialog" — for modals (though the native dialog element is preferred now)
  • role="alert" — for urgent status messages (implicitly sets aria-live="assertive")
  • role="status" — for non-urgent status updates (implicitly sets aria-live="polite")
  • role="menu", role="menuitem" — for application-style menus (NOT for navigation menus — use nav with links)
  • role="tree", role="treeitem" — for hierarchical tree views (file browsers, nested nav)
  • role="switch" — for toggle switches (distinct from checkbox — it's on/off, not checked/unchecked)

Roles You Should Almost Never Use

  • role="button" — just use button
  • role="link" — just use a
  • role="navigation" — just use nav
  • role="heading" — just use h1-h6
  • role="list" — just use ul/ol
  • role="img" — just use img with alt
Common Trap

role="menu" is NOT for site navigation menus. It's for application-style menus like right-click context menus or dropdown action menus in a toolbar. A navigation menu should use nav with a list of links. Using role="menu" on your navbar forces screen reader users into a specific keyboard interaction pattern (arrow keys instead of Tab) that breaks their navigation expectations.

Quiz
A developer adds role=menu to a website navigation bar with links to Home, About, and Contact pages. What problem does this create?

Testing Your ARIA Implementation

Adding ARIA without testing is like writing code without running it. You need to verify with actual assistive technology.

Quick Testing Checklist

  1. Keyboard-only navigation — unplug your mouse and Tab through everything. Can you reach all interactive elements? Can you activate them with Enter/Space? Can you escape modals with Escape?
  2. Screen reader testing — at minimum, test with VoiceOver on macOS (free, built-in). Turn it on with Cmd+F5 and navigate your UI. Does every element announce what you expect?
  3. Browser DevTools — Chrome and Firefox both have accessibility inspectors that show the computed accessible name and role for every element. Check that they match your intent.
  4. Automated tools — axe DevTools, Lighthouse accessibility audit, or eslint-plugin-jsx-a11y catch the low-hanging fruit. But they catch roughly 30% of accessibility issues — manual testing is non-negotiable.
The VoiceOver rotor test

On macOS, open VoiceOver (Cmd+F5), then press VO+U to open the Rotor. This shows all landmarks, headings, links, and form controls on the page. If your ARIA roles and labels are correct, the Rotor will show a clean, navigable structure. If something is missing or mislabeled, you'll see it immediately.