Web accessibility and ARIA attributes
Web accessibility (a11y) is the practice of building websites that work for people using screen readers, keyboard-only navigation, and other assistive technologies. ARIA attributes are the tool you reach for when semantic HTML alone doesn't carry enough information.
Theory
TL;DR
- ARIA is like braille labels on a museum exhibit: the exhibit (HTML) already describes itself, but braille fills gaps for visitors who need it
- Semantic HTML elements (
<button>,<nav>,<main>) tell screen readers what they are automatically; ARIA does the same for generic<div>s and custom widgets - Decision rule: reach for ARIA only when HTML has no matching native element for what you're building
- Dynamic alerts, modals, and sliders almost always need ARIA; static content usually doesn't
Quick example
<!-- Native HTML: screen reader says "Submit, button" with zero extra code -->
<button>Submit</button>
<!-- ARIA fills the gap for dynamic content injected by JS -->
<div role="alert" aria-live="polite">Form submitted!</div>
<!-- Screen reader announces this immediately, even when JS adds it to the DOM -->
<!-- Custom modal: HTML has no native equivalent -->
<div role="dialog" aria-modal="true" aria-labelledby="modal-title">
<h2 id="modal-title">Confirm Action</h2>
<button>Close</button>
</div>The first example needs nothing. The second two show where ARIA earns its place.
Key difference
Semantic HTML elements map directly to roles in the browser's Accessibility Tree. <button> becomes a node with role="button", a computed name from its text content, and built-in keyboard behavior - no extra code needed. ARIA attributes like role="button" or aria-expanded write to the same tree manually, but they don't add behavior. A <div role="button"> without tabindex="0" isn't focusable. Without a keydown handler, Enter and Space do nothing. Native HTML handles all of that automatically.
When to use ARIA
- Static pages with forms, links, and navigation: semantic HTML only
- Error messages or status updates added by JS:
role="alert"oraria-live="polite"on the container - Modals:
role="dialog"+aria-modal="true"+aria-labelledbypointing to the title element - Custom widgets with no HTML equivalent (sliders, tree views, tab panels): ARIA roles combined with keyboard event handlers
- Decorative icons inside labeled buttons:
aria-hidden="true"to prevent double-reading - Skip ARIA when a native semantic tag already covers the use case
How the accessibility tree works
Browsers parse HTML and build the Accessibility Tree alongside the DOM. Screen readers query it through OS-level APIs: Microsoft's UI Automation on Windows, Apple's Accessibility API on macOS and iOS. <button> gets a node with role, name, and state already set. ARIA attributes override or extend those properties at render time - Blink and WebKit inject aria-expanded or aria-valuenow directly into the tree.
Live regions work differently. When JS mutates a live region's content, the browser sends a tree diff notification to the screen reader right away, without waiting for the user to navigate there. That's why adding text to a plain <div> is silent, but the same change inside <div aria-live="polite"> gets announced.
Common mistakes
Adding role="button" to a <button>:
<button role="button">Click</button>No real harm, but screen readers ignore the redundant role. Remove it and use native semantics.
Using aria-label alongside a visible <label>:
<label>Username: <input aria-label="Username input" /></label>
<!-- Screen reader reads: "Username input, input" — the two labels collide -->Let the <label> element do the work. Remove aria-label when a proper label association already exists.
Forgetting aria-live on dynamic updates:
setError('Invalid email');
// <div id="error">{error}</div> — silent to screen readersFix: add role="alert" or aria-live="assertive" to the container before any error content lands in it.
aria-expanded without toggling the value in JS:
The attribute announces expansion state to screen readers. If the JS never flips it from false to true, the reader tells the user the menu opened when it didn't. Sync the attribute on every interaction.
Real-world usage
- React: Material UI tabs use
role="tablist"witharia-selectedon each tab item - Next.js:
aria-current="page"on the active<Link>for navigation landmarks - Vue: Vuetify data tables add
aria-sortto sortable column headers - Angular CDK: overlays apply
aria-labelviang-attr-aria-label - Testing: Chrome DevTools Accessibility pane shows the computed tree; axe-core catches missing labels and incorrect roles automatically
Follow-up questions
Q: What's the difference between role="alert" and aria-live="polite"?
A: role="alert" sets aria-live="assertive" automatically and interrupts whatever the screen reader is currently reading. Use it for error messages. aria-live="polite" queues the announcement until the reader finishes its current sentence - good for non-urgent status messages.
Q: How does aria-hidden="true" differ from display: none?
A: aria-hidden removes an element from the Accessibility Tree while keeping it visible in the DOM. Use it for decorative icons or animations you don't want read aloud. display: none removes it from both the visual layout and the Accessibility Tree completely.
Q: Why is tabindex="0" on a <div> a problem?
A: It makes the element focusable without giving it any interactive role, keyboard behavior, or ARIA state. Tab order hits a dead end for keyboard users. Only add tabindex="0" when building a full custom widget with all the keyboard handling that goes with it.
Q: Explain WCAG 4.1.2 Name, Role, Value.
A: Assistive tech needs three things from every interactive element: its name (from text content, aria-label, or aria-labelledby), its role (from the HTML tag or role attribute), and its current state or value (aria-valuenow, aria-checked, aria-expanded). ARIA makes this possible for elements HTML alone can't describe.
Q: (Senior) In a React portal modal, how does focus trapping work with ARIA, and what can go wrong with SSR hydration?
A: Set aria-modal="true" so screen readers treat the dialog as the only active region, then add a focus trap that intercepts Tab and Shift+Tab. On hydration mismatch, the portal may not exist in the initial server-rendered HTML, so the modal mounts without focus. Fix: call containerRef.current.focus() inside useEffect after mount, and apply the inert attribute to background content. VoiceOver on Safari has a known issue where focus escapes portals - always test that combination separately.
Examples
Semantic HTML vs ARIA roles
<!-- Static nav: semantic HTML, no ARIA needed -->
<nav>
<a href="/" aria-current="page">Home</a>
<a href="/about">About</a>
</nav>
<!-- Dynamic error: ARIA live region required -->
<form>
<label for="email">Email</label>
<input type="email" id="email" aria-describedby="email-error" />
<!-- role="alert" makes this announce the moment error text appears -->
<div id="email-error" role="alert" aria-live="assertive"></div>
</form>
<!-- Decorative icon inside a labeled button -->
<button aria-label="Close dialog">
<svg aria-hidden="true" focusable="false">...</svg>
</button>The nav example uses aria-current="page" on the active link. That's ARIA, but it's the right call because HTML has no native "current page" concept. Everything else is plain semantic HTML doing its job.
Accessible modal in React
import { useState, useEffect, useRef } from 'react';
function Modal() {
const [isOpen, setIsOpen] = useState(false);
const modalRef = useRef(null);
useEffect(() => {
// Move focus into the modal when it opens
if (isOpen) modalRef.current?.focus();
}, [isOpen]);
return (
<>
<button onClick={() => setIsOpen(true)}>Delete account</button>
{isOpen && (
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabIndex={-1}
style={{ position: 'fixed', background: 'white', padding: 24 }}
>
<h2 id="modal-title">Are you sure?</h2>
<p>This action cannot be undone.</p>
<button onClick={() => setIsOpen(false)}>Cancel</button>
<button>Confirm</button>
</div>
)}
</>
);
}
// NVDA announces "dialog Are you sure?" when the modal opens.
// aria-modal tells the reader to ignore content behind the dialog.One thing I always add in production: an Escape key handler to close the modal and a focus trap that keeps Tab cycling inside the dialog. The ARIA alone isn't enough for complete keyboard accessibility.
Custom ARIA slider with keyboard support
<div
role="slider"
tabindex="0"
aria-valuenow="50"
aria-valuemin="0"
aria-valuemax="100"
aria-label="Volume"
style="width: 200px; height: 20px; background: #ddd;"
></div>
<script>
const slider = document.querySelector('[role="slider"]');
slider.addEventListener('keydown', (e) => {
let val = +slider.getAttribute('aria-valuenow');
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
e.preventDefault(); // stops the page from scrolling
slider.setAttribute('aria-valuenow', Math.min(val + 10, 100));
}
if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
e.preventDefault();
slider.setAttribute('aria-valuenow', Math.max(val - 10, 0));
}
});
</script>The screen reader announces the new value each time aria-valuenow changes. Without e.preventDefault(), arrow keys scroll the page while updating the slider - a bug that's easy to miss in visual testing but immediately obvious when using NVDA.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.