Módulo 7: Primitives And Component Libraries
Presentación del módulo: primitivas y librerías de componentes
De "componentes que estilas" a "primitivas que además ya son accesibles"
En los seis módulos anteriores construiste un sistema completo desde los tokens hasta la superficie. El módulo 2 te dio los tokens; el 3, las utilidades que los consumen; el 4, las escalas y el contraste que garantiza que tu texto se lee; el 5, los componentes con variantes (Button con variant × size, resuelto por variants()); el 6, responsive y dark mode sin duplicar el componente. Con todo eso sabes construir un Button de sistema de punta a punta: base, ejes, defaults, breakpoints, tema. Y si mañana Mercado necesita un Badge o una Card, ya sabes el patrón: JSX + clases de Tailwind + tokens + variants().
Pero hay una familia de componentes donde ese mismo patrón no alcanza, y hasta ahora la guía no te lo ha dicho. Prueba a construir, con lo que ya sabes, el modal del carrito de Mercado: un <div> que aparece encima del catálogo cuando el usuario hace clic en el ícono del carrito. Con variants() le pones el fondo, el padding, la sombra —eso lo dominas—. Pero un modal de verdad tiene que hacer más que verse bien: si el usuario presiona Tab, el foco no puede escaparse hacia el catálogo que quedó detrás; si presiona Escape, el modal tiene que cerrarse; un lector de pantalla tiene que anunciar "diálogo: tu carrito" y no "contenido genérico"; y al cerrar, el foco tiene que volver exactamente al botón que lo abrió. Ninguna clase de Tailwind resuelve eso. Es comportamiento, no apariencia —y es exactamente el tipo de comportamiento que es fácil de olvidar porque, a simple vista, un modal sin nada de eso se ve idéntico a uno bien hecho.
Ese es el problema que este módulo resuelve, y lo resuelve con una idea prestada de dos proyectos que dominan el ecosistema React: Radix y shadcn/ui. La idea es separar dos responsabilidades que hasta ahora mezclabas en un mismo componente: el comportamiento accesible (foco, teclado, roles ARIA, estados) por un lado, y la apariencia (tus tokens, tus variantes) por el otro. Una primitiva es un componente que resuelve la primera responsabilidad —y nada de la segunda—: llega a tus manos sin una sola clase de CSS, pero con el foco, el teclado y los roles ya correctos. Tú le pones tu look encima con exactamente las mismas herramientas del módulo 5: variants(), tus tokens, tus clases. No aprendes un sistema de estilos nuevo; aprendes a apoyarte en comportamiento que ya no tienes que escribir.
Conexión con el módulo. Esta es la lección-mapa del módulo 7, el último antes del capstone. No entra a fondo en ninguna pieza: instala la tesis (una primitiva resuelve comportamiento accesible sin imponer estilo; tú la vistes con lo que ya sabes de M2-M5) y ejecuta un primer teaser de a11yAudit() —el checklist que vas a usar en todo el módulo para medir, no opinar, qué tan accesible es un componente—. La lección 2 muestra por qué esta accesibilidad es "invisible" —se ve igual, funciona distinto—. La 3 abre la anatomía de una primitiva real (Radix). La 4 la viste con tus tokens. La 5 y la 6 resuelven las dos preguntas de negocio: ¿copiar el código o instalar una dependencia?, ¿construir o usar una librería? Y la 7 profundiza en el caso más exigente, el Dialog. La 8 te pone a construir el carrito y el menú de orden de Mercado sobre primitivas reales.
Una analogía: el chasis certificado y la carrocería que le pones
Cuando una automotriz lanza un modelo nuevo, no empieza soldando el chasis y diseñando el motor desde cero para cada versión del auto. Existe una plataforma certificada —el chasis, el motor, la dirección, los frenos, las bolsas de aire— que ya pasó pruebas de choque, que ya frena donde tiene que frenar, que ya protege al pasajero si algo sale mal. Lo que cambia entre el sedán familiar y el deportivo de la misma plataforma es la carrocería: el color, la forma de las líneas, el tapizado, el logo. Nadie diseña un cinturón de seguridad desde cero para el modelo nuevo —eso ya está resuelto, certificado, probado, y reutilizarlo es lo responsable—.
Una primitiva (Radix, y en general el modelo shadcn) es esa plataforma certificada, pero para un componente de interfaz. El Dialog de Radix ya "pasó las pruebas de choque": el foco no se escapa, Escape cierra, el lector de pantalla anuncia lo que tiene que anunciar. Tu trabajo —tus tokens, variants(), las clases de Mercado— es la carrocería: el color, la forma, el tapizado. Construir esa carrocería es trabajo real y tuyo (módulo 5 entero). Pero fabricar tu propio cinturón de seguridad —reimplementar el focus-trap de un modal desde cero, a mano, componente por componente— es exactamente el tipo de trabajo que una plataforma certificada existe para ahorrarte, y que casi nadie hace bien al primer intento: hay demasiados casos borde (¿qué pasa si el usuario presiona Tab en el último elemento? ¿y Shift+Tab en el primero? ¿y si hay un <iframe> adentro?) para que valga la pena resolverlos de cero en cada proyecto.
Guarda la imagen: una primitiva es el chasis certificado —comportamiento accesible ya probado—; tus tokens y variantes son la carrocería que le pones encima. No rediseñas el chasis; lo vistes.
Ejemplo trabajado: medir la accesibilidad, no opinarla
El instrumento que vas a usar en todo el módulo se llama a11yAudit() —"a11y" es la abreviatura estándar de accessibility (a + 11 letras + y)—. Es un checklist pedagógico basado en el patrón que define la W3C para cada tipo de widget (el WAI-ARIA Authoring Practices Guide, o APG, que vas a citar en los recursos de cada lección): dado un componente descrito por sus features —¿tiene el rol correcto?, ¿es focuseable con Tab?, ¿Enter/Space lo activan?—, a11yAudit() devuelve un score y qué falta. Aclaración honesta antes de seguir: esto no es un test real de lector de pantalla ni una herramienta de auditoría de producción (esas existen —axe, Lighthouse— y las nombramos en los recursos); es un modelo que te obliga a nombrar cada pieza de comportamiento accesible en vez de asumirla.
El teaser de hoy lo aplica al caso más simple posible: un botón. Comparamos un Button casero —un <div> con un onClick, el error más común que existe— contra un botón real (nativo o construido sobre una primitiva).
// L1 intro — a11yAudit() completo, aplicado al ejemplo mas simple posible: un Button.
// a11yAudit es un checklist PEDAGOGICO basado en el patron APG (WAI-ARIA Authoring Practices),
// no un test real de lector de pantalla.
const CHECKS = [
{ key: 'role', label: 'rol ARIA correcto' },
{ key: 'focusable', label: 'focuseable con Tab' },
{ key: 'activate', label: 'Enter/Space activa' },
{ key: 'escape', label: 'Escape cierra/cancela' },
{ key: 'arrows', label: 'flechas navegan entre opciones' },
{ key: 'focusTrap', label: 'foco atrapado dentro (focus-trap)' },
{ key: 'ariaState', label: 'expone estado por aria-* (aria-checked, aria-expanded...)' },
];
function a11yAudit(component) {
const applicable = CHECKS.filter((c) => component.applicable.includes(c.key));
const missing = applicable.filter((c) => !component.pass.includes(c.key)).map((c) => c.label);
return { name: component.name, score: applicable.length - missing.length, total: applicable.length, missing };
}
const homemadeButton = {
name: 'Button casero (<div onClick>)',
applicable: ['role', 'focusable', 'activate'],
pass: [],
};
const primitiveButton = {
name: 'Button real (<button> nativo / primitiva)',
applicable: ['role', 'focusable', 'activate'],
pass: ['role', 'focusable', 'activate'],
};
console.log('=== a11yAudit: Button casero vs Button real ===\n');
for (const c of [homemadeButton, primitiveButton]) {
const r = a11yAudit(c);
console.log(`${r.name}: ${r.score}/${r.total}`);
if (r.missing.length) console.log(' falta: ' + r.missing.join(', '));
}
Qué esperar. Al correr el archivo con Node, la salida es exactamente esta:
=== a11yAudit: Button casero vs Button real ===
Button casero (<div onClick>): 0/3
falta: rol ARIA correcto, focuseable con Tab, Enter/Space activa
Button real (<button> nativo / primitiva): 3/3
Lee el resultado con cuidado, porque contiene la tesis completa del módulo. El Button casero falla las tres casillas aplicables: un <div> no tiene rol de botón a menos que se lo pongas explícitamente (role="button"), no entra en el orden de tabulación a menos que le agregues tabindex="0", y su onClick no dispara con Enter ni con Space —esos eventos de teclado son responsabilidad de quien escribe el componente, y un onClick de mouse no los cubre—. Nada de eso es visible mirando la pantalla: el <div> se ve, se colorea, hasta tiene forma de botón. Es invisible solo para quien no usa mouse. El botón real —ya sea el elemento nativo <button> del navegador o un Button construido sobre una primitiva— pasa las tres sin que tú hayas escrito una línea de JavaScript para lograrlo: el rol, el foco y la activación por teclado vienen incluidos, porque es exactamente el contrato que un elemento interactivo del navegador —o una primitiva bien hecha— tiene que cumplir.
Nota el array applicable: no todos los checks aplican a todo componente. Un Button simple no necesita Escape (no hay nada que cerrar) ni arrows (no hay opciones entre las que navegar) ni focusTrap (no es modal). Por eso el checklist completo tiene siete casillas, pero un Button solo se mide contra tres. Esa distinción —qué casillas aplican según el tipo de widget— es la que vas a usar en la lección 2 con un checkbox, y en la 7 con un modal completo, donde sí entran Escape, focusTrap y las demás.
El mapa del módulo
Idea Lección Concepto clave
────────────────────────────────────────── ──────── ─────────────────────────────────────
La accesibilidad que no se ve L2 role/focus/teclado/aria-state: se ve
igual, funciona distinto (a11yAudit)
Primitivas sin estilo pero accesibles L3 anatomia de un componente compuesto
(Root/Trigger/Content...); Radix real
Vestir la primitiva con tus tokens L4 variants() (M5) sobre Dialog.Content;
la primitiva llega con className=""
El modelo shadcn: copiar el codigo L5 copiar-el-codigo (tuyo, en tu repo) vs
instalar una dependencia (dueño externo)
Cuando usar una libreria y cuando construir L6 un motor de decision: patron de teclado
no trivial + manejo de foco -> primitiva
Un Dialog bien hecho L7 a11yAudit() completo sobre un Dialog:
casero vs sobre una primitiva, medido
────────────────────────────────────────── ──────── ─────────────────────────────────────
Proyecto: el carrito y el orden de Mercado L8 CartDialog + SortMenu sobre primitivas,
vestidos con tokens, auditados
La frontera: qué NO entra en este módulo
- ARIA a fondo —la especificación completa, cada rol y cada atributo
aria-*, cómo probar con un lector de pantalla real (VoiceOver, NVDA, JAWS)— es tema de una guía de accesibilidad dedicada, no de esta. Aquí nombramos los roles y estados que una primitiva resuelve —lo suficiente para reconocerlos y confiar en ellos— y remitimos a la fuente oficial (la APG de W3C, en los recursos de cada lección) para el detalle exhaustivo. - Los tokens y las variantes en sí —
resolveToken,variants(),base/variants/defaultVariants/compoundVariants— son los módulos 2 y 5. Aquí no se re-enseñan; se reusan para vestir primitivas, exactamente como se usaron para vestir elButtoncasero. - Responsive y dark mode del componente vestido —
sm:/lg:,dark:, theming por tokens— es el módulo 6. UnDialogde Mercado también puede ser responsive y tener dark mode; ese eje ya lo sabes aplicar y no se repite aquí. - El estado de negocio —qué hay en el carrito, qué pasa al confirmar la compra, las llamadas al servidor— sigue sin ser asunto de esta guía: es
react-fundamentalsyfrontend-state-and-data. Aquí elDialogdel carrito se abre, se cierra, atrapa el foco y se ve bien; qué contiene no es la lección.
Errores comunes
Pensar que "primitiva" significa "sin diseño", en el sentido de "fea". Qué pasa: alguien asume que usar Radix o shadcn te ata a una apariencia genérica de librería, tipo Bootstrap por defecto. Por qué pasa: muchas librerías de componentes sí imponen un look —colores, tipografía, espaciados propios— y la palabra "librería" se asocia con eso. Cómo detectarlo: dudas en adoptar primitivas porque "no se van a ver como Mercado". Cómo corregirlo: una primitiva no tiene ningún estilo, ni genérico ni bonito ni feo —llega con className=""—. El look final es 100% tuyo, con tus tokens y tu variants(), como vas a comprobar en la lección 4. La primitiva resuelve comportamiento; el diseño lo sigues poniendo tú, entero.
Creer que "se ve bien" es lo mismo que "es accesible". Qué pasa: un modal casero se revisa visualmente —colores correctos, centrado, sombra— y se da por aprobado. Por qué pasa: la revisión visual es la que hacemos por default, todo el tiempo, con los ojos. Cómo detectarlo: nadie en el equipo probó el componente sin mouse —solo con Tab, Enter, Escape— antes de aprobarlo. Cómo corregirlo: la accesibilidad de comportamiento no se ve, se prueba: navega el componente entero con teclado, sin tocar el mouse. Es exactamente lo que a11yAudit() modela en este módulo, y lo que la lección 2 pone en el centro.
Saltarse el módulo pensando "esto ya lo sé, uso una librería y listo". Qué pasa: alguien instala una librería de componentes pre-estilada (piensa en una que trae su propio look, tipo Material UI o Bootstrap) para todo, incluido el Button que ya construiste en el módulo 5. Por qué pasa: "librería" suena a "más rápido siempre". Cómo detectarlo: tu Button de Mercado —con variantes, con tus tokens, verificado en el módulo 5— convive con botones de otra librería que no comparten ni un token ni una clase, y el storefront empieza a verse inconsistente. Cómo corregirlo: la lección 6 te da el criterio —construyes lo simple (Button, Badge, Card), usas una primitiva sin estilo para lo complejo (Dialog, Menu, Combobox)—. Una librería pre-estilada y pesada que pelea con tu sistema es distinta de una primitiva, y confundirlas es el error que este módulo entero existe para prevenir.
Ejercicios
Ejercicio 1 — Comportamiento o apariencia. Para cada responsabilidad, di si es comportamiento (lo que resuelve una primitiva) o apariencia (lo que resuelves tú con tokens/variantes):
- (a) Que
Escapecierre el modal. - (b) Que el modal tenga fondo
bg-surfacey esquinasrounded-md. - (c) Que el foco no se escape del modal mientras está abierto.
- (d) Que el botón "Add to cart" sea
variant="primary" size="lg".
Ver solución
- (a) Comportamiento. Manejar la tecla
Escapey decidir qué hacer con ella (cerrar) es lógica de interacción, exactamente lo que una primitiva ya trae resuelto. - (b) Apariencia. Colores y bordes son clases de Tailwind sobre tus tokens —el módulo 2 y el 5, no algo que una primitiva decida por ti (de hecho, la primitiva llega sin ninguna de estas clases).
- (c) Comportamiento. El focus-trap es la pieza más difícil de comportamiento accesible de un modal; es justamente lo que estás delegando en la primitiva en este módulo.
- (d) Apariencia.
variantysizeson las variantes del módulo 5; deciden cómo se ve el botón, no cómo se comporta.
La pregunta guía de todo el módulo: ¿esto decide cómo funciona el componente, o cómo se ve? Lo primero es trabajo de la primitiva; lo segundo sigue siendo tuyo.
Ejercicio 2 — Predice el score. Sin correr nada: el teaser midió un Button contra tres casillas aplicables (role, focusable, activate) y el casero sacó 0/3. Si agregaras un cuarto componente —un <a href="#"> (un link real, sin onClick de JavaScript) usado como si fuera un botón— y lo midieras con las mismas tres casillas, ¿qué score esperarías, y por qué?
Ver solución
Un <a href="#"> real sí es focuseable con Tab (los links son focuseables por default) y sí activa con Enter (los links nativos responden a Enter) — pero no activa con Space (esa tecla no dispara los links, solo hace scroll de la página) y su rol es link, no button, así que un lector de pantalla lo anuncia como "enlace", no como "botón" —una expectativa distinta para quien lo escucha—. Con el checklist tal como está (activate es una sola casilla que exige el patrón completo Enter/Space, y role exige el rol correcto para lo que el componente hace), este caso sacaría 1/3 (focusable sí; role y activate no, estrictamente). Es el ejemplo clásico de "casi, pero no": un link usado como botón se siente cercano a estar bien, y por eso es un error tan común —pasa desapercibido más que el <div onClick>, que al menos "se siente" claramente sospechoso—.
Ejercicio 3 — Ubica el módulo. El módulo 1 dio las capas del sistema (tokens → utilidades → componentes → patrones). ¿En qué capa entra lo que construyes en este módulo 7 —y por qué no es una capa nueva?
Ver solución
Sigue siendo la capa de componentes (la del módulo 5), no una capa nueva. Un Dialog construido sobre una primitiva es, igual que el Button del módulo 5, un componente con variants(), tokens y una API de props. Lo único que cambia es de dónde sale el comportamiento: en el módulo 5 el comportamiento era mínimo (un onClick, cosas que React ya resuelve); aquí el comportamiento es complejo (foco, teclado, ARIA) y en vez de escribirlo a mano, lo importas de una primitiva y le pones tu apariencia encima. Mismo lugar en el sistema, misma forma final (un componente con variantes) —cambia únicamente quién es responsable de la parte de comportamiento.
Resumen y siguiente paso
En esta lección instalaste la tesis del módulo 7: una primitiva resuelve el comportamiento accesible de un componente —foco, teclado, roles ARIA— sin imponer ni una sola clase de estilo; tú le pones tu apariencia encima con las mismas herramientas que ya conoces (tokens, variants()). Con el chasis certificado y la carrocería viste la distinción que estructura el módulo: no rediseñas el chasis —el comportamiento probado—, lo vistes. Y lo comprobaste ejecutando: a11yAudit() midió un Button casero contra uno real y encontró una diferencia medible, no opinada —0/3 contra 3/3—, en tres casillas que a simple vista son invisibles.
Antes de avanzar deberías poder: explicar la diferencia entre comportamiento y apariencia con un ejemplo propio; nombrar qué es a11yAudit() y qué no es (un checklist pedagógico, no un test real de accesibilidad); y anticipar que distintos widgets se miden contra distintas casillas del checklist.
La lección 2 se queda en esta misma idea y la profundiza: la accesibilidad que no se ve. Vas a medir un segundo caso —un checkbox— donde el problema es todavía más sutil que con el botón: los dos checkboxes, el casero y el accesible, se ven exactamente igual en pantalla —el mismo ✓—. La diferencia está enterrada en el árbol de accesibilidad, invisible para cualquiera que no dependa de él. Ahí es donde el módulo deja de ser abstracto.
Recursos
- Radix Primitives, "Introduction" — radix-ui.com/primitives/docs/overview/introduction. Qué es una primitiva sin estilo y por qué existe; la fuente del modelo que este módulo enseña. En inglés.
- shadcn/ui, "Introduction" — ui.shadcn.com/docs. El modelo "copiar el código" en su propia voz; lo profundizamos en la lección 5. En inglés.
- W3C WAI-ARIA Authoring Practices Guide (APG) — w3.org/WAI/ARIA/apg. El patrón oficial de comportamiento accesible por tipo de widget (button, dialog, menu...); la fuente de la que
a11yAudit()toma sus casillas. En inglés. - React, "Passing Props to a Component" — react.dev/learn/passing-props-to-a-component. El prerequisito de
react-fundamentals, otra vez relevante: una primitiva se consume igual que cualquier componente, por props. En inglés.