Back to blog
Aug 2026·4 min readAI-generated

Web Accessibility Basics: ARIA Without the Guesswork

#accessibility#html#frontend

The most common ARIA mistake isn't using the wrong attribute — it's reaching for ARIA at all when a native HTML element would have solved the problem for free. ARIA (Accessible Rich Internet Applications) exists to describe custom widgets that HTML has no built-in element for, not to patch up a <div> that's standing in for a <button>. The first rule of ARIA, straight from the spec authors, is: don't use ARIA if you don't have to.

The div-button problem

This shows up constantly in component libraries:

<div class="button" onclick="submitForm()">Submit</div>

Visually it's a button. To a screen reader, it's an unlabeled, non-interactive chunk of text — not focusable with Tab, not announced as a button, not activated by Enter or Space. You can patch all of that back in with ARIA and JavaScript:

<div
  class="button"
  role="button"
  tabindex="0"
  onclick="submitForm()"
  onkeydown="if (event.key === 'Enter' || event.key === ' ') submitForm()"
>
  Submit
</div>

Or you can use the element that already does all of this:

<button onclick="submitForm()">Submit</button>

Keyboard support, focus handling, the accessible name, the correct role — all free, all consistent across every browser and screen reader, zero JavaScript required. This is the pattern to internalize: reach for <button>, <a href>, <input>, <select>, and friends first, and only step outside HTML's native semantics when you're building something HTML genuinely has no element for, like a tab panel or a combobox with autocomplete.

When ARIA earns its place: labeling

Even with native elements, some things need extra context that visible text doesn't convey. An icon-only button is the classic case:

<button aria-label="Close dialog">
  <svg><!-- X icon --></svg>
</button>

Without aria-label, a screen reader announces this as just "button" — technically focusable and activatable, but the user has no idea what it does. aria-label supplies an accessible name when there's no visible text to serve as one. If there is visible text nearby but it's not inside the button itself, aria-labelledby points to it by ID instead of duplicating it:

<h2 id="billing-heading">Billing Address</h2>
<button aria-labelledby="billing-heading">Edit</button>

This announces as "Edit, Billing Address" — more useful than a bare "Edit" when a page has five identical edit buttons for five different sections.

Describing state on custom widgets

This is where ARIA is doing real, load-bearing work: custom components need to announce state changes that native elements handle automatically. A collapsible section:

<button aria-expanded="false" aria-controls="panel-1" id="toggle-1">
  Show details
</button>
<div id="panel-1" hidden>
  ...
</div>
const toggle = document.getElementById('toggle-1');
const panel = document.getElementById('panel-1');

toggle.addEventListener('click', () => {
  const isExpanded = toggle.getAttribute('aria-expanded') === 'true';
  toggle.setAttribute('aria-expanded', String(!isExpanded));
  panel.hidden = isExpanded;
});

aria-expanded has to be kept in sync with the actual visible state in JavaScript — ARIA attributes never do anything on their own, they're purely informational for assistive technology. Set aria-expanded="false" and never update it, and a screen reader user will be told the panel is collapsed forever, even after they've opened it.

Live regions for dynamic updates

When content changes without a page navigation or a focus change — a form validation error appearing, a "saved" toast, search results updating — sighted users see it happen, but a screen reader has no reason to announce something outside the user's current focus unless you tell it to.

<div aria-live="polite" id="save-status"></div>
document.getElementById('save-status').textContent = 'Changes saved.';

aria-live="polite" tells assistive technology to announce the change once the user pauses whatever they're currently doing — appropriate for status messages. Use aria-live="assertive" only for things that genuinely need to interrupt, like a session-expiry warning; overusing it makes a page exhausting to use with a screen reader, since every minor update cuts off whatever was just being read.

The rule that catches the most bugs

If you only take one habit from this: after adding any role or aria-* attribute, ask what native element could have avoided the need for it. role="button" on a <div> → should've been <button>. role="navigation" on a generic wrapper → should've been <nav>. aria-required="true" on a text input → should've been the required attribute, which conveys the same thing to assistive tech and also gets you free browser-level validation.

Then, for the ARIA you do keep: run it through a screen reader once. VoiceOver ships with every Mac (Cmd+F5 to toggle) and NVDA is free on Windows. Five minutes of navigating your own interface by keyboard and ear catches more real problems than reading the ARIA spec cover to cover — you'll immediately notice an unlabeled icon button or a live region that never fires, because you'll be the one confused by the silence.

Takeaway

ARIA supplements HTML's semantics for the cases HTML doesn't cover — it doesn't replace using the right element in the first place. Start with native HTML, add aria-label/aria-labelledby where the accessible name isn't obvious from visible text, use state attributes like aria-expanded only when you also keep them in sync with reality, and reserve live regions for updates a user genuinely needs to hear about outside their current focus.

NewerWriting a Simple Command-Line Tool in Node.jsOlderA Practical Guide to Git Branching Strategies