| |

Vue.js 64 🟢 Form Accessibility (a11y) and Focus Management in Vue

⏱️ Estimated read: 16 min | |

Form accessibility is not a feature that can be added after the form works. It is a set of constraints that must shape the markup from the first line: every input needs a programmatically associated label, every error needs to be announced to a screen reader, every interactive element needs to be reachable by keyboard, and every modal or dropdown needs to trap focus so the user does not tab into the page behind it. Vue’s reactivity makes the dynamic parts—error messages appearing, focus moving, fields becoming invalid—easy to wire, but the semantics are still HTML’s responsibility. The framework provides the tools; the developer must apply them correctly.

The two areas that cause the most accessibility failures in Vue forms are error association and focus management. An error message that is visually displayed but not programmatically associated with the input is invisible to a screen reader . A modal that renders without a focus trap lets keyboard users tab into the background, losing their place and their context . Both are fixable with a few attributes and a small library, but the fixes must be deliberate.

Key point: Every input needs a <label for> or aria-label, every error needs aria-describedby pointing to its message and aria-invalid="true" on the field, every dynamic error container needs role="alert" so it is announced, and every modal or dropdown needs a focus trap (focus-trap-vue or @vueuse/integrations/useFocusTrap) to keep keyboard navigation contained.


Why error association is the most common a11y failure

The visual-only problem. An error message rendered below an input is visible to sighted users but meaningless to a screen reader unless the input’s aria-describedby attribute points to the message’s id. The W3C’s ACT rule for error identification states that the error must be programmatically associated with the field that caused it . A <span> with the error text and no association is a decoration, not an error message.

The role="alert" problem. When an error appears dynamically after form submission or blur, the screen reader does not automatically announce it unless the container has a live region role. role="alert" creates an assertive live region that interrupts the current announcement and reads the error immediately . Without it, the error is silent until the user navigates back to the field.

The aria-invalid problem. The aria-invalid attribute tells assistive technology that the field currently contains invalid data. The W3C’s ARIA21 technique states that it should be set to "true" only after validation has failed, and set to "false" (or removed) when the field is valid . Setting it before validation misleads the user; leaving it after the field becomes valid misleads them in the other direction.

The aria-describedby problem. The attribute accepts a space-separated list of IDs. When an error is present, the input’s aria-describedby should point to the error message’s ID. When the error is cleared, the attribute should be removed or updated. A static aria-describedby that always points to an empty container is a common mistake—the screen reader announces nothing, but the association is still there, cluttering the accessibility tree.


a. Labels, errors, and the ARIA attributes

The foundation is the label. Every input needs a <label> element with a for attribute that matches the input’s id. The label is what the screen reader announces when the field receives focus. A placeholder is not a label—it disappears when the user types, and it is not reliably announced by all screen readers.

<template>
  <!-- The label is associated with the input via for/id -->
  <label for="email">Email</label>
  <!-- aria-describedby points to the error message when present -->
  <!-- aria-invalid is true only after validation fails -->
  <input
    id="email"
    v-model="email"
    type="email"
    :aria-describedby="emailError ? 'email-error' : undefined"
    :aria-invalid="!!emailError"
  />
  <!-- role="alert" makes the error announced when it appears -->
  <span v-if="emailError" id="email-error" role="alert">
    {{ emailError }}
  </span>
</template>

The :aria-describedby="emailError ? 'email-error' : undefined" binding is the mechanism. When there is no error, the attribute is undefined, and Vue does not render it. When there is an error, the attribute is the ID of the message, and the screen reader reads the message when the field is focused . The :aria-invalid="!!emailError" converts the error string to a boolean: true when the error exists, false when it does not.

The role="alert" on the error container is what makes the error announced at the moment it appears. The live region is created when the element is inserted into the DOM, and the text content triggers the announcement. For errors that are already present on page load (server-side validation), the role="alert" is still appropriate because the user needs to know the field is invalid before they interact with it .

The form itself can have an aria-label or be wrapped in a <form> element with a heading. The aria-label provides an accessible name for the form region, which helps screen reader users understand what the form is for when they navigate by landmarks .


b. Focus management for modals and dropdowns

A modal dialog that does not trap focus is an accessibility failure. The user opens the modal, tabs through its fields, and then tabs into the page behind it—still technically focused, but visually hidden by the overlay. The solution is a focus trap: a component or composable that intercepts Tab and Shift+Tab and keeps the focus cycling within the modal’s DOM subtree.

The focus-trap-vue library provides a FocusTrap component that wraps its child and activates when a boolean prop becomes true. The recommended usage is v-model:active:

<template>
  <!-- The FocusTrap wraps the modal content -->
  <!-- v-model:active controls when the trap is engaged -->
  <FocusTrap v-model:active="isOpen">
    <!-- The modal dialog has tabindex="-1" so it can receive focus -->
    <div class="modal" tabindex="-1">
      <h2>Confirm</h2>
      <button @click="confirm">Yes</button>
      <button @click="isOpen = false">No</button>
    </div>
  </FocusTrap>
</template>

When isOpen becomes true, the trap activates, focus moves to the first tabbable element (or the element specified by initialFocus), and Tab cycles within the modal. When the trap deactivates, focus returns to the element that triggered the modal . The returnFocusOnDeactivate prop (default true) controls the return behavior.

The @vueuse/integrations/useFocusTrap composable provides the same functionality without a wrapper component. It returns activate, deactivate, and hasFocus functions that can be called from a watch or an event handler:

<script setup>
import { useFocusTrap } from '@vueuse/integrations/useFocusTrap'
import { useTemplateRef } from 'vue'

const modalRef = useTemplateRef('modal')
const { activate, deactivate } = useFocusTrap(modalRef)

function openModal() {
  // The modal must be rendered before the trap activates
  // Use nextTick if the modal is conditionally rendered
  activate()
}
</script>

<template>
  <!-- The ref points to the element that contains the focusable content -->
  <div v-if="isOpen" ref="modal" tabindex="-1">
    <button @click="deactivate">Close</button>
  </div>
</template>

The conditional rendering caveat is important. If the modal is rendered with v-if, the element does not exist in the DOM when activate() is called. The nextTick function must be awaited before activation, or the trap will fail to find the container .

For dropdowns and popovers, the focus trap may not be necessary if the dropdown is part of the normal tab order and closes when focus leaves. The pattern depends on the widget. A modal dialog must trap focus; a combobox or a menu may use aria-activedescendant instead, which keeps focus on the input and moves the “virtual” focus through the options. The key is that the keyboard user can always reach every interactive element and cannot accidentally leave the widget without a deliberate action.


c. The complete accessible form pattern

The accessible form combines labels, error association, live regions, and focus management into a single pattern. The pattern is the same for every field: a label, an input with aria-describedby and aria-invalid, and an error container with role="alert". The form-level submission handler moves focus to the first invalid field when validation fails.

<script setup>
import { ref } from 'vue'

const email = ref('')
const password = ref('')
const emailError = ref('')
const passwordError = ref('')

function validate() {
  // Reset the errors before validating
  emailError.value = ''
  passwordError.value = ''

  let isValid = true

  if (!email.value) {
    emailError.value = 'Email is required'
    isValid = false
  } else if (!email.value.includes('@')) {
    emailError.value = 'Must be a valid email'
    isValid = false
  }

  if (!password.value || password.value.length < 8) {
    passwordError.value = 'Password must be at least 8 characters'
    isValid = false
  }

  return isValid
}

function onSubmit() {
  if (!validate()) {
    // Move focus to the first invalid field after validation
    // The nextTick ensures the error is rendered before focusing
    if (emailError.value) {
      document.getElementById('email')?.focus()
    } else if (passwordError.value) {
      document.getElementById('password')?.focus()
    }
    return
  }
  // Submit the form
}
</script>

<template>
  <form @submit.prevent="onSubmit">
    <div>
      <label for="email">Email</label>
      <input
        id="email"
        v-model="email"
        type="email"
        :aria-describedby="emailError ? 'email-error' : undefined"
        :aria-invalid="!!emailError"
      />
      <span v-if="emailError" id="email-error" role="alert">
        {{ emailError }}
      </span>
    </div>

    <div>
      <label for="password">Password</label>
      <input
        id="password"
        v-model="password"
        type="password"
        :aria-describedby="passwordError ? 'password-error' : undefined"
        :aria-invalid="!!passwordError"
      />
      <span v-if="passwordError" id="password-error" role="alert">
        {{ passwordError }}
      </span>
    </div>

    <button type="submit">Submit</button>
  </form>
</template>

The focus movement on validation failure is the final piece. When the form is submitted and validation fails, the user needs to know which field to fix. Moving focus to the first invalid field accomplishes two things: it announces the field’s label and its error message (because aria-describedby points to the error), and it places the cursor where the user needs to type. The role="alert" on the error container ensures the message is announced even if the focus movement is missed .

The sequencing of announcements—whether the error list is announced first or the focused field’s error is announced first—is a known complexity. The Stack Overflow discussion on the topic shows that different screen readers handle the order differently, and the robust pattern is to rely on the field-level aria-describedby and the role="alert" rather than a separate form-level announcement . The field-level association is what the screen reader uses when focus arrives, and the role="alert" is what announces the error when it appears.


Complete Example Session

The following session builds a login form with accessible labels, error association, live region announcements, and a focus trap for a modal that appears after a failed login attempt.

<!-- ============================================
     PART 1: THE FORM WITH ACCESSIBLE ERRORS
     ============================================ -->
<script setup>
import { ref } from 'vue'

const email = ref('')
const password = ref('')
const emailError = ref('')
const passwordError = ref('')

function validate() {
  emailError.value = ''
  passwordError.value = ''
  let valid = true

  if (!email.value) {
    emailError.value = 'Email is required'
    valid = false
  } else if (!email.value.includes('@')) {
    emailError.value = 'Must be a valid email address'
    valid = false
  }

  if (!password.value || password.value.length < 8) {
    passwordError.value = 'Password must be at least 8 characters'
    valid = false
  }

  return valid
}

function onSubmit() {
  if (!validate()) {
    // Focus the first invalid field after the errors render
    if (emailError.value) {
      document.getElementById('email')?.focus()
    } else if (passwordError.value) {
      document.getElementById('password')?.focus()
    }
    return
  }
  // Login logic here
}
</script>

<template>
  <form @submit.prevent="onSubmit" aria-label="Sign in to your account">
    <div>
      <!-- The label is associated with the input -->
      <label for="email">Email</label>
      <input
        id="email"
        v-model="email"
        type="email"
        autocomplete="email"
        :aria-describedby="emailError ? 'email-error' : undefined"
        :aria-invalid="!!emailError"
      />
      <!-- role="alert" announces the error when it appears -->
      <span v-if="emailError" id="email-error" role="alert">
        {{ emailError }}
      </span>
    </div>

    <div>
      <label for="password">Password</label>
      <input
        id="password"
        v-model="password"
        type="password"
        autocomplete="current-password"
        :aria-describedby="passwordError ? 'password-error' : undefined"
        :aria-invalid="!!passwordError"
      />
      <span v-if="passwordError" id="password-error" role="alert">
        {{ passwordError }}
      </span>
    </div>

    <button type="submit">Sign in</button>
  </form>
</template>
<!-- ============================================
     PART 2: THE MODAL WITH A FOCUS TRAP
     ============================================ -->
<script setup>
import { FocusTrap } from 'focus-trap-vue'
import { ref } from 'vue'

const isOpen = ref(false)
</script>

<template>
  <!-- The trap activates when isOpen becomes true -->
  <FocusTrap v-model:active="isOpen">
    <div class="modal" tabindex="-1" role="dialog" aria-modal="true">
      <h2>Session expired</h2>
      <p>Please sign in again.</p>
      <button @click="isOpen = false">OK</button>
    </div>
  </FocusTrap>
</template>

The two parts cover the form with accessible labels and error association, and the modal with a focus trap. What the session demonstrates is that the accessibility attributes are not an afterthought—they are part of the template from the first line, and the focus management is a single component that wraps the modal.


Quick Reference

Error Association

AttributePurpose
for on <label>Associates label with input
aria-describedbyPoints to error message ID
aria-invalidMarks the field as invalid
role="alert"Announces dynamic errors

Focus Management

ToolPurpose
focus-trap-vueComponent wrapper for focus trap
useFocusTrapComposable from VueUse
initialFocusElement to focus on activation
returnFocusOnDeactivateReturn focus to trigger

Key Rules

RuleWhy
Label every inputScreen reader announces it
Associate errorsaria-describedby
Announce dynamic errorsrole="alert"
Trap modal focusKeyboard users stay in modal
Move focus to first errorUser finds the field

Best Practices

✅ Do This:

<!-- Label with for/id -->
<label for="email">Email</label>
<input id="email" v-model="email" />

<!-- Associate the error -->
<input :aria-describedby="error ? 'email-error' : undefined" />
<span v-if="error" id="email-error" role="alert">{{ error }}</span>

<!-- Trap focus in modals -->
<FocusTrap v-model:active="isOpen"><div tabindex="-1">...</div></FocusTrap>

❌ Don’t Do This:

<!-- Placeholder as label -->
<input placeholder="Email" /> <!-- no accessible name -->

<!-- Error without association -->
<span>{{ error }}</span> <!-- screen reader does not connect it -->

<!-- Modal without focus trap -->
<div v-if="isOpen" class="modal">...</div> <!-- focus escapes -->

Common Pitfalls

PitfallWhy It HappensFix
Screen reader ignores errorNo aria-describedbyAdd the attribute
Error not announcedNo role="alert"Add the role
aria-invalid always trueNot cleared on validSet to !!error
Focus escapes modalNo focus trapUse focus-trap-vue
Focus not moved to errorNo programmatic focuselement.focus()

Real-World Examples

1. Label association

<label for="name">Name</label>
<input id="name" v-model="name" />

2. Error association

<input :aria-describedby="err ? 'name-err' : undefined" />
<span v-if="err" id="name-err" role="alert">{{ err }}</span>

3. Invalid flag

<input :aria-invalid="!!err" />

4. Focus trap

<FocusTrap v-model:active="open"><div tabindex="-1">...</div></FocusTrap>

5. Focus first error

document.getElementById('email')?.focus()

6. Autocomplete

<input autocomplete="email" />

7. Fieldset for radio groups

<fieldset><legend>Plan</legend><input type="radio" id="a" /><label for="a">A</label></fieldset>

8. Initial focus in trap

<FocusTrap v-model:active="open" :initial-focus="() => $refs.input"><input ref="input" /></FocusTrap>

9. Return focus on close

<FocusTrap v-model:active="open" :return-focus-on-deactivate="true">

10. Live region for form summary

<div role="alert" aria-live="assertive">{{ formSummary }}</div>

Visual

Error Association

┌──────────────────────────────────────────────┐
│  <label for="email">Email</label>            │
│  <input id="email" aria-describedby="err" /> │
│  <span id="err" role="alert">Required</span> │
│         │                                    │
│         ▼                                    │
│  Screen reader:                              │
│  "Email, edit, required"                     │
└──────────────────────────────────────────────┘

Focus Trap

┌──────────────────────────────────────────────┐
│  MODAL (focus trapped)                       │
│  ┌────────────────────────────────────────┐  │
│  │  Tab cycles within the modal           │  │
│  │  Shift+Tab cycles backward             │  │
│  │  Focus cannot leave the modal          │  │
│  └────────────────────────────────────────┘  │
│         │                                    │
│         ▼                                    │
│  On close: focus returns to trigger          │
└──────────────────────────────────────────────┘

Validation Flow

┌──────────────────────────────────────────────┐
│  Submit → validate → invalid?                │
│         │                                    │
│    ┌────┴────┐                               │
│    │         │                               │
│  valid    invalid                            │
│    │         │                               │
│    ▼         ▼                               │
│  submit   set errors                         │
│           role="alert" announces             │
│           focus first invalid field          │
└──────────────────────────────────────────────┘

Summary

ItemValue
Label<label for> or aria-label
Error associationaria-describedby
Invalid markeraria-invalid
Announcementrole="alert"
Focus trapfocus-trap-vue or useFocusTrap
Focus first errorelement.focus() after validation
Modal semanticsrole="dialog" + aria-modal="true"

Key takeaways:

  • Every input needs an accessible name. A <label for> is the standard; aria-label is the fallback. A placeholder is not a label .
  • Every error needs to be programmatically associated. aria-describedby points the input to the error message’s ID. Without it, the screen reader does not connect the two .
  • Dynamic errors need a live region. role="alert" makes the error announced when it appears, without the user having to navigate back to the field .
  • aria-invalid is a state, not a decoration. Set it to "true" only after validation fails, and reset it when the field becomes valid .
  • Modals must trap focus. focus-trap-vue or useFocusTrap keeps keyboard navigation within the modal and returns focus to the trigger on close .
  • Move focus to the first invalid field on submission failure. This places the cursor where the user needs to type and triggers the field’s label and error announcement .

Remember: Form accessibility is not a checklist applied after the form works. It is the semantics of the markup. Every input needs a label. Every error needs an association. Every dynamic announcement needs a live region. Every modal needs a focus trap. Vue’s reactivity makes the dynamic parts easy—the error string changes, the aria-describedby updates, the role="alert" announces—but the attributes must be present in the template from the start. The pattern is not complex, but it is easy to skip, and the users who depend on it are the ones who cannot work around its absence.


Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!