This article is currently available in Spanish only. English translation coming soon!

Accessibility A11Y Frontend Best Practices

Accesibilidad web (A11Y): Guía práctica para desarrolladores

Cómo hacer tus aplicaciones web accesibles para todos. Técnicas, herramientas y mejores prácticas de accesibilidad.

Accesibilidad web (A11Y): Guía práctica para desarrolladores

La accesibilidad no es opcional. Aquí te muestro cómo hacerlo bien.

¿Por qué accesibilidad?

  • 15% de la población mundial tiene alguna discapacidad
  • Legal: WCAG 2.1 es requerimiento legal en muchos países
  • SEO: Mejor accesibilidad = mejor SEO
  • UX: Beneficia a todos los usuarios

Semantic HTML

❌ Malo

<div class="button" onclick="submit()">Enviar</div>
<span class="heading">Título</span>
<div class="input-wrapper">
  <div contenteditable="true"></div>
</div>

✅ Bueno

<button type="submit">Enviar</button>
<h1>Título</h1>
<input type="text" />

ARIA Attributes

Roles

<nav role="navigation">
  <ul role="list">
    <li role="listitem"><a href="/">Home</a></li>
  </ul>
</nav>

<div role="alert">Mensaje importante</div>
<div role="status">Loading...</div>
<div role="dialog" aria-modal="true">Modal</div>

States & Properties

<button 
  aria-expanded="false"
  aria-controls="menu"
  aria-label="Abrir menú"
>
  Menu
</button>

<input 
  type="text"
  aria-required="true"
  aria-invalid="false"
  aria-describedby="error-message"
/>

<div id="error-message" role="alert">
  Email inválido
</div>

Live Regions

<div 
  aria-live="polite"
  aria-atomic="true"
>
  3 nuevas notificaciones
</div>

<!-- assertive para urgente -->
<div aria-live="assertive">
  ¡Error crítico!
</div>

Keyboard Navigation

Tabindex

<!-- Orden natural -->
<button>Primero</button>
<input type="text">
<button>Tercero</button>

<!-- Orden personalizado (evitar si es posible) -->
<div tabindex="1">Primero</div>
<div tabindex="3">Tercero</div>
<div tabindex="2">Segundo</div>

<!-- Programáticamente focuseable -->
<div tabindex="-1">No en tab order</div>

<!-- Custom focuseable -->
<div tabindex="0" role="button">
  Click me
</div>

Focus Management

// Vue composable
export function useFocusTrap(containerRef) {
  const focusableElements = computed(() => {
    return containerRef.value?.querySelectorAll(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    )
  })
  
  const firstElement = computed(() => focusableElements.value?.[0])
  const lastElement = computed(() => 
    focusableElements.value?.[focusableElements.value.length - 1]
  )
  
  function handleKeydown(e) {
    if (e.key !== 'Tab') return
    
    if (e.shiftKey) {
      if (document.activeElement === firstElement.value) {
        lastElement.value?.focus()
        e.preventDefault()
      }
    } else {
      if (document.activeElement === lastElement.value) {
        firstElement.value?.focus()
        e.preventDefault()
      }
    }
  }
  
  onMounted(() => {
    document.addEventListener('keydown', handleKeydown)
    firstElement.value?.focus()
  })
  
  onUnmounted(() => {
    document.removeEventListener('keydown', handleKeydown)
  })
}

Forms Accesibles

<form>
  <div class="form-group">
    <label for="email">
      Email *
    </label>
    <input
      id="email"
      type="email"
      required
      aria-required="true"
      aria-invalid="false"
      aria-describedby="email-error email-help"
    />
    <span id="email-help" class="help-text">
      Usaremos tu email solo para contactarte
    </span>
    <span id="email-error" role="alert" aria-live="assertive">
      <!-- Error message aparece aquí -->
    </span>
  </div>
  
  <fieldset>
    <legend>Preferencias</legend>
    <input type="checkbox" id="news" />
    <label for="news">Newsletter</label>
    
    <input type="checkbox" id="updates" />
    <label for="updates">Actualizaciones</label>
  </fieldset>
  
  <button type="submit">
    Enviar formulario
  </button>
</form>

Images & Media

Alt text

<!-- ❌ Malo -->
<img src="photo.jpg" alt="imagen">
<img src="photo.jpg" alt="">

<!-- ✅ Bueno -->
<img src="hero.jpg" alt="Desarrollador escribiendo código en laptop">

<!-- Decorativa -->
<img src="decoration.jpg" alt="" role="presentation">

<!-- Compleja -->
<img 
  src="chart.png" 
  alt="Gráfico de barras mostrando crecimiento de ventas"
  aria-describedby="chart-description"
/>
<div id="chart-description">
  Las ventas crecieron de 100k en enero a 250k en diciembre,
  con un crecimiento constante del 15% mensual.
</div>

Video

<video controls>
  <source src="video.mp4" type="video/mp4">
  <track
    kind="captions"
    src="captions.vtt"
    srclang="es"
    label="Español"
    default
  />
  <track
    kind="descriptions"
    src="descriptions.vtt"
    srclang="es"
    label="Audio descriptions"
  />
</video>

Color & Contrast

Ratios mínimos (WCAG AA)

  • Texto normal: 4.5:1
  • Texto grande (18pt+): 3:1
  • UI components: 3:1

Herramientas

  • Chrome DevTools (Lighthouse)
  • WebAIM Contrast Checker
  • Stark (Figma plugin)

No solo color

<!-- ❌ Solo color -->
<span style="color: red;">Error</span>

<!-- ✅ Color + icono + texto -->
<span class="error">
  <svg aria-hidden="true"><!-- icono --></svg>
  <span>Error: Campo requerido</span>
</span>
<!-- Button: Ejecuta acción -->
<button onclick="deleteUser()">
  Eliminar usuario
</button>

<!-- Link: Navega -->
<a href="/users">
  Ver todos los usuarios
</a>

<!-- ❌ Nunca -->
<div onclick="submit()">Enviar</div>
<a href="#" onclick="doSomething()">Hacer algo</a>
<a href="#main-content" class="skip-link">
  Saltar al contenido principal
</a>

<nav><!-- Navegación --></nav>

<main id="main-content">
  <!-- Contenido -->
</main>
.skip-link {
  position: absolute;
  top: -40px;
  left: 0;
  background: #000;
  color: #fff;
  padding: 8px;
  text-decoration: none;
  z-index: 100;
}

.skip-link:focus {
  top: 0;
}
<template>
  <div
    v-if="isOpen"
    role="dialog"
    aria-modal="true"
    aria-labelledby="modal-title"
    aria-describedby="modal-description"
    ref="modalRef"
  >
    <h2 id="modal-title">Título del modal</h2>
    <p id="modal-description">Descripción</p>
    
    <button @click="close" aria-label="Cerrar modal">
      ×
    </button>
    
    <div><!-- Contenido --></div>
    
    <div class="actions">
      <button @click="confirm">Confirmar</button>
      <button @click="close">Cancelar</button>
    </div>
  </div>
</template>

<script setup>
const modalRef = ref(null)
const { isOpen, close } = defineProps()

// Focus trap
useFocusTrap(modalRef)

// Escape key
onMounted(() => {
  const handleEscape = (e) => {
    if (e.key === 'Escape') close()
  }
  document.addEventListener('keydown', handleEscape)
  onUnmounted(() => {
    document.removeEventListener('keydown', handleEscape)
  })
})

// Inert resto de la página
watch(isOpen, (value) => {
  document.body.style.overflow = value ? 'hidden' : ''
  const main = document.querySelector('main')
  if (value) {
    main?.setAttribute('inert', '')
  } else {
    main?.removeAttribute('inert')
  }
})
</script>

Testing

Automated

npm install -D @axe-core/cli
npx axe http://localhost:3000
// En tests
import { axe } from 'jest-axe'

it('no tiene violaciones de a11y', async () => {
  const { container } = render(<MyComponent />)
  const results = await axe(container)
  expect(results).toHaveNoViolations()
})

Manual

  1. Keyboard only: Navega sin mouse
  2. Screen reader: VoiceOver (Mac), NVDA (Windows)
  3. Zoom: 200% de zoom
  4. Color blindness: Simuladores

Herramientas

  • axe DevTools - Chrome extension
  • WAVE - Evaluación visual
  • Lighthouse - Score de accesibilidad
  • Screen readers:
    • VoiceOver (macOS/iOS)
    • NVDA (Windows)
    • JAWS (Windows)

Checklist

  • Semantic HTML
  • Keyboard navegable
  • Focus visible
  • ARIA labels donde necesario
  • Contraste adecuado
  • Alt text en imágenes
  • Forms con labels
  • Error messages descriptivos
  • Skip links
  • Responsive text (no fixed font size)
  • No depender solo de color
  • Videos con captions

Recursos

Conclusión

La accesibilidad mejora la experiencia para todos. No es difícil, solo requiere consideración desde el diseño hasta la implementación.

Write me on WhatsApp