Skip to content
← Todas las preguntas
⚡ Intermedio

¿Cómo se gestiona la accesibilidad en Vue?

♿ Accesibilidad🧩 Componentes🌀 Teleport

Empieza con elementos HTML semánticos en lugar de divs. Añade atributos ARIA solo cuando la semántica nativa no sea suficiente. Gestiona el foco explícitamente en los cambios de ruta y en el contenido dinámico. Vue no añade barreras a la accesibilidad, pero tampoco añade restricciones. La responsabilidad es tuya.

Primero, HTML semántico ​

La decisión de accesibilidad con mayor impacto no tiene nada que ver con Vue:

vue
<!-- MAL: sopa de divs con parches ARIA -->
<template>
  <div role="navigation">
    <div role="list">
      <div role="listitem" @click="navigate">Home</div>
    </div>
  </div>
</template>

<!-- BIEN: elementos nativos que funcionan sin configuración extra -->
<template>
  <nav aria-label="Main navigation">
    <ul>
      <li><RouterLink to="/">Home</RouterLink></li>
    </ul>
  </nav>
</template>
Open in Vue Playground

Los elementos nativos ofrecen soporte de teclado, anuncios de lectores de pantalla y comportamiento de foco de forma gratuita. ARIA no puede añadir funcionalidad, solo puede describir lo que ya existe.

Atributos ARIA en templates Vue ​

Enlaza atributos ARIA dinámicamente cuando el estado controla la interfaz:

vue
<script setup>
const isExpanded = ref(false)
const panelId = useId()
</script>

<template>
  <button
    :aria-expanded="isExpanded"
    :aria-controls="panelId"
    @click="isExpanded = !isExpanded"
  >
    Details
  </button>
  <div v-show="isExpanded" :id="panelId" role="region">Panel content</div>
</template>
Open in Vue Playground

useId() (Vue 3.5+) genera un ID único para cada instancia del componente, evitando IDs duplicados cuando el componente se reutiliza.

Gestión del foco ​

Las SPA rompen el comportamiento por defecto del navegador, donde la navegación entre páginas mueve el foco al inicio de la nueva página. Hay que gestionarlo manualmente:

ts
// router/index.ts
router.afterEach((to, from) => {
  if (to.path !== from.path) {
    nextTick(() => {
      const heading = document.querySelector('h1')
      if (heading instanceof HTMLElement) {
        heading.setAttribute('tabindex', '-1')
        heading.focus()
      }
    })
  }
})

Para modales y diálogos, atrapa el foco dentro del elemento y devuélvelo cuando el modal se cierre:

vue
<script setup>
const triggerRef = ref<HTMLElement>()
const dialogRef = ref<HTMLElement>()

function openModal() {
  isOpen.value = true
  nextTick(() => dialogRef.value?.focus())
}

function closeModal() {
  isOpen.value = false
  triggerRef.value?.focus()
}
</script>

<template>
  <button ref="triggerRef" @click="openModal">Open</button>

  <dialog
    v-if="isOpen"
    ref="dialogRef"
    tabindex="-1"
    @keydown.escape="closeModal"
  >
    <h2>Dialog title</h2>
    <p>Content here</p>
    <button @click="closeModal">Close</button>
  </dialog>
</template>
Open in Vue Playground

Usar el elemento nativo <dialog> gestiona el atrapamiento de foco automáticamente cuando se abre con showModal().

Regiones en vivo para contenido dinámico ​

Cuando el contenido se actualiza sin recargar la página, los lectores de pantalla no lo anuncian a menos que uses una región en vivo:

vue
<script setup>
const notification = ref('')

async function save() {
  await submitForm()
  notification.value = 'Changes saved successfully'
}
</script>

<template>
  <form @submit.prevent="save">
    <!-- campos del formulario -->
    <button type="submit">Save</button>
  </form>

  <div aria-live="polite" role="status" class="sr-only">
    {{ notification }}
  </div>
</template>
Open in Vue Playground

aria-live="polite" espera a que el lector de pantalla termine su anuncio actual. Usa aria-live="assertive" solo para mensajes urgentes como errores.

Visualmente oculto pero accesible ​

Contenido que debe estar disponible para lectores de pantalla pero no visible en pantalla:

css
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border-width: 0;
}
vue
<template>
  <button @click="removeItem(item)">
    <TrashIcon />
    <span class="sr-only">Remove {{ item.name }}</span>
  </button>
</template>
Open in Vue Playground

Sin el texto visualmente oculto, un lector de pantalla solo anunciaría "botón" sin indicar qué hace.

Lista de comprobación ​

ÁreaQué hacer
HTML semánticoUsa nav, main, button, ul, dialog en lugar de divs
ARIASolo añade cuando la semántica nativa no sea suficiente. Enlaza dinámicamente con :aria-*
FocoGestiona en cambios de ruta, modales y contenido dinámico
Regiones en vivoAnuncia cambios de contenido dinámico con aria-live
TecladoAsegura que todos los elementos interactivos son alcanzables y operables con el teclado
Contraste de colorMínimo 4.5:1 para texto, 3:1 para texto grande (WCAG AA)
EtiquetasCada campo de formulario necesita un <label> visible o aria-label

Ver también: ¿Cómo manejar formularios complejos? · ¿Cómo funciona Teleport? · ¿Cómo funcionan las template refs?

Referencias ​

Publicado bajo la licencia MIT.