---
title: "Accessible components: focus, keyboard and ARIA · vuejs.school"
description: "Make Vue components work for keyboard and screen reader users, with native elements, visible focus, arrow-key tabs, useId labels, a live region and a dialog."
canonical_url: "https://vuejs.school/community/blog/accessible-components-focus-keyboard-and-aria"
meta:
  "og:description": "Make Vue components work for keyboard and screen reader users, with native elements, visible focus, arrow-key tabs, useId labels, a live region and a dialog."
  "og:image": "https://vuejs.school/_og/d/a_~QWNjZXNzaWJsZSBjb21wb25lbnRzOiBmb2N1cywga2V5Ym9hcmQgYW5kIEFSSUEgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_~QWNjZXNzaWJsZSBjb21wb25lbnRzOiBmb2N1cywga2V5Ym9hcmQgYW5kIEFSSUE%2Cdescription_~TWFrZSBWdWUgY29tcG9uZW50cyB3b3JrIGZvciBrZXlib2FyZCBhbmQgc2NyZWVuIHJlYWRlciB1c2Vycywgd2l0aCBuYXRpdmUgZWxlbWVudHMsIHZpc2libGUgZm9jdXMsIGFycm93LWtleSB0YWJzLCB1c2VJZCBsYWJlbHMsIGEgbGl2ZSByZWdpb24gYW5kIGEgZGlhbG9nLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNyBtaW4gcmVhZA%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy9hY2Nlc3NpYmxlLWNvbXBvbmVudHMtZm9jdXMta2V5Ym9hcmQtYW5kLWFyaWEi%2Cs_g8qt_dHrSclKorbm.png"
  "og:image:alt": "Accessible components: focus, keyboard and ARIA · vuejs.school"
  "og:image:height": "600"
  "og:image:type": image/png
  "og:image:width": "1200"
  "og:title": "Accessible components: focus, keyboard and ARIA · vuejs.school"
  "twitter:image": "https://vuejs.school/_og/d/a_~QWNjZXNzaWJsZSBjb21wb25lbnRzOiBmb2N1cywga2V5Ym9hcmQgYW5kIEFSSUEgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_~QWNjZXNzaWJsZSBjb21wb25lbnRzOiBmb2N1cywga2V5Ym9hcmQgYW5kIEFSSUE%2Cdescription_~TWFrZSBWdWUgY29tcG9uZW50cyB3b3JrIGZvciBrZXlib2FyZCBhbmQgc2NyZWVuIHJlYWRlciB1c2Vycywgd2l0aCBuYXRpdmUgZWxlbWVudHMsIHZpc2libGUgZm9jdXMsIGFycm93LWtleSB0YWJzLCB1c2VJZCBsYWJlbHMsIGEgbGl2ZSByZWdpb24gYW5kIGEgZGlhbG9nLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNyBtaW4gcmVhZA%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy9hY2Nlc3NpYmxlLWNvbXBvbmVudHMtZm9jdXMta2V5Ym9hcmQtYW5kLWFyaWEi%2Cs_g8qt_dHrSclKorbm.png"
  "twitter:image:alt": "Accessible components: focus, keyboard and ARIA · vuejs.school"
---

> Index of every page on this site: [llms.txt](https://vuejs.school/llms.txt)

## 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.vue**Breaks***

```
<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.vue**Works***

```
<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.vue**Breaks***

```
<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.vue**Works***

```
<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.

## Link labels and hints to inputs with useId()

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](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/) 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 stuck**3 questions from the Accessible components quiz.

[**Practice now**](https://vuejs.school/learn/styling/accessible-components?tab=quiz)

**Was this helpful?**

## **Keep reading**

[Tutorial · 7 min read**Component libraries in 2026: Nuxt UI, PrimeVue, Vuetify and when to use none**](https://vuejs.school/community/blog/component-libraries-in-2026) [Tutorial · 7 min read**Dark mode and theming with CSS variables**](https://vuejs.school/community/blog/dark-mode-and-theming-with-css-variables)

```json
{"@context":"https://schema.org","@graph":[{"@id":"https://vuejs.school/#website","@type":"WebSite","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school/","publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/accessible-components-focus-keyboard-and-aria#webpage","@type":"WebPage","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2026-03-03","description":"Make Vue components work for keyboard and screen reader users, with native elements, visible focus, arrow-key tabs, useId labels, a live region and a dialog.","name":"Accessible components: focus, keyboard and ARIA","url":"https://vuejs.school/community/blog/accessible-components-focus-keyboard-and-aria","breadcrumb":{"@id":"https://vuejs.school/community/blog/accessible-components-focus-keyboard-and-aria#breadcrumb"},"isPartOf":{"@id":"https://vuejs.school/#website"},"potentialAction":[{"@type":"ReadAction","target":["https://vuejs.school/community/blog/accessible-components-focus-keyboard-and-aria"]}],"primaryImageOfPage":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/#identity","@type":"Organization","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school","logo":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/community/blog/accessible-components-focus-keyboard-and-aria#article","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2026-03-03","description":"Make Vue components work for keyboard and screen reader users, with native elements, visible focus, arrow-key tabs, useId labels, a live region and a dialog.","headline":"Accessible components: focus, keyboard and ARIA","inLanguage":"en","isAccessibleForFree":true,"thumbnailUrl":"https://vuejs.school/_og/d/a_~QWNjZXNzaWJsZSBjb21wb25lbnRzOiBmb2N1cywga2V5Ym9hcmQgYW5kIEFSSUEgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_~QWNjZXNzaWJsZSBjb21wb25lbnRzOiBmb2N1cywga2V5Ym9hcmQgYW5kIEFSSUE%2Cdescription_~TWFrZSBWdWUgY29tcG9uZW50cyB3b3JrIGZvciBrZXlib2FyZCBhbmQgc2NyZWVuIHJlYWRlciB1c2Vycywgd2l0aCBuYXRpdmUgZWxlbWVudHMsIHZpc2libGUgZm9jdXMsIGFycm93LWtleSB0YWJzLCB1c2VJZCBsYWJlbHMsIGEgbGl2ZSByZWdpb24gYW5kIGEgZGlhbG9nLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNyBtaW4gcmVhZA%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy9hY2Nlc3NpYmxlLWNvbXBvbmVudHMtZm9jdXMta2V5Ym9hcmQtYW5kLWFyaWEi%2Cs_g8qt_dHrSclKorbm.png","@type":["Article","BlogPosting"],"articleSection":["Tutorial"],"author":{"@id":"https://vuejs.school/#identity"},"image":{"@id":"https://vuejs.school/#/schema/image/1"},"isPartOf":{"@id":"https://vuejs.school/community/blog/accessible-components-focus-keyboard-and-aria#webpage"},"mainEntityOfPage":{"@id":"https://vuejs.school/community/blog/accessible-components-focus-keyboard-and-aria#webpage"},"publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/accessible-components-focus-keyboard-and-aria#breadcrumb","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","name":"Blog","item":"https://vuejs.school/community/blog","position":1},{"@type":"ListItem","name":"Accessible components: focus, keyboard and ARIA","position":2}]},{"@id":"https://vuejs.school/#logo","@type":"ImageObject","caption":"vuejs.school","contentUrl":"https://vuejs.school/apple-touch-icon.png","inLanguage":"en","url":"https://vuejs.school/apple-touch-icon.png"},{"@id":"https://vuejs.school/#/schema/image/1","@type":"ImageObject","contentUrl":"https://vuejs.school/_og/d/a_~QWNjZXNzaWJsZSBjb21wb25lbnRzOiBmb2N1cywga2V5Ym9hcmQgYW5kIEFSSUEgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_~QWNjZXNzaWJsZSBjb21wb25lbnRzOiBmb2N1cywga2V5Ym9hcmQgYW5kIEFSSUE%2Cdescription_~TWFrZSBWdWUgY29tcG9uZW50cyB3b3JrIGZvciBrZXlib2FyZCBhbmQgc2NyZWVuIHJlYWRlciB1c2Vycywgd2l0aCBuYXRpdmUgZWxlbWVudHMsIHZpc2libGUgZm9jdXMsIGFycm93LWtleSB0YWJzLCB1c2VJZCBsYWJlbHMsIGEgbGl2ZSByZWdpb24gYW5kIGEgZGlhbG9nLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNyBtaW4gcmVhZA%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy9hY2Nlc3NpYmxlLWNvbXBvbmVudHMtZm9jdXMta2V5Ym9hcmQtYW5kLWFyaWEi%2Cs_g8qt_dHrSclKorbm.png","inLanguage":"en","url":"https://vuejs.school/_og/d/a_~QWNjZXNzaWJsZSBjb21wb25lbnRzOiBmb2N1cywga2V5Ym9hcmQgYW5kIEFSSUEgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_~QWNjZXNzaWJsZSBjb21wb25lbnRzOiBmb2N1cywga2V5Ym9hcmQgYW5kIEFSSUE%2Cdescription_~TWFrZSBWdWUgY29tcG9uZW50cyB3b3JrIGZvciBrZXlib2FyZCBhbmQgc2NyZWVuIHJlYWRlciB1c2Vycywgd2l0aCBuYXRpdmUgZWxlbWVudHMsIHZpc2libGUgZm9jdXMsIGFycm93LWtleSB0YWJzLCB1c2VJZCBsYWJlbHMsIGEgbGl2ZSByZWdpb24gYW5kIGEgZGlhbG9nLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNyBtaW4gcmVhZA%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy9hY2Nlc3NpYmxlLWNvbXBvbmVudHMtZm9jdXMta2V5Ym9hcmQtYW5kLWFyaWEi%2Cs_g8qt_dHrSclKorbm.png"}]}
```