Skip to content
TutorialStyling & UI

Accessible components: focus, keyboard and ARIA

A component that only works with a mouse is broken for a lot of people. Most of the fix is choosing the right element; the rest is a few lines of keyboard handling and some aria-* bindings.

vuejs.school team3 Mar 20267 min readVue 3.5

Some people never touch a mouse. They move through a page with Tab and the arrow keys, or listen to it through a screen reader, software that reads the page aloud and announces what each control is. A component that ignores them is broken for them, however good it looks.

Each section below takes a piece of UI most apps have, shows how it fails these users and fixes it in Vue 3.5. The last one shows how to test your own components, with the keyboard and with axe. The examples are plain Vue components from a project created with npm create vue@latest, with explicit imports. None of the components needs a library.

Pick the native element for each control

HTML already has accessible controls. A <button> can be reached with Tab, fires click on Enter and Space, and tells a screen reader "button". A <div> does none of that. A clickable <div> is a common accessibility bug in component code:

src/components/SaveAction.vueBreaks
<script setup lang="ts">
const emit = defineEmits<{ save: [] }>()
</script>

<template>
  <div class="button" @click="emit('save')">Save</div>
</template>

It looks like a button and works with a mouse. A keyboard user cannot reach it, and a screen reader reads it as plain text. Adding tabindex, role="button" and a keydown handler gets you part of the way, but you would be rebuilding something the browser gives you for free:

src/components/SaveAction.vueWorks
<script setup lang="ts">
const emit = defineEmits<{ save: [] }>()
</script>

<template>
  <button type="button" class="button" @click="emit('save')">Save</button>
</template>

The rule for choosing: if it does something on the page, it is a <button>. If it goes somewhere, it is an <a href> or a <RouterLink>. If the user types or picks a value, it is an <input>, <select> or <textarea>. Style them however you like; CSS can remove every trace of the default look.

Give every control an accessible name

A screen reader announces a control by its role and its accessible name, the text that identifies it. A button's name is usually its text. An icon button has none:

src/components/CloseButton.vueBreaks
<script setup lang="ts">
defineEmits<{ close: [] }>()
</script>

<template>
  <button type="button" @click="$emit('close')">
    <svg viewBox="0 0 24 24" width="24" height="24">
      <path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" />
    </svg>
  </button>
</template>

A screen reader user hears "button" and nothing else. Give it a name with aria-label, and hide the decorative icon with aria-hidden="true":

src/components/CloseButton.vueWorks
<script setup lang="ts">
defineEmits<{ close: [] }>()
</script>

<template>
  <button type="button" aria-label="Close dialog" @click="$emit('close')">
    <svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true">
      <path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" />
    </svg>
  </button>
</template>

Name it for what it does, not what it shows: "Close dialog", not "X icon".

Keep a visible focus ring

Keyboard users need to see where focus is. Browsers draw a focus ring by default, and the classic mistake is a reset stylesheet with outline: none and nothing in its place. WCAG, the web's accessibility standard, requires a visible focus indicator at level AA. Style one rule for the whole app:

src/assets/base.css
:focus-visible {
  outline: 3px solid #1d4ed8;
  outline-offset: 2px;
}

:focus-visible matches when the browser decides focus should be shown, which is always for keyboard focus and usually not for a mouse click on a button. So you do not have to remove the ring to stop it appearing on every click. Pick a colour with at least 3:1 contrast against the backgrounds it sits on, and check it on dark sections too.

Every input needs a label tied to it with for and id. In a reusable component the id must be unique on the page, and with server rendering it must match on server and client. useId(), added in Vue 3.5, does both:

src/components/TextField.vue
<script setup lang="ts">
import { useId } from 'vue'

defineProps<{ label: string; hint?: string }>()
const value = defineModel<string>({ default: '' })
const inputId = useId()
const hintId = useId()
</script>

<template>
  <div>
    <label :for="inputId">{{ label }}</label>
    <input :id="inputId" v-model="value" :aria-describedby="hint ? hintId : undefined">
    <p v-if="hint" :id="hintId">{{ hint }}</p>
  </div>
</template>

Each call returns a different id, and each instance of the component gets its own. The docs warn against calling it inside computed(); call it at the top of <script setup>.

The aria-describedby binding shows how Vue treats aria-* attributes. undefined or null removes the attribute, which is what you want when there is no hint. A boolean is written out as text: :aria-selected="false" renders aria-selected="false", which is exactly what ARIA expects. Vue 2 removed the attribute on false; Vue 3 does not.

Build tabs that work with arrow keys

Tabs are a widget HTML has no element for, so ARIA fills the gap. The ARIA Authoring Practices Guide describes the expected keyboard behaviour: Tab moves into the tab list and lands on the active tab, the Left and Right arrows move between tabs and wrap at the ends, and Home and End (optional in the guide) jump to the first and last. Only one tab is in the Tab order at a time, a technique called roving tabindex.

src/components/SettingsTabs.vue
<script setup lang="ts">
import { ref, useId } from 'vue'

const tabs = ['Profile', 'Security', 'Billing']
const baseId = useId()
const selected = ref(0)

const tabId = (index: number) => `${baseId}-tab-${index}`
const panelId = (index: number) => `${baseId}-panel-${index}`

function select(index: number) {
  selected.value = (index + tabs.length) % tabs.length
  document.getElementById(tabId(selected.value))?.focus()
}

function onKeydown(event: KeyboardEvent) {
  const targets: Record<string, number> = {
    ArrowRight: selected.value + 1,
    ArrowLeft: selected.value - 1,
    Home: 0,
    End: tabs.length - 1,
  }
  const target = targets[event.key]
  if (target === undefined) return
  event.preventDefault()
  select(target)
}
</script>

<template>
  <div>
    <div role="tablist" aria-label="Settings" @keydown="onKeydown">
      <button
        v-for="(tab, index) in tabs"
        :id="tabId(index)"
        :key="tab"
        type="button"
        role="tab"
        :aria-selected="index === selected"
        :aria-controls="panelId(index)"
        :tabindex="index === selected ? 0 : -1"
        @click="select(index)"
      >
        {{ tab }}
      </button>
    </div>
    <div
      v-for="(tab, index) in tabs"
      v-show="index === selected"
      :id="panelId(index)"
      :key="tab"
      role="tabpanel"
      :aria-labelledby="tabId(index)"
      tabindex="0"
    >
      <slot :name="tab.toLowerCase()" />
    </div>
  </div>
</template>

What each part does:

  • The tabs are still <button> elements, so click, Enter and Space work with no extra code.
  • role="tab" and aria-selected make a screen reader announce something like "Security, tab, selected, 2 of 3".
  • tabindex keeps only the active tab in the Tab order, so one press of Tab moves past the whole list into the panel.
  • select() wraps the index with %, so the Right arrow on the last tab goes to the first. It also moves selection and focus together, which the guide recommends when the panel shows without delay; v-show keeps every panel rendered, so that holds here.
  • Focus is moved by id rather than through template refs, because Vue's docs warn that a ref array from v-for is not guaranteed to follow the source order.

A parent fills the panels through named slots such as <template #security>.

Announce async status with a live region

When a save finishes, a sighted user sees "Saved" appear. A screen reader user hears nothing unless the text sits in a live region, an element whose changes are read aloud. This button reports a save's progress in one:

src/components/SaveButton.vue
<script setup lang="ts">
import { ref } from 'vue'

const props = defineProps<{ save: () => Promise<void> }>()
const saving = ref(false)
const status = ref('')

async function onClick() {
  if (saving.value) return
  saving.value = true
  status.value = 'Saving…'
  try {
    await props.save()
    status.value = 'Changes saved'
  }
  catch {
    status.value = 'Saving failed. Try again.'
  }
  finally {
    saving.value = false
  }
}
</script>

<template>
  <div>
    <button type="button" :aria-disabled="saving" @click="onClick">Save</button>
    <p role="status">{{ status }}</p>
  </div>
</template>

role="status" makes a polite live region: the screen reader finishes its current sentence, then reads the new text. The paragraph is always rendered and only its text changes. A region created with v-if at the same moment as its message is often not announced at all, because nothing was watching it yet.

The button uses aria-disabled rather than disabled to keep focus where it is: a button that becomes disabled while it has focus loses it, and the keyboard user has to find their place again. aria-disabled does not stop clicks, though, which is why onClick returns early while a save is running.

Keep focus inside a modal dialog

A modal dialog must keep focus inside while it is open, close on Escape, and hand focus back when it closes. The native <dialog> element opened with showModal() does the first two: the browser makes the rest of the page inert, so Tab cannot reach anything behind it, and Escape closes it. The HTML standard has the browser return focus as well; the component below also does it itself, a one-line fallback.

A parent controls this dialog through v-model:open, as in <ConfirmDialog v-model:open="confirming" @confirm="deleteCard" />:

src/components/ConfirmDialog.vue
<script setup lang="ts">
import { useId, useTemplateRef, watchPostEffect } from 'vue'

const open = defineModel<boolean>('open', { required: true })
const emit = defineEmits<{ confirm: [] }>()
const dialog = useTemplateRef<HTMLDialogElement>('dialog')
const titleId = useId()
let opener: HTMLElement | null = null

watchPostEffect(() => {
  const el = dialog.value
  if (!el) return
  if (open.value && !el.open) {
    opener = document.activeElement as HTMLElement | null
    el.showModal()
  }
  else if (!open.value && el.open) {
    el.close()
  }
})

function onClose() {
  open.value = false
  opener?.focus()
}
</script>

<template>
  <dialog ref="dialog" :aria-labelledby="titleId" @close="onClose">
    <h2 :id="titleId">Delete this card?</h2>
    <p>This cannot be undone.</p>
    <button type="button" autofocus @click="open = false">Cancel</button>
    <button type="button" @click="emit('confirm'); open = false">Delete</button>
  </dialog>
</template>
  • watchPostEffect calls showModal() or close() when open changes. It runs after rendering, so the template ref exists.
  • The close event fires however the dialog closed, Escape included, so onClose keeps open in sync and returns focus to the button that opened it.
  • autofocus puts focus on Cancel when the dialog opens, which is the safe choice for a destructive action.

Test with the keyboard, then with axe

Put the mouse away and go through the page:

  • Tab through everything, then Shift+Tab back. Every stop shows a focus ring, and the order follows the layout.
  • Press Enter and Space on every control.
  • In the tabs, use the arrows, Home and End. Tab should leave the list in one press.
  • Open the dialog, press Escape, and check that focus is back on the button you started from.

Then automate the parts a machine can check. axe is an open-source rule engine for accessibility, and @axe-core/playwright runs it inside a Playwright test. If you picked Playwright in npm create vue@latest, tests live in e2e/, and the config already starts the dev server and sets baseURL. Install the package with npm install -D @axe-core/playwright. The tests assume a /settings route that renders SettingsTabs:

e2e/settings.spec.ts
import { expect, test } from '@playwright/test'
import { AxeBuilder } from '@axe-core/playwright'

test('tabs follow the arrow keys', async ({ page }) => {
  await page.goto('/settings')
  await page.getByRole('tab', { name: 'Profile' }).focus()
  await page.keyboard.press('ArrowRight')

  const security = page.getByRole('tab', { name: 'Security' })
  await expect(security).toBeFocused()
  await expect(security).toHaveAttribute('aria-selected', 'true')
})

test('settings page has no axe violations', async ({ page }) => {
  await page.goto('/settings')
  const results = await new AxeBuilder({ page }).analyze()
  expect(results.violations).toEqual([])
})

getByRole finds elements through their role and accessible name, so the first test fails if either is wrong. axe catches missing names, missing labels, low contrast and invalid ARIA. It cannot tell whether your focus order makes sense or whether a dialog returns focus, which is why the keyboard pass comes first.

Check it stuck3 questions from the Accessible components quiz.
Practice now
Was this helpful?

Keep reading