Skip to content

Accessibility

Assignment

A member who works with a screen reader writes to the association. The number of hits in the catalog stays silent, all the "Favorite" buttons sound the same, and after the back button the focus lands in the search field instead of on the thing they were just looking at.

As a member who works with a screen reader, I want to hear what changes on the page and continue where I was, so that I can use the app without help.

The acceptance criteria are:

  1. The screen reader announces the number of hits as soon as it changes.
  2. Every button and every link says what it's for, even without its surroundings.
  3. After the back button, the focus is on the thing the member came from.

Learning goals

After this chapter you can

  • explain which rules apply to accessibility,
  • work with semantic HTML, labels and names,
  • set the focus on purpose after a navigation and after a change,
  • announce status messages with live regions,
  • check contrast and the keyboard and inspect a page with the browser's tools.

The Web Content Accessibility Guidelines 2.2, WCAG for short, have been a W3C Recommendation since October 2023 and were last updated in December 2024. Their success criteria come in the levels A, AA and AAA, and requirements usually ask for level AA. In Switzerland, version 3.0 of the standard eCH-0059 from 2020 asks websites to meet WCAG 2.1 at level AA, as a recommendation. Article 14 of the Disability Discrimination Act requires online services of the Confederation to be accessible to people with visual impairments without aggravating conditions. An SPA needs extra care here, because without a new page a screen reader notices nothing of a change, as chapter 2.1 showed.

A button can be reached and triggered with the keyboard, and a screen reader calls it a button. A div with @click can do none of that. The Vue documentation therefore recommends the fitting elements, headings in their order, landmarks like header, nav, main and footer, and a link to skip the navigation as the first element of the page. The running case app puts this link in front of the header.

PageLayout.vue
<script setup>
// The skip link moves the focus to the content without changing the address. A link to #main would
// become a navigation of the router in the hash mode of the demo.
function skipToContent() {
document.getElementById('main').focus()
}
</script>
<template>
<div class="layout">
<a class="skip-link" href="#main" @click.prevent="skipToContent">Skip to content</a>
<header class="site">
<slot name="header">Lorraine Commons</slot>
</header>
<main id="main" tabindex="-1">
<slot />
</main>

The link only moves the focus. An ordinary link to #main would turn into a navigation of the router in the hash mode of the demo.

According to WCAG 4.1.2, every control needs a name that assistive technology can read. The name comes from the element's text, from aria-label or from text that's only there for screen readers. A button that only shows a cross has no useful name without one of these.

App.vue
<script setup>
import { ref } from 'vue'
const removed = ref('')
</script>
<template>
<p>Three buttons with the same sign. Only two have a name for the screen reader.</p>
<button type="button" @click="removed = '1'">✕</button>
<button type="button" aria-label="Remove drill" @click="removed = '2'">✕</button>
<button type="button" @click="removed = '3'">
<span aria-hidden="true">✕</span>
<span class="visually-hidden">Remove projector</span>
</button>
<p role="status">{{ removed && `Button ${removed} pressed` }}</p>
</template>
<style>
/* Hidden on the screen, read by a screen reader */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
</style>

In the running case app, the buttons and links on the cards now name the thing they belong to, with text that's only there for screen readers. The visible text comes first in the name, so that voice control also finds the button by the word on the screen.

According to WCAG 2.1.1, everything has to work with the keyboard, in a sensible order and with a visible focus. WCAG 2.2 also asks that your own content, such as a fixed banner, doesn't fully hide the focus, and that pointer targets are at least 24 by 24 pixels or have enough space around them. You can check this without any tool. Put the mouse aside and go through the page with Tab, Shift and Tab, Enter, the space bar and Escape.

Since chapter 6.5, the running case app has put the focus on the heading of the new page after a navigation. When someone comes back from a thing to the catalog with the back button, the router restores the old position, and until now the focus landed in the search field far up the page. Now the catalog puts the focus on the card of the thing, without the page jumping.

CatalogView.vue
// Back from the page of a thing, the focus returns to the card of that thing, at the position the
// router restores, and the page does not jump. A new visit starts in the search field.
onMounted(() => {
const cameFrom = window.history.state?.forward
const id = typeof cameFrom === 'string' ? cameFrom.match(/\/items\/(\d+)$/)?.[1] : undefined
const link = id && document.querySelector(`main .card a[href$="/items/${id}"]`)
if (link) link.focus({ preventScroll: true })
else searchBar.value.focus()
})

Vue Router keeps the address someone comes back from in history.state, and preventScroll leaves the restored position alone. When an element that has the focus disappears, for example after removing an entry, the app has to pass the focus on as well. Otherwise the keyboard starts again at the top of the page.

App.vue

App.vue
<script setup>
import { nextTick, ref, useTemplateRef } from 'vue'
import { things } from './things.js'
const favorites = ref(things.slice(0, 3))
const heading = useTemplateRef('heading')
const list = useTemplateRef('list')
// The removed button takes its focus along. The focus moves to the entry that takes its place,
// at the end to the one before, and to the heading once the list is empty.
async function remove(index) {
favorites.value = favorites.value.filter((_, position) => position !== index)
await nextTick()
const buttons = list.value?.querySelectorAll('button') ?? []
if (buttons.length > 0) buttons[Math.min(index, buttons.length - 1)].focus()
else heading.value.focus()
}
</script>
<template>
<h2 ref="heading" tabindex="-1">Favorites</h2>
<ul v-if="favorites.length > 0" ref="list">
<li v-for="(thing, index) in favorites" :key="thing.id">
{{ thing.name }}
<button type="button" @click="remove(index)">Remove</button>
</li>
</ul>
<p v-else>No favorites yet.</p>
</template>

things.js

things.js
// A few things of the catalog
export const things = [
{ id: 1, name: 'Drill', category: 'Tools', deposit: 20, availableFrom: null },
{ id: 2, name: 'Tent for four', category: 'Camping', deposit: 50, availableFrom: '2026-11-12' },
{ id: 5, name: 'Jigsaw', category: 'Tools', deposit: 20, availableFrom: null },
{ id: 6, name: 'Projector', category: 'Parties', deposit: 80, availableFrom: null },
{ id: 15, name: 'Circular saw', category: 'Tools', deposit: 40, availableFrom: '2026-11-05' },
]

According to WCAG 4.1.3, a screen reader has to be able to read status messages without them receiving the focus. That's what live regions are for. According to MDN, a screen reader reads a region with role="status" as soon as it's idle, and one with role="alert" right away. The region has to be on the page before its content changes.

App.vue

App.vue
<script setup>
import { computed, ref } from 'vue'
import { things } from './things.js'
const query = ref('')
const hits = computed(() => things.filter((thing) => thing.name.toLowerCase().includes(query.value.trim().toLowerCase())))
</script>
<template>
<label for="search">Search</label>
<input id="search" v-model="query" type="search">
<!-- The live region is there from the start, a screen reader announces each new text -->
<p role="status">{{ hits.length }} things found</p>
<ul>
<li v-for="thing in hits" :key="thing.id">{{ thing.name }}</li>
</ul>
</template>

things.js

things.js
// A few things of the catalog
export const things = [
{ id: 1, name: 'Drill', category: 'Tools', deposit: 20, availableFrom: null },
{ id: 2, name: 'Tent for four', category: 'Camping', deposit: 50, availableFrom: '2026-11-12' },
{ id: 5, name: 'Jigsaw', category: 'Tools', deposit: 20, availableFrom: null },
{ id: 6, name: 'Projector', category: 'Parties', deposit: 80, availableFrom: null },
{ id: 15, name: 'Circular saw', category: 'Tools', deposit: 40, availableFrom: '2026-11-05' },
]

The running case app announces the number of hits in the catalog, sent requests and recorded returns this way. Errors while loading come as role="alert".

Every field needs a label that points to its id with for. According to the Vue documentation, a placeholder doesn't replace a label. When a field reports an error, aria-invalid marks the field, and aria-describedby connects it with the message, which a screen reader then reads together with the label.

ReservationForm.vue
<form class="fields" novalidate @submit.prevent="submit">
<label for="from">First day</label>
<input
id="from"
v-model="request.from"
type="date"
:aria-invalid="!!shownErrors.from"
:aria-describedby="shownErrors.from ? 'from-error' : undefined"
@blur="touched.from = true"
>
<p v-if="shownErrors.from" id="from-error" class="error">{{ shownErrors.from }}</p>

According to WCAG 1.4.3, text needs a contrast of at least 4.5:1 against its background, large text 3:1. For controls and important graphics, WCAG 1.4.11 asks for at least 3:1. The running case app checks its text colors in a test with the formula of the WCAG.

accessibility.test.js
// The contrast ratio of WCAG 2.2 between two colors such as #1f6b4f and #ffffff
function contrast(first, second) {
const luminance = (hex) => {
const [r, g, b] = [1, 3, 5].map((start) => parseInt(hex.slice(start, start + 2), 16) / 255)
const linear = (channel) => (channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4)
return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b)
}
const [light, dark] = [luminance(first), luminance(second)].sort((a, b) => b - a)
return (light + 0.05) / (dark + 0.05)
}

The Chrome DevTools show the tree that assistive technology reads in the "Accessibility" pane, with the role and name of every element. Lighthouse rates the accessibility of a page automatically and lists further items that someone has to check by hand. The Accessibility Inspector of Firefox looks for problems with contrast, keyboard and text labels with "Check for issues". A screen reader shows most clearly what a page sounds like. Windows comes with Narrator, macOS with VoiceOver, and NVDA is free for Windows.

The minimum scope asks for the basic rules of accessibility. Before you hand in, check that every field has a label, that buttons act and links navigate, that every control has a name, that the focus lands in a sensible place after a navigation, that messages appear in live regions and that text reaches the contrast. Use the app once with the keyboard only and run Lighthouse.

Work with the examples of this chapter and with the running case app. Write down what you expect before you try anything.

  1. In the example with the three buttons, open the developer tools and the "Accessibility" pane. Which name does each button have?
  2. In the example with the live region, remove role="status". What changes on the screen, and what for a screen reader?
  3. In the example on removing, remove the lines that set the focus and remove an entry with the keyboard. Where does the next Tab take you?
  4. Load the catalog in the running case app and press Tab. Which element gets the focus first, and what happens on Enter?

The exercises make a page with a search, favorites and a form accessible. You'll find the project in the folder exercises/08-architecture/accessibility/en of the course repo. Copy the folder, install the packages and start the tests.

Terminal window
npm install
npm test

Exercise Getting started

Give the search field and the category select a label each. Turn the star for favorites into a button with aria-pressed whose name mentions the thing, and give the remove buttons a name with the thing.

Solution
SearchPanel.vue
<template>
<section>
<h2>Search</h2>
<label for="search">Search</label>
<input id="search" v-model="query" type="search">
<label for="category">Category</label>
<select id="category" v-model="category">
<option value="">All categories</option>
<option v-for="name in categories" :key="name">{{ name }}</option>
</select>
<!-- A live region exists from the start, a screen reader announces every new number -->
<p class="hits" role="status">{{ visible.length }} things found</p>
<ul class="catalog">
<li v-for="item in visible" :key="item.id">
{{ item.name }}
<!-- A real button with its state, the star is decoration and the hidden text its name -->
<button type="button" class="favorite" :aria-pressed="favoriteIds.includes(item.id)" @click="toggleFavorite(item.id)">
<span aria-hidden="true">{{ favoriteIds.includes(item.id) ? '★' : '☆' }}</span>
<span class="visually-hidden">Favorite: {{ item.name }}</span>
</button>
</li>
</ul>
</section>
</template>

The star stays visible, aria-hidden hides it from the screen reader, and the hidden text supplies the name.

Exercise Going further

Turn the number of hits into a live region that's on the page from the start. In the form, mark a field with an error with aria-invalid and connect it to its message with aria-describedby. Once the field is right again, the connection disappears.

Solution
RequestForm.vue
<form class="fields" novalidate @submit.prevent="submit">
<label for="from">First day</label>
<!-- aria-invalid marks the field, aria-describedby reads the message together with its label -->
<input
id="from"
v-model="request.from"
type="date"
:aria-invalid="!!shown.from"
:aria-describedby="shown.from ? 'from-error' : undefined"
>
<p v-if="shown.from" id="from-error" class="error">{{ shown.from }}</p>
<label for="to">Last day</label>
<input
id="to"
v-model="request.to"
type="date"
:aria-invalid="!!shown.to"
:aria-describedby="shown.to ? 'to-error' : undefined"
>
<p v-if="shown.to" id="to-error" class="error">{{ shown.to }}</p>

Exercise Challenge

Whoever removes a favorite loses the focus together with the button. Move it to the entry that takes its place, at the end of the list to the one before, and to the heading once the list is empty.

Solution
FavoritesList.vue
<script setup>
import { nextTick, useTemplateRef } from 'vue'
defineProps({ items: { type: Array, required: true } })
const emit = defineEmits(['remove'])
const heading = useTemplateRef('heading')
const list = useTemplateRef('list')
// The button disappears together with its focus. The focus moves to the entry that takes its place,
// at the end of the list to the one before, and to the heading once the list is empty.
async function remove(id, index) {
emit('remove', id)
await nextTick()
const buttons = list.value?.querySelectorAll('button.remove') ?? []
if (buttons.length > 0) buttons[Math.min(index, buttons.length - 1)].focus()
else heading.value.focus()
}
</script>
<template>
<section>
<h2 ref="heading" tabindex="-1">Favorites</h2>
<ul v-if="items.length > 0" ref="list" class="favorites">
<li v-for="(item, index) in items" :key="item.id">
{{ item.name }}
<button type="button" class="remove" @click="remove(item.id, index)">
<span aria-hidden="true">✕</span>
<span class="visually-hidden">Remove: {{ item.name }}</span>
</button>
</li>
</ul>
<p v-else>No favorites yet.</p>
</section>
</template>

nextTick waits until Vue has redrawn the list. The heading gets tabindex="-1", so it can take the focus without joining the tab order.

MistakeWhat helps
A div with @click serves as a button.A button comes with a role, the focus and the keyboard.
A field only has a placeholder.Use a label with for.
A live region only appears together with its message and stays silent.Show the region from the start and only change its content.
After removing an entry, the keyboard starts again at the top.Move the focus to the next entry or to a heading.
outline: none takes the keyboard's orientation away.Keep the visible focus or design one of your own, as WCAG 2.4.7 asks.
An AI assistant puts aria-label on elements with visible text and overrides their names that way.Visible text is usually the best name. Use ARIA only where HTML isn't enough.

Self-check

  1. Which WCAG level do most requirements ask for, and which version is current?
  2. Where does a button get its name from?
  3. Why does an SPA have to decide about the focus after a navigation?
  4. How does a page announce a change without moving the focus?
Answers
  1. Level AA, and WCAG 2.2 is current.
  2. From its text, from aria-label or from text that's only there for screen readers.
  3. Without a new page the focus stays where it was, and a screen reader doesn't notice the change.
  4. With a live region, such as an element with role="status" that's already on the page.

Assignment

The member writes back that they can now use the catalog without help. Martina Hostettler now wants to show the app at the general meeting, publicly reachable and without a server of the association. The next chapter builds and publishes it.

Text and figures are licensed under CC BY-SA 4.0, the code under the MIT License and the course data under CC0 1.0. The figures use the font Kalam under the SIL Open Font License 1.1. The sources are on GitHub.