---
title: "Accessible form errors: aria-describedby, live regions and focus · vuejs.school"
description: "Build form errors that screen readers announce and everyone can fix. Link messages with aria-describedby, announce a summary, move focus, then test it."
canonical_url: "https://vuejs.school/community/blog/accessible-form-errors"
meta:
  "og:description": "Build form errors that screen readers announce and everyone can fix. Link messages with aria-describedby, announce a summary, move focus, then test it."
  "og:image": "https://vuejs.school/_og/d/a_~QWNjZXNzaWJsZSBmb3JtIGVycm9yczogYXJpYS1kZXNjcmliZWRieSwgbGl2ZSByZWdpb25zIGFuZCBmb2N1cyDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~QWNjZXNzaWJsZSBmb3JtIGVycm9yczogYXJpYS1kZXNjcmliZWRieSwgbGl2ZSByZWdpb25zIGFuZCBmb2N1cw%2Cdescription_~QnVpbGQgZm9ybSBlcnJvcnMgdGhhdCBzY3JlZW4gcmVhZGVycyBhbm5vdW5jZSBhbmQgZXZlcnlvbmUgY2FuIGZpeC4gTGluayBtZXNzYWdlcyB3aXRoIGFyaWEtZGVzY3JpYmVkYnksIGFubm91bmNlIGEgc3VtbWFyeSwgbW92ZSBmb2N1cywgdGhlbiB0ZXN0IGl0Lg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy9hY2Nlc3NpYmxlLWZvcm0tZXJyb3JzIg%2Cs_OIiTy2Zw1YkbVy7c.png"
  "og:image:alt": "Accessible form errors: aria-describedby, live regions and focus · vuejs.school"
  "og:image:height": "600"
  "og:image:type": image/png
  "og:image:width": "1200"
  "og:title": "Accessible form errors: aria-describedby, live regions and focus · vuejs.school"
  "twitter:image": "https://vuejs.school/_og/d/a_~QWNjZXNzaWJsZSBmb3JtIGVycm9yczogYXJpYS1kZXNjcmliZWRieSwgbGl2ZSByZWdpb25zIGFuZCBmb2N1cyDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~QWNjZXNzaWJsZSBmb3JtIGVycm9yczogYXJpYS1kZXNjcmliZWRieSwgbGl2ZSByZWdpb25zIGFuZCBmb2N1cw%2Cdescription_~QnVpbGQgZm9ybSBlcnJvcnMgdGhhdCBzY3JlZW4gcmVhZGVycyBhbm5vdW5jZSBhbmQgZXZlcnlvbmUgY2FuIGZpeC4gTGluayBtZXNzYWdlcyB3aXRoIGFyaWEtZGVzY3JpYmVkYnksIGFubm91bmNlIGEgc3VtbWFyeSwgbW92ZSBmb2N1cywgdGhlbiB0ZXN0IGl0Lg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy9hY2Nlc3NpYmxlLWZvcm0tZXJyb3JzIg%2Cs_OIiTy2Zw1YkbVy7c.png"
  "twitter:image:alt": "Accessible form errors: aria-describedby, live regions and focus · vuejs.school"
---

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

## Link each message to its field

A screen reader reads a field's label when the field gets focus. It does not read the paragraph underneath unless you connect the two. The connection is `aria-describedby`: put an `id` on the message and point the field's `aria-describedby` at it. Now the message is read as the field's description, every time the user arrives on it.

`id` values must be unique on the page, so generate them with `useId()`, which Vue 3.5 added for exactly this: stable IDs that match between server and client rendering.

Start the component with the email field. Every block until the VeeValidate section adds to this one file.

*src/components/SignupForm.vue*

```
<script setup lang="ts">
import { reactive, useId } from 'vue'

const values = reactive({ email: '', password: '' })
const errors = reactive({ email: '', password: '' })
const emailId = useId()
</script>

<template>
  <label :for="emailId">Email address</label>
  <input
    :id="emailId"
    v-model="values.email"
    type="email"
    autocomplete="email"
    :aria-invalid="errors.email ? 'true' : undefined"
    :aria-describedby="errors.email ? `${emailId}-error` : undefined"
  >
  <p v-if="errors.email" :id="`${emailId}-error`">
    <strong>Error:</strong> {{ errors.email }}
  </p>
</template>
```

`aria-invalid="true"` makes the screen reader say the field is invalid ("invalid data" in VoiceOver, "invalid entry" in NVDA). Binding it to `undefined` when there is no error removes the attribute entirely, which is cleaner than `aria-invalid="false"`.

The visible part matters as much. Colour alone must never carry the meaning; that is a WCAG rule (1.4.1, Use of Color), and it is why the message starts with the word "Error:". Add an icon or a thicker border if your design wants one, but keep the text.

If a field also has a hint, such as "At least 12 characters", give the hint its own `id` and list both in `aria-describedby`, separated by a space: `${hintId} ${errorId}`.

## Write errors that say how to fix them

WCAG asks that an error identifies the problem in text ([3.3.1, Error Identification](https://www.w3.org/WAI/WCAG22/Understanding/error-identification.html)) and, when the fix is known, suggests it (3.3.3, Error Suggestion). In practice, write the message as the instruction the user should follow:

- "Invalid email" becomes "Enter an email address like [name@example.com](mailto:name@example.com)".
- "Required" becomes "Enter your email address".
- "Password too short" becomes "Enter a password of at least 12 characters".

Each one names the field, says what to type, and avoids blame. Keep it short enough to read in one breath, because a screen reader will read it every time the field gets focus.

## Validate on blur, then as they type

Timing decides whether errors feel helpful or hostile. Checking on every keystroke from the start shows "Enter an email address like [name@example.com](mailto:name@example.com)" while someone is halfway through typing their address. Checking only on submit makes them hunt for problems at the end.

The pattern that works: check a field when the user leaves it (the `blur` event). Once it has an error, check again on every keystroke, so the message disappears the moment the value becomes valid.

*src/components/SignupForm.vue*

```
type Field = keyof typeof values

function check(field: Field): string {
  if (field === 'email') {
    const email = values.email.trim()
    if (!email) return 'Enter your email address'
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return 'Enter an email address like name@example.com'
    return ''
  }
  return values.password.length < 12 ? 'Enter a password of at least 12 characters' : ''
}

function onBlur(field: Field) {
  errors[field] = check(field)
}

function onInput(field: Field) {
  if (errors[field]) errors[field] = check(field)
}
```

Add these functions to `<script setup>`, below `emailId`, and wire them to the input with `@blur="onBlur('email')"` and `@input="onInput('email')"`. The error stays visible while it is still true and goes away as soon as it is not, without ever appearing mid-word on the first attempt.

## Announce a summary after submit

When a submit fails, something has to tell a screen reader user what happened. That is the job of a **live region**: an element whose content changes are announced without moving focus. `role="status"` makes a polite one, read when the screen reader finishes its current sentence.

Wrap the fields in a `<form>` with the region at the top; `summary` and `onSubmit` come in the next section. The common mistake is creating the region at the same moment as its text:

*src/components/SignupForm.vue**Breaks***

```
<template>
  <form novalidate @submit.prevent="onSubmit">
    <p v-if="summary" role="status">{{ summary }}</p>
    <!-- fields and submit button -->
  </form>
</template>
```

Screen readers announce changes inside a live region they already know about. An element that arrives with its text already inside is, in many browser and screen reader combinations, never announced. [MDN's live region guide](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Guides/Live_regions) says it directly: establish the region before updating its content, and the most reliable way is to put it in the initial markup.

*src/components/SignupForm.vue**Works***

```
<template>
  <form novalidate @submit.prevent="onSubmit">
    <p role="status">{{ summary }}</p>
    <!-- fields and submit button -->
  </form>
</template>
```

The paragraph is always in the page, empty until there is something to announce.

`novalidate` turns off the browser's own validation. Without it, a malformed address in a `type="email"` field makes the browser block the submit and show its own bubble, and your handler never runs.

## Move focus to the first invalid field

The summary says what went wrong. Focus should take the user to where to fix it. Move it to the first invalid field: the screen reader then reads that field's label, "invalid", and its error message, because of the `aria-describedby` link you already built. The submit handler goes in `<script setup>` with the rest:

*src/components/SignupForm.vue*

```
const summary = ref('')
const labels: Record<Field, string> = { email: 'Email address', password: 'Password' }
const inputs = {
  email: useTemplateRef<HTMLInputElement>('email-input'),
  password: useTemplateRef<HTMLInputElement>('password-input'),
}

async function onSubmit() {
  const fields: Field[] = ['email', 'password']
  for (const field of fields) errors[field] = check(field)
  const invalid = fields.filter(field => errors[field])
  const [first] = invalid

  summary.value = ''
  if (!first) return // all valid: send the form here

  await nextTick()
  summary.value = `There is a problem with: ${invalid.map(field => labels[field]).join(', ')}.`
  inputs[first].value?.focus()
}
```

Clearing the summary and waiting for `nextTick` before setting it again matters on the second failed submit: the same text written twice is no change at all, so nothing would be announced.

To finish the component:

1. Import `ref`, `nextTick` and `useTemplateRef` from `vue` alongside `reactive` and `useId`.
2. Add the password field: a copy of the email field labelled "Password", with its own `useId()`, `type="password"`, `autocomplete="new-password"`, and `password` in place of `email` in the bindings.
3. Add `ref="email-input"` and `ref="password-input"` to the two inputs, so `useTemplateRef` can find them.
4. End the form with `<button type="submit">Create account</button>`.

## Build the same field with VeeValidate

VeeValidate's defaults already match the timing above for the first check: a field validates on `change` and `blur`, not on every `input` event. Here is the email field again, with VeeValidate running the rule and tracking the error:

*src/components/SignupFormVee.vue*

```
<script setup lang="ts">
import { useId } from 'vue'
import { ErrorMessage, Field, Form } from 'vee-validate'

const emailId = useId()

function validateEmail(value: unknown) {
  const email = typeof value === 'string' ? value.trim() : ''
  if (!email) return 'Enter your email address'
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || 'Enter an email address like name@example.com'
}

function onSubmit(values: Record<string, unknown>) {
  console.log('Send to your API:', values)
}

function focusFirstError() {
  document.getElementById(emailId)?.focus()
}
</script>

<template>
  <Form v-slot="{ errors }" @submit="onSubmit" @invalid-submit="focusFirstError">
    <label :for="emailId">Email address</label>
    <Field v-slot="{ field }" name="email" :rules="validateEmail" :validate-on-input="Boolean(errors.email)">
      <input
        v-bind="field" :id="emailId" type="email" autocomplete="email"
        :aria-invalid="errors.email ? 'true' : undefined"
        :aria-describedby="errors.email ? `${emailId}-error` : undefined"
      >
    </Field>
    <ErrorMessage v-slot="{ message }" name="email">
      <p :id="`${emailId}-error`"><strong>Error:</strong> {{ message }}</p>
    </ErrorMessage>
    <button type="submit">Create account</button>
  </Form>
</template>
```

What each VeeValidate piece does:

- `v-bind="field"` gives the input its `name`, its value, and VeeValidate's `blur`, `change` and `input` listeners.
- `:validate-on-input="Boolean(errors.email)"` reads `errors` from the `Form` slot and turns on input validation once the field has an error, like `onInput` in the plain version.
- `@invalid-submit` calls `focusFirstError` when a submit fails validation. With one field, it focuses that field by its `id`.
- `ErrorMessage` renders nothing while the field is valid, and `Form` adds `novalidate` to its `<form>` for you.

The slot on `ErrorMessage` is deliberate. Without one, it renders a `span` (or the element named in its `as` prop) with `role="alert"`. An alert is announced the moment it appears, so every message would interrupt whatever the screen reader was reading as the user tabs to the next field.

With a slot and no `as` prop, it renders only your slot content, without the role. That lets you put the `id` on the paragraph and keep announcements to the one summary after submit, which you add exactly as in the plain version.

## Test it with a screen reader and with axe

Automated tools catch missing labels and broken references; they cannot tell you whether the experience makes sense. Do both.

For the screen reader pass, start VoiceOver on macOS (Command+F5) or NVDA on Windows, which is free. Then:

1. Tab into the email field, leave it empty and tab out. The error appears silently.
2. Tab back. You should hear the label, "invalid", then the message.
3. Submit with errors. You should hear the summary and land on the first invalid field.

For the automated pass, run axe, the accessibility rules engine, inside a Playwright test with `@axe-core/playwright`. The test assumes a page at `/signup` that renders the form:

*tests/e2e/signup.spec.ts*

```
import { expect, test } from '@playwright/test'
import AxeBuilder from '@axe-core/playwright'

test('signup errors are announced and reachable', async ({ page }) => {
  await page.goto('/signup')
  await page.getByRole('button', { name: 'Create account' }).click()

  const email = page.getByRole('textbox', { name: 'Email address' })
  await expect(email).toBeFocused()
  await expect(email).toHaveAttribute('aria-invalid', 'true')
  await expect(email).toHaveAccessibleDescription(/Enter your email address/)

  const results = await new AxeBuilder({ page }).analyze()
  expect(results.violations).toEqual([])
})
```

The first three checks prove the behaviour this post built: focus moved, the field is marked invalid, and its description is the error. The axe scan runs after the failed submit, so it checks the page in its error state, which a scan of the freshly loaded form never sees.

**Check it stuck**3 questions from the Form state and validation by hand quiz.

[**Practice now**](https://vuejs.school/learn/forms/form-state-and-validation-by-hand?tab=quiz)

**Was this helpful?**

## **Keep reading**

[Tutorial · 6 min read**Multi-step forms with a Pinia store**](https://vuejs.school/community/blog/multi-step-forms-with-a-pinia-store) [Tutorial · 6 min read**Schema validation with Zod 4 and VeeValidate**](https://vuejs.school/community/blog/schema-validation-with-zod-4-and-veevalidate)

```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-form-errors#webpage","@type":"WebPage","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2026-09-29","description":"Build form errors that screen readers announce and everyone can fix. Link messages with aria-describedby, announce a summary, move focus, then test it.","name":"Accessible form errors: aria-describedby, live regions and focus","url":"https://vuejs.school/community/blog/accessible-form-errors","breadcrumb":{"@id":"https://vuejs.school/community/blog/accessible-form-errors#breadcrumb"},"isPartOf":{"@id":"https://vuejs.school/#website"},"potentialAction":[{"@type":"ReadAction","target":["https://vuejs.school/community/blog/accessible-form-errors"]}],"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-form-errors#article","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2026-09-29","description":"Build form errors that screen readers announce and everyone can fix. Link messages with aria-describedby, announce a summary, move focus, then test it.","headline":"Accessible form errors: aria-describedby, live regions and focus","inLanguage":"en","isAccessibleForFree":true,"thumbnailUrl":"https://vuejs.school/_og/d/a_~QWNjZXNzaWJsZSBmb3JtIGVycm9yczogYXJpYS1kZXNjcmliZWRieSwgbGl2ZSByZWdpb25zIGFuZCBmb2N1cyDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~QWNjZXNzaWJsZSBmb3JtIGVycm9yczogYXJpYS1kZXNjcmliZWRieSwgbGl2ZSByZWdpb25zIGFuZCBmb2N1cw%2Cdescription_~QnVpbGQgZm9ybSBlcnJvcnMgdGhhdCBzY3JlZW4gcmVhZGVycyBhbm5vdW5jZSBhbmQgZXZlcnlvbmUgY2FuIGZpeC4gTGluayBtZXNzYWdlcyB3aXRoIGFyaWEtZGVzY3JpYmVkYnksIGFubm91bmNlIGEgc3VtbWFyeSwgbW92ZSBmb2N1cywgdGhlbiB0ZXN0IGl0Lg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy9hY2Nlc3NpYmxlLWZvcm0tZXJyb3JzIg%2Cs_OIiTy2Zw1YkbVy7c.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-form-errors#webpage"},"mainEntityOfPage":{"@id":"https://vuejs.school/community/blog/accessible-form-errors#webpage"},"publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/accessible-form-errors#breadcrumb","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","name":"Blog","item":"https://vuejs.school/community/blog","position":1},{"@type":"ListItem","name":"Accessible form errors: aria-describedby, live regions and focus","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_~QWNjZXNzaWJsZSBmb3JtIGVycm9yczogYXJpYS1kZXNjcmliZWRieSwgbGl2ZSByZWdpb25zIGFuZCBmb2N1cyDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~QWNjZXNzaWJsZSBmb3JtIGVycm9yczogYXJpYS1kZXNjcmliZWRieSwgbGl2ZSByZWdpb25zIGFuZCBmb2N1cw%2Cdescription_~QnVpbGQgZm9ybSBlcnJvcnMgdGhhdCBzY3JlZW4gcmVhZGVycyBhbm5vdW5jZSBhbmQgZXZlcnlvbmUgY2FuIGZpeC4gTGluayBtZXNzYWdlcyB3aXRoIGFyaWEtZGVzY3JpYmVkYnksIGFubm91bmNlIGEgc3VtbWFyeSwgbW92ZSBmb2N1cywgdGhlbiB0ZXN0IGl0Lg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy9hY2Nlc3NpYmxlLWZvcm0tZXJyb3JzIg%2Cs_OIiTy2Zw1YkbVy7c.png","inLanguage":"en","url":"https://vuejs.school/_og/d/a_~QWNjZXNzaWJsZSBmb3JtIGVycm9yczogYXJpYS1kZXNjcmliZWRieSwgbGl2ZSByZWdpb25zIGFuZCBmb2N1cyDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~QWNjZXNzaWJsZSBmb3JtIGVycm9yczogYXJpYS1kZXNjcmliZWRieSwgbGl2ZSByZWdpb25zIGFuZCBmb2N1cw%2Cdescription_~QnVpbGQgZm9ybSBlcnJvcnMgdGhhdCBzY3JlZW4gcmVhZGVycyBhbm5vdW5jZSBhbmQgZXZlcnlvbmUgY2FuIGZpeC4gTGluayBtZXNzYWdlcyB3aXRoIGFyaWEtZGVzY3JpYmVkYnksIGFubm91bmNlIGEgc3VtbWFyeSwgbW92ZSBmb2N1cywgdGhlbiB0ZXN0IGl0Lg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy9hY2Nlc3NpYmxlLWZvcm0tZXJyb3JzIg%2Cs_OIiTy2Zw1YkbVy7c.png"}]}
```