Blog

Part 46 · ARIA Basics for React Developers

How screen readers learn about a page, why a real button beats a div, and how to write ARIA names, states and live regions in JSX. The examples are checked in Chromium.

This part starts a new stage of the series: accessibility. That means everyone can use the page, including people who can’t see the screen or can’t use a mouse.

We have met pieces of it already. Part 38 gave an accordion aria-expanded and aria-controls. Part 39 gave tabs their roles. Part 42 named a modal with aria-labelledby. Part 45 used page-part tags like <nav>.

This part explains the idea under all of them. We’ll see what a screen reader gets from the browser, and how ARIA changes it. We’ll measure each step in Chromium, the base of Chrome, with no window open. Part 47 covers keyboard navigation, and Part 48 covers forms.

Try this first

Read this code. Don’t press Run yet.

import { useState } from 'react'

export default function App() {
  const [divSaves, setDivSaves] = useState(0)
  const [buttonSaves, setButtonSaves] = useState(0)

  return (
    <div>
      <div onClick={() => setDivSaves(divSaves + 1)}>Save (div)</div>
      <button onClick={() => setButtonSaves(buttonSaves + 1)}>Save (button)</button>
      <p>Div: {divSaves}. Button: {buttonSaves}.</p>
    </div>
  )
}

Both lines say “Save”, and both count clicks. Now guess. You press Run, click an empty spot in the result box, and press the Tab key. Which one gets the focus? Then you press Enter. Which number goes up?

Press Run and try it.

Clicking with the mouse works on both. But Tab jumps straight to the button and skips the div. Enter on the button adds 1 to “Button”. Tab can’t reach the div at all. We checked this in Chromium. In Firefox and WebKit, Tab skipped a <div onClick> too.

On a Mac, Safari may skip buttons when you press Tab. Safari has a setting called “Press Tab to highlight each item on a webpage”. Apple says it lets you “Move around a webpage to highlight links and buttons without using the mouse”. Turn it on to try these examples.

The two look almost the same on the screen. To a keyboard user and to a screen reader, they are very different. Let’s see why.

What a screen reader is

A screen reader is a program that reads the page out loud. Some can also show the text in braille, the raised dots that people read with their fingers. People who can’t see the screen use one. MDN names some that come with a computer or phone. Here are two. VoiceOver comes with Apple’s Macs and iPhones. Narrator comes with Windows.

A screen reader doesn’t look at the pixels. It asks the browser what is on the page.

The accessibility tree

The browser builds the page from your HTML. That is the DOM, the tree of tags. Then it builds a second tree from the DOM, just for tools like screen readers. MDN, a set of web guides, calls it the accessibility tree.

MDN says each thing in that tree has four properties:

  • Role: what kind of thing it is. A button, a link, a heading, a list.
  • Name: what to call it. A button’s text is usually its name.
  • Description: more words about it, beyond the name.
  • State: how it is right now. Checked or not checked, open or closed.

Some things also have a value, like the text inside a text box.

We asked Chromium for its accessibility tree of “Try this first”. We printed each thing on one line: its role, its name in quotes, and some of its properties. Here is the part for the two Saves:

generic
  StaticText "Save (div)"
button "Save (button)" focusable=true

The button has the role button and the name “Save (button)”. It is focusable. Focusable means the focus can move to it. The div has the role generic, which means “a box with no meaning”. Inside it is only some text, StaticText. Nothing in Chromium’s tree shows that the div does anything. A screen reader user hears the words “Save (div)”, but gets no sign that it is something to click.

This is the key idea of the whole part. A screen reader only knows what the accessibility tree tells it. ARIA is a way to change that tree.

What ARIA is

ARIA stands for Accessible Rich Internet Applications. It is a set of HTML attributes from the W3C, the group that writes web standards. There are two kinds:

  • role sets the role: role="button", role="dialog", role="status".
  • The aria-* attributes set the name, the description and the states: aria-label, aria-pressed, aria-expanded, and many more.

ARIA changes only the accessibility tree. It doesn’t change how a tag looks or what it does. That last point is the source of most ARIA bugs.

This figure follows one button from React to a screen reader. It is a Mute button. Mute means “turn the sound off”.

1. React puts the button on the page. 2. Chromium builds a node for it in the accessibility tree. 3. A screen reader reads the node: name, role and state. 4. You click. React sets aria-pressed to "true". 5. The tree changes. The screen reader can tell it is pressed. the page (DOM) accessibility tree screen reader <buttonaria-pressed="false">Mute</button>Mutewhat the screen shows role: buttonname: "Mute"pressed: false name: Muterole: buttonstate: not pressedeach screen reader picks its own words

From React to a screen reader, through the accessibility tree. Press play, or step through it.

Here are the same steps in words.

  1. React puts <button aria-pressed="false">Mute</button> on the page.
  2. Chromium makes a node in the accessibility tree: role button, name “Mute”, and pressed=false.
  3. A screen reader reads that node. It says the name, the role and the state. Each screen reader picks its own words for them.
  4. You click the button. React changes the attribute to aria-pressed="true".
  5. Chromium updates the node to pressed=true. The screen reader can now tell its user that the button is pressed.

We measured steps 2 and 5 in Chromium. We didn’t run a real screen reader, so we can’t show its exact words.

The first rule: use HTML first

The W3C wrote a short guide called “Using ARIA”. Its first rule is long. It starts like this: “If you can use a native HTML element”. It ends: “then do so.”

In plain words: if HTML already has a tag that does the job, use it. Native means built into the browser. The rule talks about a tag that has the meaning and the behavior you need “already built in”. A <button> means “a button”, and it acts like one.

The W3C has stopped working on this guide. It is now marked as a “Discontinued Draft”, which means it won’t be updated. It says its rules “are kept for historical purposes and for easier reference”. The W3C points people to another guide, the ARIA Authoring Practices Guide. People call it the APG. The APG starts with a page called “Read Me First”. Its first heading says: “No ARIA is better than Bad ARIA”.

A role is a promise

The APG’s next idea is “A role is a promise”. It says <div role="button"> promises that you also wrote the code a button needs. It also says “ARIA roles do not cause browsers to provide keyboard behaviors or styling.”

We measured four ways to make a Save control in Chromium. We pressed Tab to reach each one, then Enter, then Space. Firefox and WebKit gave the same results.

The tag Role in the tree Tab reaches it Enter clicks Space clicks
<div onClick> generic no no no
<div role="button" onClick> button no no no
<div role="button" tabIndex={0} onClick> button yes no no
<button onClick> button yes yes yes

The third line is the real trap. Tab reaches it, and a screen reader says “button”. But Enter and Space do nothing. On a long page, Space scrolled the page down instead. The role made a promise that the code didn’t keep.

To keep the promise, you write the keyboard part yourself:

import { useState, type KeyboardEvent } from 'react'

export default function App() {
  const [saves, setSaves] = useState(0)

  function save() {
    setSaves(saves + 1)
  }

  function handleKeyDown(e: KeyboardEvent<HTMLDivElement>) {
    if (e.key === 'Enter' || e.key === ' ') {
      e.preventDefault()
      save()
    }
  }

  return (
    <div>
      <div role="button" tabIndex={0} onClick={save} onKeyDown={handleKeyDown}>
        Save
      </div>
      <p>Saved {saves} times.</p>
    </div>
  )
}

tabIndex={0} puts the div in the Tab order. onKeyDown runs save on Enter and on Space. e.key is ' ', one space, for the Space key. e.preventDefault() stops Space from scrolling the page. In Chromium, Enter and Space both counted once each.

That is a lot of code to copy what <button> gives you for free. And it still isn’t everything. We checked two more things in Chromium. TypeScript doesn’t allow disabled on a div, so we forced it on. It didn’t stop the div’s click. And a click on the div inside a <form> didn’t send the form. A <button> does both. So use <button> to do something, and <a href> to go somewhere. Part 43 saw the same with asChild: a <div> child got no keyboard support at all. Part 47 goes deeper into the keyboard.

Writing ARIA in JSX

Part 1 said that most attribute names in JSX use camelCase, but aria- names keep their dashes. React’s docs say: “In React, all ARIA attribute names are exactly the same as in HTML.” And role is a plain string.

What about values? Many ARIA states are "true" or "false". In JSX you can pass a real boolean:

export default function App() {
  return (
    <div>
      <button aria-expanded={false}>Menu</button>
      <button aria-pressed={true}>Mute</button>
      <span aria-hidden={true}>★</span>
      <button hidden={false}>Help</button>
    </div>
  )
}

React 19.3 turned each boolean into the text "true" or "false". Notice aria-expanded={false}. React kept it as aria-expanded="false". That matters. "false" tells a screen reader “this can open, and it is closed now”. No attribute at all would mean “this doesn’t open”. MDN’s aria-expanded page gives these meanings.

hidden is different. It is a normal HTML attribute, not ARIA. So hidden={false} left no attribute on the page at all.

camelCase doesn’t work for ARIA

export default function App() {
  return <button ariaLabel="Close">×</button>
}

TypeScript stops here, with the error Property 'ariaLabel' does not exist. It also asks: Did you mean '"aria-label"'? The playground doesn’t check types. Press Run, and React prints this in the Console:

Invalid ARIA attribute `ariaLabel`. Did you mean `aria-label`?

The page gets arialabel="Close", which means nothing to the browser. In Chromium the button’s name stayed “×”.

TypeScript doesn’t catch every mistake

TypeScript checks the value of a known ARIA attribute. aria-expanded="maybe" is a type error, because the only allowed values are true, false, "true" and "false". But TypeScript doesn’t check names that have a dash. So it allowed aria-labeledby, with one “l” missing. It also allowed role="buton", because role can be any string. React warns about the name that is spelled wrong. This is in the Console:

Invalid aria prop `aria-labeledby` on <button> tag. For details, see https://react.dev/link/invalid-aria-props

React said nothing about role="buton". In Chromium, the wrong role had no effect, and the button stayed a button.

One more surprise: Chromium treats aria-labeledby as a second spelling, and named the button from it. Don’t count on that. The tool we used to drive Chromium has its own way to work out names. It named the same button “Go”. React and axe-core, a checking tool we’ll meet below, both say the name is wrong.

Giving things a name

Every control needs a name. Without one, a screen reader can only say “button”, and the user has to guess what it does.

Most of the time the name comes for free. A button’s name is its text. A text box gets its name from its <label>. ARIA adds three more ways:

  • aria-label="Close" gives the name as text, written right on the tag.
  • aria-labelledby="some-id" uses the text of another tag as the name. You give it that tag’s id.
  • aria-describedby="some-id" adds a description: extra words that a screen reader reads after the name.

Ids must be unique on the page. A component can be used many times. So make the ids with useId, as Part 38 did.

import { useId } from 'react'

export default function App() {
  const id = useId()

  return (
    <section aria-labelledby={id + '-title'}>
      <h2 id={id + '-title'}>Your cart</h2>
      <button aria-label="Close">×</button>
      <label htmlFor={id + '-email'}>Email</label>
      <input id={id + '-email'} type="email" aria-describedby={id + '-hint'} />
      <p id={id + '-hint'}>We send the receipt here.</p>
    </section>
  )
}

This is Chromium’s tree for it:

region "Your cart"
  heading "Your cart" level=2
  button "Close" focusable=true
    StaticText "×"
  LabelText
    StaticText "Email"
  textbox "Email" description="We send the receipt here." focusable=true
    generic
  paragraph
    StaticText "We send the receipt here."

A StaticText line is a piece of text. It shows what is inside a tag, even when the tag’s name comes from somewhere else.

Each line shows one way to name something:

  • The <section> became a region, named “Your cart” by aria-labelledby. A region is a named part of the page that a screen reader can jump to. A <section> with no name stayed a plain generic box in our test.
  • The × button is named “Close” by aria-label. Without it, its name would be “×”.
  • The text box is named “Email” by its <label>. Its description comes from aria-describedby.

Which name wins

A tag can have more than one name source. We tried them against each other in Chromium. aria-labelledby won over aria-label. aria-label won over the text inside. So <button aria-label="Close dialog">Close</button> was named “Close dialog”.

That last one is a trap. The screen says “Close”, but a screen reader says “Close dialog”. If the words on the screen are good, leave out aria-label. Use it for buttons that have no words, like an icon.

States: what is true right now

A state tells a screen reader how something is right now. ARIA has a state for many common cases. When your React state changes, you change the ARIA state with it.

aria-pressed: a toggle button

A toggle button stays pressed or not pressed, like a Mute button.

import { useState } from 'react'

export default function App() {
  const [muted, setMuted] = useState(false)

  return (
    <button aria-pressed={muted} onClick={() => setMuted(!muted)}>
      Mute
    </button>
  )
}

This is the button from the figure. In Chromium, the tree said button "Mute" focusable=true pressed=false after Run. After one click it said pressed=true.

Keep the name the same and let the state change. The name says what the button is for. The state says whether it’s on. MDN’s button role page says aria-pressed="false" makes the button a toggle button that is “currently not pressed”. With no aria-pressed, it’s a normal button.

aria-expanded, aria-checked and aria-current

You have met some of these already.

  • aria-expanded says if the thing a button opens is open now. Part 38 used it for an accordion.
  • aria-checked is the state of a checkbox or a switch made with ARIA. <button role="switch" aria-checked={on}> showed up in Chromium’s tree as switch "Wi-Fi" focusable=true checked=true. But by the first rule, a real <input type="checkbox"> is better when it fits. It is checked and named without any ARIA.
  • aria-current="page" marks the link to the page you’re on. Part 37 put it on the router’s Link.
  • aria-setsize and aria-posinset tell how long a list is and where an item sits in it. Part 35 used them for a list that shows only some of its rows.

Chromium’s tree doesn’t show aria-current, aria-setsize or aria-posinset, so we can’t show them here.

disabled or aria-disabled

Both say “you can’t use this now”. They don’t do the same thing.

import { useState } from 'react'

export default function App() {
  const [clicks, setClicks] = useState(0)

  return (
    <div>
      <button disabled onClick={() => setClicks(clicks + 1)}>Disabled</button>
      <button aria-disabled="true" onClick={() => setClicks(clicks + 1)}>ARIA disabled</button>
      <p>Clicks: {clicks}</p>
    </div>
  )
}

Run it, and click both buttons. “Disabled” does nothing. “ARIA disabled” counts, even though it says it is disabled.

This is what we measured in Chromium. Firefox and WebKit matched it for Tab and clicks.

disabled aria-disabled="true"
In the tree disabled=true disabled=true
Tab reaches it no yes
A click runs onClick no yes
Enter or Space runs onClick no (no focus) yes

So aria-disabled changes only what a screen reader hears. Your code must still stop the action. Why use it at all? Because Tab can still reach it. A keyboard user can then find the button and hear that it is disabled. MDN’s aria-disabled page says the same: you still need JavaScript to stop it from working.

aria-invalid: a field with a mistake

aria-invalid={true} says a field’s value is wrong. Pair it with aria-describedby, pointing at the error message. Then the message becomes the field’s description. In Chromium, <input aria-invalid={true} aria-describedby="age-error"> showed up as a textbox with invalid=true and the error as its description. Part 48 builds this into whole forms.

Live regions: telling about changes

Sometimes the page changes, but the focus doesn’t move. A message appears: “Saved”. A count goes up. A sighted user sees it. A screen reader user is somewhere else on the page, and hears nothing.

A live region fixes this. It is a part of the page that a screen reader watches. When its text changes, the screen reader reads the new text out, without moving the focus.

  • aria-live="polite" waits until the user pauses. Polite means “not rude”: it doesn’t cut in. Use it for most messages.
  • aria-live="assertive" breaks in at once. Assertive means “pushing forward”. MDN says to use it only for messages that need “the user’s immediate attention”.
  • role="status" is a polite live region. role="alert" is an assertive one.
  • HTML’s own <output> tag is a polite live region too. MDN says many browsers make it one.

In Chromium’s tree, role="status" had live=polite, and role="alert" had live=assertive. <output> became a status with live=polite.

import { useState } from 'react'

export default function App() {
  const [items, setItems] = useState(0)

  return (
    <div>
      <button onClick={() => setItems(items + 1)}>Add to cart</button>
      <p role="status">{items === 0 ? '' : `Items in your cart: ${items}`}</p>
    </div>
  )
}

The region must be there first

Look at the <p role="status">. It is always on the page, even when it is empty. That is on purpose.

MDN says screen readers “will generally only announce dynamic changes in the content of a live region”. So MDN’s advice comes in three steps:

  • “Establish the live region before updating its content.”
  • “Start with an empty live region”. Then wait a little: “allow time for it to be exposed to assistive technologies”.
  • “The most reliable way to ensure that live regions are registered is to include them in the initial markup.”

Assistive technology means tools that help people use a computer, like screen readers. The “initial markup” is the HTML the page starts with. In a React app like ours, JavaScript adds the whole page, the live region too. So render the region well before the first message. Our example does: the empty <p role="status"> is there from the first render. The message comes only after a click.

So in React, render the live region all the time, and change only its text. Don’t do this:

function CartMessage({ items }: { items: number }) {
  return items > 0 && <p role="status">Items in your cart: {items}</p>
}

The first time, the region and its text appear together. In Chromium’s tree, before the first click, there was no status node at all. With the version above, the tree had an empty status node with live=polite, waiting.

MDN adds that role="alert" gets special care. Its text is read out “in most cases”, even when the region is new. Still, keeping the region on the page works for both.

What we couldn’t check

Chromium showed us the live region in the tree. It can’t show us what a screen reader says, or when. MDN warns that this “can vary across browser and assistive technology combinations”. To be sure, test with a real screen reader.

Hiding things

There are many ways to hide a tag. They hide it from different people. We tried each one in Chromium.

import type { CSSProperties } from 'react'

const visuallyHidden: CSSProperties = {
  position: 'absolute',
  width: 1,
  height: 1,
  overflow: 'hidden',
  clipPath: 'inset(50%)',
  whiteSpace: 'nowrap',
}

export default function App() {
  return (
    <div>
      <p aria-hidden="true">A: aria-hidden</p>
      <p hidden>B: hidden</p>
      <p style={{ display: 'none' }}>C: display none</p>
      <div inert>
        <button>D: inert</button>
      </div>
      <p style={visuallyHidden}>E: visually hidden</p>
      <button aria-hidden="true">F: aria-hidden button</button>
    </div>
  )
}
The way Seen on screen In the tree Tab reaches it
A: aria-hidden="true" yes no —
B: hidden no no —
C: display: 'none' no no —
D: inert around a button yes no no
E: the visuallyHidden style no (1 pixel, cut off) yes —
F: aria-hidden="true" on a button yes no yes

What each one is for:

  • hidden and display: 'none' hide from everyone. Use them for closed panels, like Part 38’s accordion.
  • aria-hidden="true" hides from screen readers only. Use it for things that are only there to look nice, like an icon next to a word.
  • inert keeps a part on the screen. But nobody can click it, focus it, or reach it with a screen reader. Part 42 met it with <dialog>. React wrote inert="" for inert.
  • aria-modal="true" goes on a role="dialog", as in Part 42. MDN says it tells assistive technology “that content outside a dialog is inert”. Chromium showed dialog "Settings" modal=true. The text behind the dialog stayed in its tree, too.
  • The visuallyHidden style is the opposite of aria-hidden. It is in the tree, but not on the screen. Use it for words that only screen reader users need. The tag is 1 pixel big, and the rest is cut off.

Line F is a bug. The button is out of the tree, but Tab still reaches it. A keyboard user lands on something that a screen reader says nothing about. The W3C guide’s fourth rule says: “Do not use role=”presentation” or aria-hidden=”true” on a focusable element”. aria-hidden on a parent hides every button inside it too.

Landmarks, briefly

Part 45 showed that <header>, <nav>, <main>, <aside> and <footer> are landmarks. They are the big parts of a page, and a screen reader can jump between them. Chromium gave them the roles banner, navigation, main, complementary and contentinfo. A <header> inside <main> was not a banner.

You don’t need role="navigation" on a <nav>. It has that role already. In our test the tree was the same with or without it.

Bad ARIA makes things worse

ARIA can also take meaning away. Semantics means what a tag means. The W3C guide’s second rule is “Do not change native semantics, unless you really have to.”

The rule’s example is <h2 role=tab>. In Chromium, that tag became a tab and stopped being a heading. A screen reader user who jumps from heading to heading can’t find it anymore. In the same way, <ul role="button"> became one button, and the list was gone.

Each role also allows only some aria-* attributes. A plain <button> can’t have aria-selected or aria-checked. axe-core reported aria-allowed-attr for both: “Elements must only use supported ARIA attributes”. Part 39 met this with tabs. With role="tab", aria-selected is allowed, and Chromium showed tab "Go" selected=true.

Here is a small component with four mistakes:

export function Toolbar({ onSave }: { onSave: () => void }) {
  return (
    <div>
      <div onClick={onSave}>Save</div>
      <div role="button" onClick={onSave}>Save</div>
      <button aria-labeledby="title">Go</button>
      <div aria-hidden="true">
        <a href="/help">Help</a>
      </div>
    </div>
  )
}

TypeScript found 0 errors in it. We then ran two checking tools on it.

axe-core 4.14.0 checks a page that is already drawn. We rendered Toolbar with React in Chromium and ran it there. It found two problems:

aria-hidden-focus [serious] ARIA hidden element must not be focusable or contain focusable elements (1 node)
aria-valid-attr [critical] ARIA attributes must conform to valid names (1 node)

The first is the link inside aria-hidden. The second is aria-labeledby. axe-core found nothing wrong with the four Save controls in our table. That includes the plain <div onClick>.

Oxlint is the linter from Part 10. A linter reads your code and warns about likely mistakes. With that project’s own settings, it found nothing. Oxlint has a set of accessibility rules called jsx-a11y, but that project doesn’t turn them on. With --jsx-a11y-plugin added, Oxlint 1.87.0 found 6 warnings. Here are their first lines:

! jsx-a11y(click-events-have-key-events): Enforce a clickable non-interactive element has at least one keyboard event listener.
! jsx-a11y(no-static-element-interactions): Static HTML elements with event handlers require a role.
! jsx-a11y(click-events-have-key-events): Enforce a clickable non-interactive element has at least one keyboard event listener.
! jsx-a11y(interactive-supports-focus): Elements with the 'button' interactive role must be tabbable.
! jsx-a11y(prefer-tag-over-role): Prefer `button` over `role` attribute `button`.
! jsx-a11y(aria-props): 'aria-labeledby' is not a valid ARIA attribute.

So the two tools found different things:

Mistake TypeScript axe-core Oxlint with jsx-a11y
<div onClick> no no yes
role="button" with no keyboard no no yes
aria-labeledby no yes yes
a link inside aria-hidden no yes no

axe-core looks at the finished page. Oxlint reads your code. Each one missed something the other found. Neither one presses keys or listens to a screen reader. Part 50 puts these tools into tests.

What we can’t check without a screen reader

Our tools showed us the accessibility tree, the Tab order, and what Enter and Space did. Some things they can’t show:

  • The words a screen reader says, and in what order. Each screen reader picks its own.
  • When a live region is read out, or if it is read at all.
  • How it feels to use. A page can pass every check and still be slow or confusing to use with a screen reader.

So test your page with a real screen reader too. Try VoiceOver on a Mac or iPhone, or Narrator on Windows. Close your eyes, and try to do one task with only the keyboard.

Common mistakes

A <div> with onClick

function SaveButton({ onSave }: { onSave: () => void }) {
  return <div onClick={onSave}>Save</div>
}

The keyboard can’t reach it, and a screen reader doesn’t call it a button. The fix: <button onClick={onSave}>Save</button>.

role="button" with nothing else

A role changes what a screen reader says, not how the tag behaves. With only role="button", Tab skips the div and Enter does nothing. Use a <button>. If you truly can’t, add tabIndex={0} and an onKeyDown for Enter and Space, as in A role is a promise.

aria-disabled without stopping the action

aria-disabled="true" doesn’t stop clicks. In our test, a click still ran onClick. Check it in your handler:

function SendButton({ ready, onSend }: { ready: boolean; onSend: () => void }) {
  return (
    <button
      aria-disabled={!ready}
      onClick={() => {
        if (!ready) return
        onSend()
      }}
    >
      Send
    </button>
  )
}

A live region that appears with its message

{saved && <p role="status">Saved</p>} adds the region and its text at the same time. A screen reader may not read it. Render <p role="status"> all the time, and change only the text inside.

aria-hidden on something you can focus

<button aria-hidden="true"> takes the button out of the tree but not out of the Tab order. If it should be hidden from everyone, use hidden. If it is visible and works, remove aria-hidden.

An aria-label that hides the visible words

<button aria-label="Close dialog">Close</button> is named “Close dialog”, not “Close”. The screen and the screen reader now say different things. If the visible text is a good name, leave out aria-label.

Practice

Press Edit on the examples above and try these.

  1. In the aria-pressed example, add a ref to the button and an Effect that logs its aria-pressed attribute: console.log(ref.current?.getAttribute('aria-pressed')). Give the Effect [muted] as its list. Before you run it, guess: what does the Console show after Run? And after one click?
  2. In the disabled example, make “ARIA disabled” stop counting. Keep aria-disabled, so Tab can still reach it.
  3. In the “Writing ARIA in JSX” example, change aria-expanded to aria-labeledby="x". What does the Console show? Then fix the spelling.
  4. In the live region example, change the <p role="status"> line to {items > 0 && <p role="status">Items in your cart: {items}</p>}. Does the page look different? What changed for a screen reader?
Answers

1. After Run, it logs false two times. Strict Mode runs the Effect’s setup, then its cleanup, then the setup again, in development only. After one click, it logs true one time. The code:

import { useEffect, useRef, useState } from 'react'

export default function App() {
  const [muted, setMuted] = useState(false)
  const ref = useRef<HTMLButtonElement>(null)

  useEffect(() => {
    console.log(ref.current?.getAttribute('aria-pressed'))
  }, [muted])

  return (
    <button ref={ref} aria-pressed={muted} onClick={() => setMuted(!muted)}>
      Mute
    </button>
  )
}
false
false
true

2. Check the state at the start of the handler:

import { useState } from 'react'

export default function App() {
  const [clicks, setClicks] = useState(0)
  const ready = false

  return (
    <div>
      <button
        aria-disabled={!ready}
        onClick={() => {
          if (!ready) return
          setClicks(clicks + 1)
        }}
      >
        ARIA disabled
      </button>
      <p>Clicks: {clicks}</p>
    </div>
  )
}

Clicks stay at 0. Tab still reaches the button, because it has no disabled attribute.

3. React prints the warning Invalid aria prop `aria-labeledby` on <button> tag, with a link to its docs. It prints it once, even though Strict Mode renders twice. The right name is aria-labelledby.

4. The page looks the same. But the region now appears together with its first message. Chromium’s tree has no status node before the click, so a screen reader may not read the first message. Put back the <p> that is always rendered.

Interview questions

Try to answer each one out loud before you open the answer.

What is the accessibility tree?

It is a second tree that the browser builds from the DOM, for tools like screen readers. Each node has a role, a name, a description and a state, and some have a value. A screen reader reads this tree, not the pixels. ARIA attributes change the tree, and only the tree.

A strong answer says a program can ask the browser for this tree, as we did with Chromium. It also says that a <div onClick> shows up as generic with some text. Chromium’s tree shows no sign of the click handler.

What is the first rule of ARIA?

If HTML has a tag or attribute that already does the job, use it instead of ARIA. A <button> gets a role, a name, focus, and Enter and Space for free. A <div role="button"> gets the role, and its name from its text. It gets no focus and no keys.

A strong answer quotes the APG: “No ARIA is better than Bad ARIA”. It explains “a role is a promise”. The role tells a screen reader to expect a button’s keys, and you must write them. It also knows when ARIA is needed. Tabs need it, because HTML has no tab tag. <output> is already a polite live region. For other live regions, you use role="status" or aria-live.

What is the difference between aria-label, aria-labelledby and aria-describedby?

aria-label gives a name as text. aria-labelledby uses the text of other tags as the name, by their ids. aria-describedby adds a description, read after the name. In Chromium, aria-labelledby won over aria-label, and both won over the text inside.

A strong answer adds two warnings. A visible label is better than aria-label, because everyone sees the same words. And the ids must be unique, so in React they come from useId.

When would you use aria-disabled instead of disabled?

When keyboard users should still reach the control and hear why they can’t use it. disabled takes the button out of the Tab order and blocks clicks. aria-disabled="true" only changes the tree. Tab still reaches it, and clicks still run onClick.

A strong answer says the handler must check the state and return early. Otherwise the “disabled” button still works. Inside a <form>, a click on an aria-disabled button still sent the form in our test. So also stop the form from being sent, or give the button type="button".

Why must a live region be on the page before its text changes? How do you do that in React?

Screen readers watch live regions they already know about, and read changes inside them. A region that appears with its message already in it may not be read. MDN says to “Establish the live region before updating its content.”

In React, render the <p role="status"> every time, and change only the text inside it. Don’t write {message && <p role="status">{message}</p>}. A strong answer knows the difference between polite and assertive, and that role="status" and role="alert" stand for them.

What is the difference between aria-hidden, hidden and inert?

hidden (or display: none) hides a tag from everyone. aria-hidden="true" hides it only from screen readers. It stays on the screen, and its buttons stay in the Tab order. inert keeps it on the screen, but nobody can click it, focus it or reach it with a screen reader.

A strong answer names the bug: aria-hidden on a button, or on a parent of one. Tab lands on something a screen reader says nothing about. axe-core reports it as aria-hidden-focus.

How do you write ARIA attributes in JSX?

Exactly as in HTML, with dashes: aria-label, aria-expanded. Not camelCase. role is a plain string. You can pass boolean values, and React writes them as "true" and "false". aria-expanded={false} stays on the page as "false", which is not the same as leaving it out.

A strong answer knows what catches mistakes. TypeScript catches ariaLabel and wrong values like aria-expanded="maybe". It doesn’t catch an aria- name or a role that is spelled wrong. React warns about an aria- name that is spelled wrong, in development.

Can tools prove that a component is accessible?

No. They catch some mistakes. In our test, axe-core found an ARIA name that was spelled wrong, and a link inside aria-hidden. Oxlint’s jsx-a11y rules found a <div onClick> and a role="button" with no keyboard support. Each one missed something the other found.

A strong answer says no tool tells you what a screen reader says, or how it feels to use. You still test with a keyboard and a real screen reader.

Sources

How useful was this post?

Click on a heart to rate it!

Average rating 0 / 5. Vote count: 0

No votes so far! Be the first to rate this post.