Skip to content
TutorialForms

Accessible form errors: aria-describedby, live regions and focus

A red border tells some users that something is wrong. Build error messages that every user can find, hear and fix, first in plain Vue and then with VeeValidate's ErrorMessage.

vuejs.school team29 Sep 20266 min readVue 3.5 · VeeValidate 4.15

Most form errors are built for people who can see a red border. A screen reader user hears nothing when it appears, a colour-blind user may not notice it, and "Invalid input" tells nobody what to do.

The fixes fit in one sign-up form: a text message linked to its field with aria-describedby, wording that tells the user what to type, and, when a submit fails, an announcement and focus on the first invalid field. They are plain HTML and ARIA (the attributes that describe a page to assistive technology), so they work in any Vue app, Nuxt included. You build the form with Vue 3.5 and <script setup lang="ts">, then rebuild its email field with VeeValidate 4.15.

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) 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".
  • "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" 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.vueBreaks
<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 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.vueWorks
<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 stuck3 questions from the Form state and validation by hand quiz.
Practice now
Was this helpful?

Keep reading