EduFlow

Section 3 · Keyboard, Focus and ARIA

ARIA: Roles, States and Live Regions

18 min3 min readCode playground

Lesson at a glance

Apply ARIA only where HTML falls short, keep states such as aria-pressed and aria-expanded in sync, and announce dynamic updates with status and alert live regions.

  1. 1The first rule of ARIA
  2. 2Roles, properties and states
  3. 3Live regions: announcing changes

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

Everything so far has used plain HTML, and that is deliberate. HTML elements come with roles, names, states and keyboard behaviour built in. But sometimes HTML runs out. There is no native element for a toggle button’s pressed state, for a set of tabs, or for “say this out loud when the basket changes”. That is where ARIA (Accessible Rich Internet Applications) comes in: a set of attributes that let you describe roles, states and relationships directly to the accessibility tree.

The first rule of ARIA

The W3C’s own guidance starts with a rule that surprises people: if you can use a native HTML element with the behaviour you need, do that instead of ARIA. WebAIM’s annual scans of the top million home pages keep finding that pages using ARIA have more detected errors, not fewer. The reason is simple. ARIA only changes what is announced. It doesn’t make anything focusable, doesn’t add keyboard handling, and doesn’t change appearance. Every ARIA attribute is a promise to assistive technology that your JavaScript then has to keep.

HTML
<!-- A promise with nothing behind it -->
<span role="button">Add to basket</span>

<!-- The same promise, kept by the browser -->
<button type="button">Add to basket</button>

Roles, properties and states

ARIA attributes fall into three groups.

Roles say what something is: role="tab", role="dialog", role="switch", role="status". You rarely need them for things HTML already has.

Properties describe relationships or fixed characteristics: aria-labelledby and aria-describedby (you used both on forms), aria-controls, aria-required, aria-haspopup.

States describe things that change as the user interacts, and your script must update them: aria-expanded, aria-pressed, aria-checked, aria-selected, aria-current, aria-disabled, aria-invalid.

Ticketly’s wishlist heart is a good example. It is already a real button, but nothing tells a screen reader whether the event is saved. aria-pressed turns it into a toggle button:

HTML
<button type="button" class="fav" aria-pressed="false">Save to wishlist</button>
ticketly/wishlist.js
fav.addEventListener('click', () => {
  const saved = fav.getAttribute('aria-pressed') === 'true';
  fav.setAttribute('aria-pressed', String(!saved));
});

NVDA now says “Save to wishlist, toggle button, not pressed”, then “pressed” after activation. Notice that the label stays the same; the state carries the change. And you can style from the state directly, which keeps the visuals and the semantics from drifting apart:

CSS
.fav[aria-pressed='true'] {
  background: #5b3cc4;
  color: #fff;
}

For the current page in a navigation menu, aria-current="page" tells screen reader users which link they are on: “Tickets, current page, link”.

A few more rules from the W3C’s “Using ARIA” note keep you out of trouble:

  • Don’t change native semantics unless you really must: <h2 role="button"> destroys the heading. Put a button inside the heading instead.
  • Every interactive ARIA control must be keyboard operable.
  • Every interactive element needs an accessible name.

Live regions: announcing changes

When Ticketly adds a ticket, the basket counter in the header changes from 1 to 2. Sighted users notice. Screen reader users hear nothing, because their focus is still on the button they pressed. A live region is an element whose content changes are read out automatically, without moving focus.

ticketly/index.html
<p id="basket-status" role="status"></p>
JavaScript
function addToBasket() {
  count += 1;
  basketStatus.textContent = `Ticket added. ${count} tickets in your basket.`;
}
An empty status element exists from page load. When script changes its text, the browser fires a change event through the accessibility API, and the screen reader speaks the new text after the current speech, while focus stays on the button. role="status" empty, at load textContent Browser sees a change "Ticket added. 2 tickets…" Focus never moves; the message is spoken when speech is idle
How a live region announcement happens

Three rules make live regions reliable:

  1. The region must exist before the change. Screen readers watch existing live regions for changes. Inserting a new element that already contains text is often missed. Render the empty region at page load.
  2. Pick the politeness. role="status" (same as aria-live="polite") waits until the screen reader finishes its current sentence. role="alert" (assertive) interrupts. Reserve alerts for urgent, rare messages like a failed payment; an assertive “Ticket added” on every click is exhausting.
  3. Keep messages short and meaningful. Announce the result (“2 tickets in your basket”), not the mechanics (“basket component updated”).

ARIA’s states and roles become most powerful in custom widgets, where no HTML element exists at all. The next lesson builds three of them following the ARIA Authoring Practices Guide.

Key takeaways

  • The first rule of ARIA is to use a native HTML element when one exists; ARIA changes what is announced, never how anything behaves.
  • ARIA comes in roles (what it is), properties (relationships and fixed traits) and states (values that change, which your script must keep in sync).
  • Never put aria-hidden=“true” on a focusable element or override the role of a native element that already has the right one.
  • A live region must already be in the DOM before its text changes; use role=“status” for polite updates and role=“alert” only for urgent ones.

Further reading

Check your understanding

4 questions

Question 1 of 4

What does adding role="button" to a <span> change?

Question 2 of 4

Ticketly shows “Ticket added. 2 tickets in your basket.” in this element after a click. Why do screen readers stay silent?

JavaScript
const msg = document.createElement('p');
msg.setAttribute('role', 'status');
msg.textContent = 'Ticket added. 2 tickets in your basket.';
basket.append(msg);
Question 3 of 4

Which of these uses ARIA correctly?

Question 4 of 4

The payment provider rejects a card at Ticketly’s checkout. Which live region role fits the message “Payment failed. Your card was declined.”?

Practice

Code playground

Add the ARIA HTML can't give you

Four small ARIA jobs on Ticketly’s pass card:

  1. Mark the Tickets link as the current page with aria-current="page".
  2. Make Save to wishlist a toggle button: start with aria-pressed="false" and flip it on each click. Keep the label text unchanged.
  3. Make #basket-status a polite live region so “Ticket added…” is announced.
  4. The sticky bar’s Add to basket button has aria-hidden="true", which leaves a silent Tab stop. Fix it without removing the button (you may change its text).
<nav aria-label="Site">
  <a href="#home">Home</a>
  <a href="#tickets" class="active">Tickets</a>
  <a href="#faq">FAQ</a>
</nav>

<article class="pass">
  <h2>Friday pass</h2>
  <button type="button" id="add">Add to basket</button>
  <button type="button" class="fav" id="fav">Save to wishlist</button>
</article>

<p id="basket-status"></p>

<div class="sticky-bar">
  <button type="button" id="add-sticky" aria-hidden="true">Add to basket</button>
</div>Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Preview

Console

(no output)

Checks

  • Only the Tickets link has aria-current=“page”
  • Save to wishlist starts at aria-pressed=“false” and toggles on each click
  • #basket-status is a live region and announces the basket count
  • The basket message is polite, not an assertive alert
  • No focusable element is hidden with aria-hidden