Accessibility
In short: Accessibility means the page also works with a keyboard and a screen reader: the browser builds a tree from the HTML with the role, name and state of every element.
Problem
The button that is not a button
The product page works: you click "Add to cart" and the product goes into the cart. A customer writes that he cannot buy anything. He does not use a mouse: he moves with the keyboard, and a screen reader tells him what is on the page.
Console
Click in the "Quantity" field and press Tab. Focus skips "Add to cart" and goes to the heart. The heart is a button, but the screen reader says only "button": what it does is unknown. Both elements look fine. The bug is in what the browser does not draw.
The idea
The second tree
From the DOM the browser builds one more tree: the accessibility tree. It is the page as seen by the people and programs that do not look at the screen:
- a screen reader reads the page aloud to a person who cannot see;
- voice control: a person says "click Add to cart", and the program looks for a button with that name;
- the keyboard: Tab goes through the elements that can be pressed;
- automated tests such as Testing Library search by role and name. The sandbox here checks that way too.
Every node of this tree has three things:
- a role: what the element is: button, link, heading, field, image;
- an accessible name: what it is called: "Add to cart";
- a state: is it pressed, checked, disabled.
The role comes from the tag: <button> is a button, <a href> is a link, <h1> is a heading. <div> and <span> have no role. Only their text is left in the tree.
See it
See the two trees
A product card with three "buttons". Watch what is left of each one on the right.
<body><article> <h2>Bread</h2> <img src="bread.svg" alt="Bread"> <label>Qty <input type="number"></label> <div onclick="add()">Add</div> <button><svg>…</svg></button> <button>Add</button></article></body>Step by step, in words
- On the left is the DOM. From it the browser builds a second tree: the accessibility tree. Screen readers, voice control and tests by role read it. <article> becomes the role article.
- <h2> becomes a heading. Its name comes from the text inside: "Bread".
- A picture has no text inside. Its name comes from alt. Without alt the image has no name.
- The field gets the role spinbutton (a number field), and its name comes from <label>: "Qty". <label> itself is not in the tree, only its text.
- <div> has no role. onclick does not change that. What is left is the text "Add". It is not a button, and the keyboard cannot reach it.
- This is a real button, but its only content is an icon without text. The name is empty. A screen reader says just "button".
- A <button> with text: the role button and the name "Add". It is reachable with Tab and works with Enter and Space.
- getByRole('button', { name: 'Add' }) searches the right tree. It finds one button: the real one. The <div> and the nameless button do not exist for it.
You can see the accessibility tree in the browser: in the developer tools (F12) open Elements and the Accessibility panel. It shows the role and the name of the selected element.
The idea
Where the name comes from
The name of an element is taken by rules. The most common sources:
- the text inside:
<button>Add to cart</button>,<a href="/cart">Cart</a>,<h1>; - the
altof an image:<img src="logo.svg" alt="The shop">. A link with only an image inside takes its name fromalt; - the
<label>of a field: "Quantity" is the name of the field next to it; aria-label: a name written right in the attribute, when there is no visible text:<button aria-label="Add to favourites">with an icon inside;aria-labelledby: the name is the text of another element, given byid.
aria-label and aria-labelledby are stronger than the text inside. The name says what the element does, not how it looks: "Add to cart", not "trolley". For an image in a link the name says where the link goes: "The shop", not "logo".
Try it
Give them names
The logo at the top leads to the home page, but it is an image without alt. The cart button is only an icon. Give a name to the link ("The shop") and to the button ("Add to cart"). The check finds them by role and name and presses the button.
Console
Solution
index.html<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Fresh milk 3% · The shop</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<a href="/"><img src="img/logo.svg" alt="The shop" width="40" height="40"></a>
</header>
<main>
<article>
<h1>Fresh milk 3%</h1>
<p>Price: €2.40</p>
<button type="button" aria-label="Add to cart" onclick="console.log('Added to cart: Fresh milk 3%')">
<svg viewBox="0 0 24 24" width="22" height="22"><path d="M3 4h3l2 11h11l2-8H7" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="9" cy="19" r="1.6"/><circle cx="18" cy="19" r="1.6"/></svg>
</button>
</article>
</main>
</body>
</html>
style.cssbody { font-family: system-ui, sans-serif; margin: 16px; }
.add, button { font: inherit; padding: 6px 14px; border: 0; border-radius: 6px; background: #1f6f5c; color: white; cursor: pointer; }
.fav { background: none; color: #c2477f; padding: 4px; }
.fav svg { fill: currentColor; }
.add { display: inline-block; }
label { display: block; margin: 8px 0; }
input { width: 4em; }
The logo gets alt="The shop", and the link takes its name from it. The button gets aria-label="Add to cart": it has no text to take a name from. alt="logo" does not pass: a person who hears "logo, link" does not know where it goes. aria-label="Cart" does not pass either: the button does not open the cart, it adds to it.
The idea
The keyboard and focus
Not everyone uses a mouse. Someone has shaking hands, someone cannot see the pointer, someone types faster with the keyboard. They move through the page with Tab and press with Enter.
Focus is the element that gets the keys right now. Tab moves it forward, Shift+Tab back. Tab reaches only the elements that can be pressed: links with href, buttons, fields, drop-downs. The order is the DOM order, top to bottom.
<button> comes with everything: a role, focus, and Enter and Space press it. <div> has none of this. Adding focus and Enter to it means writing <button> again.
tabindex changes which element gets focus:
tabindex="0": the element joins the Tab order, at its place in the DOM;tabindex="-1": Tab skips it, but code can focus it withfocus();tabindex="1"and up: the element goes first, before all the others. The order of the page breaks. Do not use it.
The browser draws an outline around the focused element. Without it a keyboard user does not know where they are. Do not remove it. If you do not like it, replace it with another one.
Try it
With the keyboard
"Add to cart" is a <div>. Make Tab reach it after the "Quantity" field and make Enter add to the cart. The check presses Tab twice and then Enter.
Console
Solution
index.html<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Dobrudzha bread · The shop</title>
<link rel="stylesheet" href="style.css">
<script src="script.js" defer></script>
</head>
<body>
<main>
<h1>Dobrudzha bread</h1>
<p>Price: €1.80</p>
<label>Quantity <input type="number" value="1" min="1"></label>
<button type="button" class="add">Add to cart</button>
</main>
</body>
</html>
style.cssbody { font-family: system-ui, sans-serif; margin: 16px; }
.add, button { font: inherit; padding: 6px 14px; border: 0; border-radius: 6px; background: #1f6f5c; color: white; cursor: pointer; }
.fav { background: none; color: #c2477f; padding: 4px; }
.fav svg { fill: currentColor; }
.add { display: inline-block; }
label { display: block; margin: 8px 0; }
input { width: 4em; }
script.jsconst add = document.querySelector('.add');
const qty = document.querySelector('input');
add.addEventListener('click', () => {
console.log(`Added to cart: ${qty.value} × Dobrudzha bread`);
});
<div class="add"> becomes <button type="button" class="add">. The class stays, so script.js finds it and the CSS draws it as before. Tab reaches the button after the field because it is after it in the DOM. Enter on a button fires click, so the same listener works. <div role="button" tabindex="0"> does not pass: the screen reader says "button" and Tab reaches it, but Enter does nothing. tabindex="1" on the button does not pass either: Tab goes to it first, before the field.
Try it
Find the bug
The button works with a mouse. A keyboard user presses Tab and cannot see where they are. Which line is to blame?
Pick the line with the bug.
Line 3 removes the focus outline. The button gets focus, but nothing shows it. Remove the line or put another visible outline: button:focus-visible { outline: 3px solid #f4c542; }.
The idea
Headings and the first rule of ARIA
Headings are the table of contents of the page. A screen reader user does not read the page from the start. They ask for a list of headings or jump between them with a key (H in NVDA and JAWS). That is why the levels tell the outline: one <h1> for the page, <h2> for the sections, <h3> inside them. Do not pick <h4> because it is smaller. The size is changed with CSS.
ARIA (Accessible Rich Internet Applications) is a set of attributes that change the accessibility tree: role, aria-label, aria-expanded, aria-live and others. They tell the screen reader something HTML cannot say by itself.
ARIA changes only what is announced, not the behaviour. role="button" on a <div> makes the reader say "button", but Tab still does not reach it and Enter still does nothing. The person hears "button", presses, and nothing happens. That is worse than a <div> with no role.
That is why the first rule of ARIA is: if there is an HTML element that does this, use it. <button>, not role="button". <a href>, not role="link". <nav>, not role="navigation". ARIA is for the cases where HTML has no such element or there is no visible text to take a name from.
Try it
Fill in
Which attribute gives a name to a button that has only an icon?
<button type="button" ="Add to favourites">
<svg viewBox="0 0 24 24">…</svg>
</button>aria-label: the name the screen reader says and by which voice control and tests find the button.
More precisely, if you are curious
Messages that appear. After "Add to cart" the page writes "Added: Dobrudzha bread". Whoever looks at the screen sees it. The screen reader does not notice the change, because focus is not there. <p role="status"> (or aria-live="polite") says: when the text here changes, read it when you finish what you are reading. For urgent errors there is role="alert", which interrupts. The element has to be on the page before the message: only its text changes.
The language. <html lang="bg"> tells the screen reader which voice to read with. With the wrong lang, Bulgarian text is read with English pronunciation and cannot be understood. A word in another language in the text gets its own: <span lang="fr">croissant</span>.
Hiding. display: none and the hidden attribute remove the element both from the screen and from the accessibility tree. aria-hidden="true" removes it only from the tree: for icons next to text that the reader has no reason to say. A focusable element inside aria-hidden is a bug: Tab reaches it, and the reader is silent.
The order of names. The full algorithm is the standard Accessible Name and Description Computation. In short: aria-labelledby, then aria-label, then what HTML gives (<label>, alt, the text inside), and last title. title and placeholder are poor names: title shows only when you hover with the mouse, and placeholder disappears once you type.
:focus and :focus-visible. :focus applies whenever the element has focus, also after a mouse click. :focus-visible applies when the browser decides the outline should show: on Tab, but not after a click on a button. So if you want to change the outline, use :focus-visible. CSS is the next module.
Skip to content. The first link on many pages is "Skip to content" (<a href="#main">). It is hidden until it gets focus. That way a keyboard user does not go through the whole menu every time.
Exercise
The cart
The cart was made in a hurry. "Cart" is only bold text, the "×" buttons are <span>, and the link "Go to checkout" has tabindex="1", "so that it is first". Fix it: a level 1 heading; buttons that Tab reaches and Enter presses, named "Remove Dobrudzha bread" and "Remove Fresh milk 3%"; Tab in the order of the page: the first button, the second, the link. The check goes through the cart with the keyboard only.
Console
Solution
index.html<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Cart · The shop</title>
<link rel="stylesheet" href="style.css">
<script src="script.js" defer></script>
</head>
<body>
<main>
<h1>Cart</h1>
<ul>
<li>Dobrudzha bread, 2 × €1.80 <button type="button" class="remove" aria-label="Remove Dobrudzha bread">×</button></li>
<li>Fresh milk 3%, 1 × €2.40 <button type="button" class="remove" aria-label="Remove Fresh milk 3%">×</button></li>
</ul>
<p>Total: €6.00</p>
<a href="/checkout">Go to checkout</a>
</main>
</body>
</html>
style.cssbody { font-family: system-ui, sans-serif; margin: 16px; }
.title { display: block; font-size: 1.6em; margin: 8px 0; }
li { margin: 6px 0; }
.remove { font: inherit; margin-left: 8px; padding: 0 8px; border: 1px solid #999; border-radius: 4px; background: white; cursor: pointer; }
script.jsfor (const remove of document.querySelectorAll('.remove')) {
remove.addEventListener('click', () => remove.closest('li').remove());
}
"Cart" becomes <h1>. Every <span class="remove"> becomes <button type="button" class="remove"> with aria-label: the visible text "×" does not say what the button does. The name also says which product it removes, because two "Remove" buttons sound the same. tabindex="1" goes away: the link is last in the DOM and last for Tab. The check presses Enter on the first button and counts the products, so <span role="button" tabindex="0"> does not pass.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: The HTML document · Text, links, images, tables · Forms
- Continues in: Responsive design · A list from data · Data from an API · The first component
- Tasks on this topic: HTML: the structure of a page