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>
Buttons vs Links
<!-- 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>
Skip Links
<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;
}
Modal Accesible
<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
- Keyboard only: Navega sin mouse
- Screen reader: VoiceOver (Mac), NVDA (Windows)
- Zoom: 200% de zoom
- 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.