Referencia Detallada de 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.
Nota para desarrolladores hispanohablantes: Esta guía incluye ejemplos y convenciones de nomenclatura adaptadas a equipos que trabajan en español. Cuando existen diferencias significativas en terminología técnica entre el inglés y el español, se indican explícitamente para facilitar la comunicación en equipos multiculturales.
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.
FAQ
¿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
CSS Modern Layout: Grid, Flexbox, Container Queries
Master modern CSS layout: CSS Grid, Flexbox, container queries, subgrid, logical properties, and responsive design patterns without media queries.
RecipeContainer Queries for Component Responsiveness
How to use CSS container queries for component-level responsive layouts that adapt to their container size instead of the viewport.
RecipeDesign Tokens with CSS Custom Properties
How to build a design token system using CSS custom properties, including color scales, spacing, typography, themes, and responsive scaling.