Accessible components
Build components that work with a keyboard and a screen reader, using native elements, labels with useId(), ARIA state, a live region and a native dialog that manages focus.
What you'll learn
When you finish, you can do these.- 1Use real buttons and tie labels to inputs with useId()
- 2Make a screen reader say what the screen shows
- 3Move focus on purpose and open a dialog with showModal()
- 4Spot and fix a silent live region and a nameless button
Picture a trip planner: an email field, a Delivery options button that shows a panel, and a packing list whose Clear list button opens a dialog, a box that asks before acting. Some people use only the keyboard, pressing Tab to move the focus, the place where keys act. Others use a screen reader, software that reads the page aloud. Built carelessly, the planner fails them: the panel never opens from the keyboard, the email error is never read out, and focus is lost when the dialog closes.
The Code tab holds the planner built to work for them. Most fixes are the right HTML element plus a few attributes. You need one idea from Lifecycle and template refs, in the Beginner track: a template ref hands your code a real element, so it can call methods such as focus().
Start with the element that already works
The HTML element made for the job has keyboard and screen reader support built in. The Delivery options button is a real <button>: Tab reaches it, Enter and Space run its click handler, and a screen reader announces it as a button.
A <div @click> does none of that: Tab skips it, and a screen reader treats it as text. So use <button> for actions and <a href> for links.
Every input also needs a label the browser connects to it: clicking the label focuses the input, and a screen reader reads the label with it. Wrapping the input in its <label> is enough, as in the packing list's New item field.
The email field is a component, Field.vue, that a form can show many times. Its label and input sit apart, so the label's for names the input's id, which must be unique on the page.
Vue's useId() gives each copy its own id. It is the same on the server and in the browser, so it still matches after hydration, when Vue takes over a page the server rendered.
Field.vue puts the id on its label and input, and adds an error message:
<label :for="id">{{ label }}</label>
<input
:id="id"
v-model="model"
:aria-invalid="error ? 'true' : undefined"
:aria-describedby="error ? errorId : undefined"
/>
<p v-if="error" :id="errorId" class="error">{{ error }}</p>
Type an address without an @: "Enter an address with an @" appears under the input, sent by App.vue, the page, as the error prop.
aria- attributes change nothing on screen: they tell a screen reader what the screen shows. aria-invalid says the value is wrong, and aria-describedby points at the error paragraph by its id, errorId, so the error is read with the field.
Say what the screen shows
Sighted people see that a panel is open, that ✕ means remove, that an item was added. A screen reader needs them as attributes or text.
Delivery options is a disclosure, a button that shows and hides a panel, in Disclosure.vue. Its button carries aria-expanded, which says whether the panel is open, and aria-controls, which points at the panel:
<button type="button" :aria-expanded="open" :aria-controls="panelId" @click="open = !open">
<slot name="title" />
<span aria-hidden="true">{{ open ? ' ▴' : ' ▾' }}</span>
</button>
The button flips open, a ref that is true while the panel shows. Vue writes its value into aria-expanded, so a screen reader says "Delivery options, button, collapsed", and "expanded" once the panel opens. The triangle is decoration, so aria-hidden="true" keeps it from being read aloud.
An icon-only button needs a name too. The packing list, PackingList.vue, starts with Passport and Charger, each with a ✕ button that removes it.
Inside each ✕ button, a span says Remove and the item's name. Its class, sr-only, from App.vue, hides text from the eye but not from a screen reader, so Passport's button is announced as "Remove Passport". aria-label="Remove Passport" on the button does the same.
A change away from the focus needs a live region: an element whose new text is read aloud. role="status" makes a polite one, read when the screen reader finishes its current sentence. The packing list keeps one under its items: add Socks, and it reads "Socks added. 3 items." while focus stays in the New item field.
Move focus on purpose
When an action removes the focused element or opens something new, you decide where focus goes. The starter draws the focus as a blue outline, the focus ring.
Removing an item deletes the ✕ button that had focus, so remove(), the function that button calls, ends with input.value?.focus(). input is the template ref of the New item field. Without that line, focus falls back to the page, and no ring shows where the keyboard is.
A dialog needs four things: focus moves into it, Tab stays inside it, Escape closes it, and focus returns to its trigger, the button that opened it.
- Trigger
- Dialog opensfocus the first control
- Escape or Close
- Focus back to the trigger
The native <dialog> element does all four when you open it with showModal(). In the packing list, ref="dialog" makes it the template ref dialog, which the Clear list button opens:
<button type="button" :disabled="!items.length" @click="dialog?.showModal()">Clear list</button>
<dialog ref="dialog" :aria-labelledby="titleId">
<h2 :id="titleId">Remove all {{ items.length }} items?</h2>
<button type="button" @click="dialog?.close()">Cancel</button>
<button type="button" @click="clear">Clear</button>
</dialog>
The dialog asks "Remove all 2 items?" above Cancel and Clear, and aria-labelledby names it after that heading.
showModal() makes everything outside the dialog inert: it cannot be clicked, focused or read until the dialog closes. show() opens the same element without the inert page, and Escape does not close it.
What goes wrong
The first mistake is a live region that appears with its message:
<p v-if="status" role="status">{{ status }}</p>
"Socks added. 3 items." shows on screen, and a screen reader often says nothing: v-if creates the region with its text, before a screen reader watches it. Keep the region on the page and change only its text:
<p role="status">{{ status }}</p>
Now every new message is read aloud.
The second is an icon-only button with no name:
<button type="button" @click="remove(index)">✕</button>
A screen reader announces "button", or "multiplication x, button", with no hint of which item it removes. Hide the ✕ and add hidden words:
<button type="button" @click="remove(index)">
<span aria-hidden="true">✕</span>
<span class="sr-only">Remove {{ item.name }}</span>
</button>
Each button now has a name: "Remove Passport", "Remove Charger".
What to remember
- Use the native element first:
<button>for actions, a<label>for every input,<dialog>for dialogs. useId()gives each copy of a component its own id, the same on the server and in the browser. A hard-coded id breaks once the component appears twice.- Put state in
aria-attributes such asaria-expandedandaria-invalid, and hide decoration witharia-hidden. - When an action removes or covers the focused element, decide where focus goes.
showModal()handles dialogs, including the return to the trigger. - A live region must be on the page before its text changes, and an icon-only button needs hidden words or
aria-label.
Try it
Open the Code tab and change the starter code. Each task says what you should see.- In App.vue, add
const name = ref('')and<Field v-model="name" label="Your name" />above the email field. Click each label: its own input gets the focus ring. Then, in Field.vue, replaceuseId()with'field': clicking "Email for updates" now focuses the name input, because both inputs share one id. - Click inside the preview, Tab to Clear list and press Enter. Focus starts on Cancel, and Tab never reaches the list behind. Press Escape: the dialog closes and the ring is back on Clear list. In PackingList.vue, change
showModal()toshow()and repeat: Escape does nothing, and Shift+Tab leaves the dialog. - In Disclosure.vue, change
<button type="button"to<divand its closing</button>to</div>, keeping the@click. A click still opens the panel, but Tab from the email field skips to the New item input, and no key opens the panel. Put the button back.