ARIA Where It Is Needed
ARIA changes what is announced and nothing else: name a control, report a state, announce an async result.
<button :aria-expanded="open" :aria-controls="panelId">ARIA is a set of attributes that change the role, name, description or state the browser reports to assistive technology. It changes nothing else: no focus, no keyboard handling, no styling. Hence the first rule of ARIA — if a native HTML element or attribute already does the job, use that instead. A role="button" div still needs tabindex and key handlers to work; a real <button> needs neither.
Four jobs worth knowing
- Name —
aria-labelfor a literal string,aria-labelledbyfor the id of visible text. Both override the element's own text. - Describe —
aria-describedby, announced after the name, for a hint or an error. - State —
aria-expanded,aria-pressed,aria-invalid,aria-current. These must follow your reactive state, not be written once. - Announce —
aria-live="polite"on a container means later changes to its text get read out.role="alert"is the assertive version and interrupts.
How bindings behave
A binding of null or undefined removes the attribute. false on an aria-* attribute renders the string "false", which is what ARIA wants. So :aria-expanded="open" and :aria-expanded="open ? 'true' : 'false'" produce the same markup, and :aria-expanded is itself shorthand for v-bind:aria-expanded.
What trips people up
A live region only announces changes made while it is already in the document. Render the empty container first and fill it later; a region created by v-if at the moment the result arrives says nothing. Every id reference has the same requirement.
The example draws a heading with a toggle button; clicking it shows or hides the panel and flips the announced state between expanded and collapsed.
Example
<script setup lang="ts">
import { ref, useId } from 'vue'
const props = defineProps<{
heading: string
}>()
const open = ref(false)
const panelId = useId()
</script>
<template>
<div>
<h3>
<button
type="button"
:aria-expanded="open"
:aria-controls="panelId"
@click="open = !open"
>
{{ props.heading }}
</button>
</h3>
<div v-show="open" :id="panelId">
<slot />
</div>
</div>
</template>
<style scoped>
button {
display: block;
width: 100%;
text-align: left;
}
button:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
</style>When to use it
- Announcing "no matching orders" after an async filter without moving focus away from the search box.
- Giving a star or bookmark toggle a name and a pressed state instead of only a changing icon.
- Telling a screen reader that a collapsed section's button controls the panel below it.
- Marking the current page in a navigation list built from RouterLink.
More examples
A live region for an async result
A spinner appearing and a list changing length are silent events. The <p> is in the document from first render with empty text, so each new value of status is announced; aria-live="polite" waits for a pause rather than cutting in. Keep the sentence short, because it is read in full every time. aria-disabled is used rather than the disabled attribute so the button stays focusable and keeps announcing its name while the request is in flight; guard the handler in script if you also need to block the second click.
<script setup lang="ts">
import { ref } from 'vue'
type Product = { id: number; name: string }
const term = ref('')
const products = ref<Product[]>([])
const status = ref('')
const loading = ref(false)
async function search() {
loading.value = true
status.value = 'Searching'
const response = await fetch(`/api/products?q=${encodeURIComponent(term.value)}`)
products.value = (await response.json()) as Product[]
loading.value = false
status.value = `${products.value.length} results for ${term.value}`
}
</script>
<template>
<form role="search" @submit.prevent="search">
<label for="product-search">Search products</label>
<input id="product-search" v-model="term" type="search" />
<button type="submit" :aria-disabled="loading">Search</button>
</form>
<p aria-live="polite">{{ status }}</p>
<ul>
<li v-for="product in products" :key="product.id">{{ product.name }}</li>
</ul>
</template>
<style scoped>
ul {
margin-top: 1rem;
}
</style>A toggle that reports its pressed state
A star that changes shape communicates nothing to a screen reader, and nothing at all in forced-colors mode. aria-pressed makes the button announce as "Save for later, toggle button, pressed". The glyph is marked aria-hidden so only the label is read. defineModel is stable from Vue 3.4 and returns a writable ref, so the parent writes v-model="saved" and this component assigns to model directly; in the template a setup ref is unwrapped, so model = !model compiles to the .value assignment. In script you would have to write model.value.
<script setup lang="ts">
const props = defineProps<{
label: string
}>()
const model = defineModel<boolean>({ required: true })
</script>
<template>
<button type="button" :aria-pressed="model" @click="model = !model">
<span aria-hidden="true">{{ model ? '★' : '☆' }}</span>
{{ props.label }}
</button>
</template>
<style scoped>
button[aria-pressed='true'] {
font-weight: 600;
}
button:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
</style>aria-current in a navigation list
Vue Router 5 already renders aria-current="page" on the exact-active link, so the current item is announced as "Overview, current page" without you adding anything. Change the value with the ariaCurrentValue prop, written aria-current-value in the template; it accepts page, step, location, date, time, true and false. Styling the attribute instead of the active class means the visual highlight and the announced state cannot drift apart. The :key is the route path because these items never reorder by index.
<script setup lang="ts">
type Link = { to: string; text: string }
const links: Link[] = [
{ to: '/', text: 'Overview' },
{ to: '/tickets', text: 'Tickets' },
{ to: '/settings', text: 'Settings' }
]
</script>
<template>
<nav aria-label="Main">
<ul>
<li v-for="link in links" :key="link.to">
<router-link :to="link.to">{{ link.text }}</router-link>
</li>
</ul>
</nav>
</template>
<style scoped>
a[aria-current='page'] {
font-weight: 600;
}
</style>
Discussion