Lección 8 de 10 · 14 min de lectura · Revisado el 26/10/2026
Componentes reutilizables y Web Components: API accesible, semántica y Shadow DOM
En la lección anterior resolviste la accesibilidad a escala de aplicación: rutas, títulos y foco. Esta baja al ladrillo con el que se construyen hoy las interfaces: el componente reutilizable. Un botón, un campo o una tarjeta bien empaquetados repiten la accesibilidad en cada pantalla; mal empaquetados, repiten el error cien veces. No se trata de los patrones de interacción (pestañas, menús, diálogos), que viste en el curso de componentes interactivos, sino de cómo se programa el componente para que su uso accesible sea el camino fácil: su API, su marcado y, en el caso de los Web Components, su relación con el Shadow DOM.
Diseña la API para que lo accesible sea lo fácil
La API de un componente son las propiedades que acepta. Si el nombre accesible es opcional, alguien lo omitirá. Hazlo obligatorio y deja que el sistema de tipos lo exija:
Ejemplo
type BotonIconoProps = {
icono: React.ReactNode;
etiqueta: string; // obligatorio: sin él no compila
onClick: () => void;
};
export function BotonIcono({ icono, etiqueta, onClick }: BotonIconoProps) {
return (
<button type="button" onClick={onClick}>
<span aria-hidden="true">{icono}</span>
<span className="visually-hidden">{etiqueta}</span>
</button>
);
}
Quien use <BotonIcono icono={<Papelera />} onClick={borrar} /> sin etiqueta recibe un error de TypeScript en el editor, mucho antes que cualquier auditoría.
Otras decisiones de API con efecto directo en la accesibilidad:
- Semántica según el uso: si el componente recibe
href, que renderice un<a>; si recibe una acción, un<button type="button">. Un único componente «Botón» que siempre pinta<button>y navega cononClickrompe el menú contextual, el «abrir en pestaña nueva» y el anuncio de «enlace». - Niveles de encabezado configurables: una tarjeta que fija un
<h3>romperá la jerarquía en cuanto se use en otra página. Acepta una propiedadnivelcon un valor por defecto razonable. - Atributos que llegan al elemento correcto:
id,aria-describedby,aria-invalidoautocompletedeben aplicarse al<input>real, no aldivque lo envuelve. - Acceso al elemento: el componente padre necesita poder enfocar el control (para llevar el foco a un error o devolverlo al cerrar un diálogo). En React se expone con
forwardRefo, desde React 19, recibiendorefcomo una propiedad más; en Vue, con referencias de plantilla ydefineExpose; en Angular, conElementRefy las consultas de vista. - Contenido, no solo texto: deja que quien usa el componente aporte contenido rico mediante
childrenen React, slots en Vue o proyección de contenido en Angular, para que no recurra a trucos como meter HTML en una cadena.
Error frecuente
Un componente de campo que reparte todos los atributos recibidos sobre el contenedor exterior. En Vue ocurre por defecto: los atributos que no son propiedades declaradas se aplican al elemento raíz. El resultado es un <div aria-describedby="ayuda-dni" aria-invalid="true"> que el lector ignora y un <input> sin descripción ni estado de error. La solución es desactivar la herencia y dirigir los atributos al control:
<script setup>
import { useId } from 'vue';
defineOptions({ inheritAttrs: false });
defineProps({ etiqueta: { type: String, required: true } });
const id = useId();
</script>
<template>
<div class="campo">
<label :for="id">{{ etiqueta }}</label>
<input :id="id" v-bind="$attrs">
</div>
</template>
En React, el equivalente es recoger el resto de propiedades (...resto) y extenderlas sobre el <input>, no sobre el envoltorio.
Que el envoltorio no rompa la semántica
Algunas relaciones del HTML exigen parentesco directo: un <li> debe ser hijo de <ul> u <ol>, y las filas y celdas de una tabla deben estar en su sitio. Los componentes tienden a meter elementos intermedios que rompen esas relaciones, y el lector deja de anunciar «lista, 8 elementos» o pierde la asociación entre celdas y encabezados.
- React: si un componente necesita devolver varios elementos, usa un fragmento (
<>…</>) en lugar de undiv. - Vue 3: admite varios nodos raíz, así que no hace falta un envoltorio artificial.
- Angular: cada componente se renderiza dentro de su elemento anfitrión (
<app-tramite>), que queda entre el<ul>y el<li>. La solución es un selector de atributo, de modo que el anfitrión sea el propio elemento semántico:
@Component({
selector: 'li[appTramite]',
template: `<a [href]="url">{{ titulo }}</a>`,
})
export class TramiteComponent {
@Input() titulo = '';
@Input() url = '';
}
<!-- Uso: la lista sigue siendo una lista -->
<ul>
<li appTramite titulo="Empadronamiento" url="/padron"></li>
<li appTramite titulo="Cita previa" url="/cita"></li>
</ul>
Ten cuidado con display: contents como atajo para «hacer desaparecer» un envoltorio: durante años varios navegadores eliminaban con él la semántica del elemento (un botón o una tabla dejaban de serlo). Aunque se ha corregido en las versiones recientes, no lo apliques a elementos con rol propio y compruébalo en el árbol de accesibilidad.
Web Components y Shadow DOM
Los Web Components (elementos personalizados, como <mi-interruptor>) son la forma estándar de crear componentes reutilizables sin framework, y muchos sistemas de diseño los usan para servir a equipos con tecnologías distintas. Tienen dos particularidades que hay que conocer.
Un elemento personalizado no tiene semántica
Para el navegador, <mi-boton> es un elemento genérico, como un span: sin rol, sin foco y sin teclado. Hay dos estrategias. La preferible es envolver un elemento nativo dentro de su shadow root (el árbol interno encapsulado del componente):
class MiBoton extends HTMLElement {
constructor() {
super();
const raiz = this.attachShadow({ mode: 'open', delegatesFocus: true });
raiz.innerHTML = '<button type="button"><slot></slot></button>';
}
}
customElements.define('mi-boton', MiBoton);
El botón interno aporta rol, foco y teclado, y su nombre se calcula con el texto que el autor coloca dentro de <mi-boton>. La opción delegatesFocus hace que, al enfocar el anfitrión, el foco pase al primer elemento enfocable del interior.
Cuando el anfitrión debe ser el propio control, la interfaz ElementInternals permite darle rol y estados por defecto sin escribir atributos ARIA que el autor pueda pisar por accidente:
class MiInterruptor extends HTMLElement {
#internals = this.attachInternals();
constructor() {
super();
this.#internals.role = 'switch';
this.#internals.ariaChecked = 'false';
this.addEventListener('click', () => this.alternar());
this.addEventListener('keydown', (e) => {
if (e.key === ' ') { e.preventDefault(); this.alternar(); }
});
}
connectedCallback() {
if (!this.hasAttribute('tabindex')) this.tabIndex = 0;
}
alternar() {
this.#internals.ariaChecked = String(this.#internals.ariaChecked !== 'true');
}
}
customElements.define('mi-interruptor', MiInterruptor);
Con esta estrategia tú asumes todo: foco, teclado, estados y nombre (que tendrá que aportar el autor con aria-label o aria-labelledby). Es la regla 1 de ARIA aplicada a los Web Components: si existe un elemento nativo, úsalo dentro.
Las referencias por id no cruzan el Shadow DOM
Los atributos que relacionan elementos por su id (for, aria-labelledby, aria-describedby, aria-controls, aria-activedescendant) solo funcionan dentro del mismo árbol. Una etiqueta del documento no puede apuntar a un <input> que vive dentro de un shadow root, ni un elemento interno puede apuntar a un mensaje de error de fuera.
<!-- Incorrecto: el input real está dentro del shadow root de campo-texto -->
<label for="dni">DNI</label>
<campo-texto id="dni"></campo-texto> <!-- campo sin nombre accesible -->
Soluciones, de más a menos recomendable: mantener etiqueta, control, ayuda y error dentro del mismo componente (que recibe sus textos por atributos o propiedades); convertirlo en un elemento asociado a formulario (static formAssociated = true junto con ElementInternals), que el navegador trata como etiquetable y que participa en el envío y la validación del formulario; o, como último recurso, pasar el nombre con aria-label. Hay propuestas en el W3C y en los navegadores para permitir referencias que crucen esta frontera, pero su soporte todavía no permite depender de ellas.
Pruébalo tú
Elige un componente de campo de tu sistema de diseño o de una librería conocida. Inspecciona el <input> en las herramientas de desarrollo y abre la pestaña de accesibilidad: comprueba su nombre, su descripción y si aparece invalid cuando el campo tiene un error. Si el nombre está vacío o la descripción no llega, mira a qué elemento se están aplicando los atributos.
Documentar y probar cada componente
Un componente accesible necesita una ficha que diga qué resuelve y qué debe aportar quien lo usa: propiedades obligatorias (etiqueta, título del diálogo), tabla de teclado, atributos ARIA que gestiona el propio componente y ejemplos de uso correcto e incorrecto. Prueba cada estado (cerrado, abierto, con error, deshabilitado) con las pruebas automáticas de la lección siguiente; axe-core analiza también los shadow roots abiertos, y los complementos de accesibilidad de catálogos de componentes como Storybook ejecutan esas reglas en cada historia. Evita mode: 'closed': oculta el interior a las herramientas de prueba sin aportar seguridad real.
Punto clave
Un componente reutilizable multiplica lo que contiene. Haz obligatorias las propiedades de las que depende el nombre accesible, dirige los atributos al control real, no metas envoltorios que rompan listas y tablas y, en los Web Components, envuelve elementos nativos y mantén juntas la etiqueta y el control dentro del mismo árbol.
Resumen
- Diseña la API para que el nombre accesible sea obligatorio y la semántica dependa del uso (enlace o botón, nivel de encabezado configurable).
- Dirige
id,aria-*y demás atributos al control real (inheritAttrs: falseen Vue, resto de propiedades en React) y expón una referencia para gestionar el foco. - Evita envoltorios entre
ulylio dentro de tablas: fragmentos en React, varias raíces en Vue, selectores de atributo en Angular. - Un elemento personalizado no tiene semántica: envuelve un nativo o usa
ElementInternalsasumiendo foco y teclado. - Las referencias por
idno cruzan el Shadow DOM: etiqueta y control en el mismo árbol o elemento asociado a formulario.
Comprueba lo que has aprendido
Esta lección se puede leer libremente. Para hacer su test de 3 preguntas, los ejercicios prácticos con páginas reales, guardar tu avance y optar al examen y al certificado, matricúlate en el curso.