---
title: Accessibility Guide - WCAG and ARIA Best Practices
features:
  order: 2
  level: 2
meta:
  - name: description
    content: Build accessible Vue 3 applications with Vuetify0. Learn ARIA patterns, keyboard navigation, focus management, and WCAG compliance for headless UI components.
  - name: keywords
    content: vuetify0, accessibility, a11y, ARIA, WCAG, keyboard navigation, focus management, screen reader, Vue 3
related:
  - /composables/plugins/use-locale
  - /composables/plugins/use-rtl
  - /guide/fundamentals/components
---

# Accessibility

v0 provides ARIA attributes out-of-the-box through the `attrs` pattern. You provide styling and visual feedback.

<DocsPageFeatures :frontmatter />

## The attrs Pattern

Every v0 component exposes an `attrs` object containing all accessibility attributes. Spread it onto your elements:

```vue playground
<script setup>
  import { Selection } from '@vuetify/v0'

  const items = ['Apple', 'Banana', 'Cherry']
</script>

<template>
  <Selection.Root v-slot="{ attrs }">
    <div v-bind="attrs">
      <Selection.Item v-for="item in items" :key="item" v-slot="{ attrs }">
        <button v-bind="attrs">{{ item }}</button>
      </Selection.Item>
    </div>
  </Selection.Root>
</template>
```

### What's Included in attrs

| Component | ARIA Attributes Provided |
| - | - |
| Selection.Item | `aria-selected`, `aria-disabled`, `data-selected`, `data-disabled` |
| Group.Item | `role="checkbox"`, `aria-checked`, `aria-disabled`, `data-selected`, `data-disabled`, `data-mixed` |
| ExpansionPanel.Activator | `id`, `role`, `tabindex`, `aria-expanded`, `aria-controls`, `aria-disabled` |
| Pagination.Root | `aria-label`, `role="navigation"`[^pagination-nav] |
| Popover.Activator | `popovertarget`, `data-open`[^popover-native] |

[^pagination-nav]: `role="navigation"` is only added when the root element isn't already a `<nav>`. If you render `Pagination.Root as="nav"`, the role is omitted to avoid redundant landmark roles.

[^popover-native]: `Popover.Activator` wires the [native Popover API](https://developer.mozilla.org/en-US/docs/Web/API/Popover_API) via the `popovertarget` attribute. See the [Browser Support](/introduction/browser-support) page for fallback behavior in older browsers.

> [!TIP]
> Always spread the `attrs` object from slot props onto your interactive elements. Missing ARIA attributes break screen reader support.

## Developer Responsibilities

v0 provides the ARIA plumbing. You must provide:

| Responsibility | Example |
| - | - |
| Visual focus indicators | `:focus-visible { outline: 2px solid blue }` |
| Color contrast | Ensure 4.5:1 ratio minimum |
| Visible labels | Add `<label>` or `aria-label` for inputs |
| Skip links | Navigation landmarks for keyboard users |

### Focus Trapping

v0 does **not** provide focus trapping. Use external solutions:

- [focus-trap](https://github.com/focus-trap/focus-trap)
- Native `inert` attribute for siblings
- [vue-final-modal](https://vue-final-modal.org/)

### Roving Tabindex

v0 does **not** provide roving tabindex. This keeps the library headless - implement in your design system layer if needed for arrow key navigation between items.

### Teleported Content and Landmarks

Content teleported by [Portal](/components/primitives/portal) renders into `body`, outside your app's landmarks, so audit tools flag it with the axe [region](https://dequeuniversity.com/rules/axe/4.12/region) rule. v0 is headless — it won't pick a landmark role for you. Give the teleported subtree its own semantics: `role="dialog"` for modals (exempt from the landmark rule), `role="status"` or `role="alert"` for toast regions, or `role="region"` plus `aria-label` for arbitrary overlays. See the [Portal accessibility notes](/components/primitives/portal#landmarks) for an example.

## Keyboard Navigation

### What v0 Handles

- `tabindex` management (-1 when disabled, 0 when enabled)
- ARIA state synchronization (`aria-expanded`, `aria-selected`)
- Data attributes for styling (`data-selected`, `data-disabled`)

### What You Implement

| Pattern | Keys to Handle |
| - | - |
| List selection | Arrow keys, Home/End |
| Menus | Arrow keys, Escape, Enter |
| Dialogs | Escape to close, focus trap |
| Tabs | Arrow keys, Home/End |

```ts
// You implement navigation logic - v0 provides selection state
function onKeydown (e: KeyboardEvent, ids: string[], currentIndex: number) {
  switch (e.key) {
    case 'ArrowDown': selection.select(ids[currentIndex + 1]); break
    case 'ArrowUp': selection.select(ids[currentIndex - 1]); break
    case 'Home': selection.select(ids[0]); break
    case 'End': selection.select(ids[ids.length - 1]); break
  }
}
```

## Testing Strategies

### Automated Testing

```ts MyComponent.test.ts
import { axe } from 'vitest-axe'

it('passes accessibility audit', async () => {
  const { container } = render(MyComponent)
  expect(await axe(container)).toHaveNoViolations()
})
```

`vitest-axe` is a Node package — it reaches for `node:module` on import, so it works in a jsdom or happy-dom test environment and not in Vitest's browser mode. If your components are tested in a real browser, drop the wrapper and call `axe-core` directly:

```ts MyComponent.browser.test.ts
import axe from 'axe-core'

it('passes accessibility audit', async () => {
  const { container } = render(MyComponent)
  const results = await axe.run(container)
  expect(results.violations).toEqual([])
})
```

Prefer the browser form where you can. Layout-dependent rules — colour contrast, target size, element overlap — need real computed styles, and a simulated DOM either skips them or answers from a layout that does not exist. v0 audits its own components this way; see `packages/0/src/components/a11y.browser.test.ts`.

### Manual Testing Checklist

Use this checklist during manual QA:

```markdown
- [ ] Tab through all interactive elements
- [ ] Verify focus visibility
- [ ] Test with keyboard only (no mouse)
- [ ] Check color contrast with DevTools
- [ ] Validate with browser accessibility tree
```

### Recommended Tools

| Tool | Purpose |
| - | - |
| axe DevTools | Browser extension for WCAG scanning |
| Lighthouse | Built-in Chrome audit |
| NVDA/VoiceOver | Screen reader verification |

## Internationalization

Every v0 component ships an inline English accessible name for the ARIA labels it renders — a Dialog close button reads `"Close"`, a pagination landmark reads `"Pagination"`, and so on. Each default lives at the component's call site as `locale.ti(key) ?? 'English default'`, so an app that never installs a locale plugin still satisfies [WCAG 4.1.2 Name, Role, Value](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value.html) out of the box. No locale strings are bundled into the runtime.

To localize those names, install the [Locale plugin](/composables/plugins/use-locale) with translations for the component keys (`Dialog.close`, `Pagination.label`, `Carousel.next`, …). When a key resolves the component uses your translation; when it doesn't, the inline English default still renders. The optional `@vuetify/v0/locale/messages/en` export is the canonical map of every key v0 components look up — import it as a starting point for a new translation, or register it as-is for a complete English baseline.

For RTL (right-to-left) support, see `useRtl`. Direction is managed independently from locale — `useRtl` provides a reactive `isRtl` boolean and sets the `dir` attribute on the target element.
