Accesibilidad Web: WCAG 2.2
Dominá accesibilidad web con WCAG 2.2: roles ARIA, navegación por teclado, soporte para screen readers, contraste de color y formularios accesibles.
Introducción
Accesibilidad web significa construir websites que people con disabilities pueden usar. WCAG 2.2 es el international standard con cuatro principles: Perceivable, Operable, Understandable y Robust. Accesibilidad no es optional — es required por law en muchos countries y mejora usability para todos. A continuación: semantic HTML, ARIA roles, keyboard navigation, screen reader support, color contrast, focus management y accessible forms con practical code examples.
WCAG 2.2 Principles
PERCEIVABLE: Users deben poder perceive content
- Text alternatives para images
- Captions para video
- Sufficient color contrast (4.5:1 para normal text, 3:1 para large text)
- Content es resizable hasta 200% sin loss
OPERABLE: Users deben poder interact
- All functionality available via keyboard
- No keyboard traps
- Skip navigation links
- Enough time para read content (o pause/extend timers)
UNDERSTANDABLE: Content y interface deben ser understandable
- Readable language (lang attribute en <html>)
- Predictable navigation y behavior
- Input error prevention y clear error messages
ROBUST: Content funciona con assistive technologies
- Valid HTML
- Compatible con screen readers y other AT
- ARIA usado correctamente (solo cuando HTML no es enough)
Semantic HTML First
<!-- Bad: div soup — screen readers no ven nada -->
<div class="header">
<div class="nav">
<div class="link" onclick="navigate('/')">Home</div>
</div>
</div>
<!-- Good: semantic elements — screen readers entienden structure -->
<header>
<nav aria-label="Main navigation">
<a href="/">Home</a>
</nav>
</header>
<!-- Semantic elements proveen built-in accessibility -->
<main>
<article>
<header>
<h1>Article Title</h1>
<time datetime="2026-07-05">July 5, 2026</time>
</header>
<section>
<h2>Section Heading</h2>
<p>Content...</p>
</section>
<aside>
<h2>Related</h2>
<ul>
<li><a href="/related-1">Related Article 1</a></li>
</ul>
</aside>
</article>
</main>
ARIA Roles
Cuándo usar ARIA
<!-- Rule 1: Si HTML provee un semantic element, usalo en vez de ARIA -->
<!-- Bad -->
<div role="button" tabindex="0" onclick="submit()">Submit</div>
<!-- Good -->
<button onclick="submit()">Submit</button>
<!-- Rule 2: ARIA es para cuando HTML no es enough -->
<!-- Custom widget: tab interface -->
<div role="tablist">
<button role="tab" id="tab-1" aria-selected="true" aria-controls="panel-1">Tab 1</button>
<button role="tab" id="tab-2" aria-selected="false" aria-controls="panel-2" tabindex="-1">Tab 2</button>
</div>
<div role="tabpanel" id="panel-1" aria-labelledby="tab-1">
Content for tab 1
</div>
<div role="tabpanel" id="panel-2" aria-labelledby="tab-2" hidden>
Content for tab 2
</div>
ARIA attributes comunes
<!-- aria-label: cuando no hay visible text -->
<button aria-label="Close dialog" onclick="closeDialog()">
<svg aria-hidden="true"><use href="#icon-close" /></svg>
</button>
<!-- aria-labelledby: cuando visible text labeléa el element -->
<input type="search" aria-labelledby="search-label" />
<span id="search-label">Search products</span>
<!-- aria-describedby: additional description -->
<input type="password" aria-describedby="password-hint" />
<p id="password-hint">Must be at least 12 characters with a number and symbol.</p>
<!-- aria-live: announceéa dynamic content changes -->
<div aria-live="polite" id="status"></div>
<script>
document.getElementById('status').textContent = 'Form saved successfully';
</script>
<!-- aria-expanded: toggle state -->
<button aria-expanded="false" aria-controls="menu" onclick="toggleMenu()">
Menu
</button>
<ul id="menu" hidden>
<li><a href="/settings">Settings</a></li>
<li><a href="/logout">Logout</a></li>
</ul>
<!-- aria-hidden: hide de screen readers (decorative content) -->
<svg aria-hidden="true" class="icon">
<path d="..." />
</svg>
Navegación por Teclado
Focus management
/* Always visible focus indicator */
*:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
/* No removás focus outlines sin replacement */
/* Bad: *:focus { outline: none; } */
/* Skip link para keyboard users */
.skip-link {
position: absolute;
top: -40px;
left: 0;
background: #2563eb;
color: white;
padding: 8px 16px;
z-index: 100;
transition: top 0.2s;
}
.skip-link:focus {
top: 0;
}
<!-- Skip to main content link -->
<a href="#main" class="skip-link">Skip to main content</a>
<nav>...</nav>
<main id="main">
<h1>Page content</h1>
</main>
Roving tabindex para custom widgets
// Tab interface: solo un tab está en el tab order a la vez
class TabList {
private tabs: HTMLElement[] = [];
private activeIndex = 0;
constructor(container: HTMLElement) {
this.tabs = Array.from(container.querySelectorAll('[role="tab"]'));
this.tabs.forEach((tab, index) => {
tab.addEventListener("keydown", this.handleKeyDown.bind(this));
tab.addEventListener("click", () => this.activate(index));
});
this.updateTabindex();
}
private updateTabindex() {
this.tabs.forEach((tab, index) => {
tab.tabIndex = index === this.activeIndex ? 0 : -1;
tab.setAttribute("aria-selected", String(index === this.activeIndex));
});
}
private activate(index: number) {
this.activeIndex = index;
this.updateTabindex();
this.tabs[index].focus();
// Show corresponding panel...
}
private handleKeyDown(e: KeyboardEvent) {
switch (e.key) {
case "ArrowRight":
e.preventDefault();
this.activate((this.activeIndex + 1) % this.tabs.length);
break;
case "ArrowLeft":
e.preventDefault();
this.activate((this.activeIndex - 1 + this.tabs.length) % this.tabs.length);
break;
case "Home":
e.preventDefault();
this.activate(0);
break;
case "End":
e.preventDefault();
this.activate(this.tabs.length - 1);
break;
}
}
}
Formularios Accesibles
<form>
<!-- Cada input tiene un label -->
<div>
<label for="name">Full Name</label>
<input type="text" id="name" name="name" required autocomplete="name" />
</div>
<!-- Required fields están marked -->
<div>
<label for="email">
Email <span aria-hidden="true" class="required">*</span>
<span class="sr-only">(required)</span>
</label>
<input
type="email"
id="email"
name="email"
required
autocomplete="email"
aria-describedby="email-error"
aria-invalid="false"
/>
<p id="email-error" class="error" role="alert"></p>
</div>
<!-- Select con grouped options -->
<div>
<label for="country">Country</label>
<select id="country" name="country">
<optgroup label="North America">
<option value="us">United States</option>
<option value="ca">Canada</option>
</optgroup>
<optgroup label="Europe">
<option value="uk">United Kingdom</option>
<option value="de">Germany</option>
</optgroup>
</select>
</div>
<!-- Checkbox group con fieldset/legend -->
<fieldset>
<legend>Notification Preferences</legend>
<label><input type="checkbox" name="notify" value="email" /> Email</label>
<label><input type="checkbox" name="notify" value="sms" /> SMS</label>
<label><input type="checkbox" name="notify" value="push" /> Push notifications</label>
</fieldset>
<!-- Radio group -->
<fieldset>
<legend>Subscription Plan</legend>
<label><input type="radio" name="plan" value="free" /> Free</label>
<label><input type="radio" name="plan" value="pro" /> Pro ($9/month)</label>
</fieldset>
<!-- Submit button con clear text -->
<button type="submit">Create Account</button>
</form>
Form validation con ARIA
// Accessible form validation
function validateField(field: HTMLInputElement): boolean {
const errorEl = document.getElementById(`${field.id}-error`);
const isValid = field.checkValidity();
field.setAttribute("aria-invalid", String(!isValid));
if (!isValid && errorEl) {
errorEl.textContent = field.validationMessage;
errorEl.style.display = "block";
} else if (errorEl) {
errorEl.textContent = "";
errorEl.style.display = "none";
}
return isValid;
}
// Announceéa form submission result
function announceResult(message: string) {
const announcer = document.getElementById("form-status");
announcer.textContent = message;
}
// Usage
form.addEventListener("submit", async (e) => {
e.preventDefault();
const fields = Array.from(form.querySelectorAll("input[required]"));
const allValid = fields.every(validateField);
if (!allValid) {
announceResult("Please fix the errors above.");
fields.find((f) => f.getAttribute("aria-invalid") === "true")?.focus();
return;
}
announceResult("Form submitted successfully.");
});
Contraste de Color
/* WCAG 2.2 contrast ratios:
Normal text (< 18pt / 24px): 4.5:1 minimum (AA), 7:1 (AAA)
Large text (>= 18pt / 24px o 14pt bold): 3:1 minimum (AA), 4.5:1 (AAA)
UI components y graphical objects: 3:1 minimum
*/
/* Good contrast (AA compliant) */
.text-primary { color: #1e293b; } /* on white: 14.7:1 */
.text-secondary { color: #475569; } /* on white: 7.6:1 */
.text-muted { color: #64748b; } /* on white: 4.6:1 */
/* Bad contrast (fails AA) */
.text-faint { color: #94a3b8; } /* on white: 2.9:1 — fails para normal text */
/* Focus indicator debe ser 3:1 contra adjacent colors */
button:focus-visible {
outline: 3px solid #2563eb; /* blue on white: 5.2:1 */
outline-offset: 2px;
}
/* No relies solo en color para convey information */
/* Bad: solo red text indica error */
.error { color: #dc2626; }
/* Good: icon + text + color */
.error {
color: #dc2626;
}
.error::before {
content: "⚠ ";
aria-hidden: true;
}
Screen Reader Only Content
/* Visually hidden pero available para screen readers */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Show visually cuando focused (e.g., skip link) */
.sr-only-focusable:focus {
position: static;
width: auto;
height: auto;
overflow: visible;
clip: auto;
white-space: normal;
}
<!-- Proveé context para screen reader users -->
<button>
<span aria-hidden="true">🗑</span>
<span class="sr-only">Delete item</span>
</button>
<!-- Indicá position en un list -->
<ul>
<li>Item 1 <span class="sr-only">of 5</span></li>
<li>Item 2 <span class="sr-only">of 5</span></li>
</ul>
Modal Dialog Accesible
class AccessibleModal {
private dialog: HTMLDialogElement;
private previouslyFocused: HTMLElement | null = null;
constructor(dialog: HTMLDialogElement) {
this.dialog = dialog;
dialog.addEventListener("close", this.onClose.bind(this));
dialog.addEventListener("keydown", this.onKeyDown.bind(this));
}
open() {
this.previouslyFocused = document.activeElement as HTMLElement;
this.dialog.showModal();
// Focusá first focusable element
const firstFocusable = this.dialog.querySelector(
"button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])"
) as HTMLElement;
firstFocusable?.focus();
}
close() {
this.dialog.close();
}
private onClose() {
// Return focus al element que abrió el modal
this.previouslyFocused?.focus();
}
private onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
this.close();
return;
}
// Focus trap: mantené focus dentro del dialog
if (e.key !== "Tab") return;
const focusables = Array.from(
this.dialog.querySelectorAll<HTMLElement>(
"button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])"
)
).filter((el) => !el.hasAttribute("disabled"));
if (focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
<dialog aria-labelledby="modal-title" aria-describedby="modal-desc">
<h2 id="modal-title">Confirm Deletion</h2>
<p id="modal-desc">Are you sure you want to delete this item? This action cannot be undone.</p>
<div class="actions">
<button onclick="modal.close()">Cancel</button>
<button onclick="confirmDelete()">Delete</button>
</div>
</dialog>
Best Practices
-
For a deeper guide, see WCAG 2.2 Accessibility: A Developer Guide.
-
Usá semantic HTML first —
<button>,<nav>,<main>,<article>tienen built-in accessibility -
Agregá ARIA solo cuando HTML no es sufficient — ARIA no agrega behavior, solo semantics
-
Asegurate que todas las interactions funcionen con keyboard alone — testéa desplugging tu mouse
-
Mantené 4.5:1 color contrast para text — usá un contrast checker tool
-
Proveé visible focus indicators — nunca removás
outlinesin un replacement -
Usá
<label>para cada form input — click el label debería focusar el input -
Announceéa dynamic changes con
aria-live— screen reader users necesitan saber que content updated -
Testéa con actual screen readers — NVDA (Windows), VoiceOver (macOS), TalkBack (Android)
-
Seteá
langen<html>— ayuda a screen readers pronounce content correctamente -
Usá
alttext para informative images, emptyalt=""para decorative ones
Common Mistakes
- Remover focus outlines:
* { outline: none }hace keyboard navigation impossible. Siempre proveé un visible focus indicator. - Usar divs para buttons:
<div onclick>no es keyboard accessible. Usá<button>. - Missing form labels: inputs sin labels son unnamed para screen readers. Siempre usá
<label for="id">. - Color-only error indication: red text solo es invisible para colorblind users. Agregá text o icons.
- No alt text en images: screen readers leen el filename. Usá descriptive
altoalt=""para decorative images. - Heading hierarchy skips: saltar de
<h1>a<h4>break el document outline. No saltes levels.
Preguntas frecuentes
¿Qué es WCAG 2.2?
Los Web Content Accessibility Guidelines version 2.2, published por el W3C. Define tres conformance levels: A (lowest), AA (target para most organizations) y AAA (highest). WCAG 2.2 agregó requirements para focus appearance, dragging movements y target size.
¿Qué es ARIA?
Accessible Rich Internet Applications. Un set de HTML attributes que agregan accessibility information a custom widgets. ARIA no agrega behavior — solo comunica semantics a assistive technologies. Usalo cuando HTML's built-in semantics no son enough.
¿Cómo testeo accesibilidad?
Usá automated tools (axe DevTools, Lighthouse) para quick audits. Testéa con keyboard only (Tab, Shift+Tab, Enter, Space, arrows). Testéa con un screen reader (NVDA en Windows, VoiceOver en macOS). Testéa a 200% zoom. Checkeá color contrast con un contrast checker.
¿Qué color contrast ratio necesito?
WCAG AA requiere 4.5:1 para normal text, 3:1 para large text (18pt+ o 14pt bold) y 3:1 para UI components y graphical objects. WCAG AAA requiere 7:1 para normal text y 4.5:1 para large text.
¿Qué es un focus trap?
Un pattern que mantiene keyboard focus dentro de un modal dialog o widget. Cuando el user tabs past el last focusable element, focus wrappea al first element. Esto previene que users interactuen con content detrás del modal.
Recursos Relacionados
Layout CSS Moderno: Grid, Flexbox, container queries
Dominá modern CSS layout: CSS Grid, Flexbox, container queries, subgrid, logical properties y responsive design patterns sin media queries.
RecipeContainer Queries para Responsiveness de Componentes
Cómo usar CSS container queries para layouts responsivos a nivel de componente que se adaptan al tamaño de su contenedor en lugar del viewport.
RecipeDesign Tokens con CSS Custom Properties
Cómo construir un sistema de design tokens usando CSS custom properties, incluyendo escalas de color, spacing, tipografía, temas y escalado responsivo.