ARIA Where It Is Needed

ARIA changes what is announced and nothing else: name a control, report a state, announce an async result.

Syntax<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-label for a literal string, aria-labelledby for 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

Example · vue
<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.

Example · vue
<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.

Example · vue
<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.

Example · vue
<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

  • Be the first to comment on this lesson.