Módulo 7: Primitives And Component Libraries

Vestir primitivas con tus tokens

Descripción

La lección 3 te dejó con el kit de piezas de Dialog.* ensamblado y sin pintar: comportamiento completo, apariencia cero. Esta lección le pone el acabado. Y la noticia central es que no hay nada nuevo que aprender para hacerlo: vestir Dialog.Content usa exactamente el mismo motor variants()base + variants + defaultVariants— que construiste para el Button de Mercado en el módulo 5. Una primitiva no impone un sistema de estilos propio, ni una convención de nombres especial, ni una API distinta: es un componente más, que recibe className, como cualquier otro. Lo único distinto es que, además de tu clase, ya trae comportamiento resuelto.

Conexión con el módulo. Esta lección junta dos módulos que hasta ahora vivían separados: las primitivas (L1-L3 de este módulo) y variants() (módulo 5). Reusa el motor tal cual —sin ninguna modificación— y lo aplica a Dialog.Content en vez de a Button. Es la prueba de que "vestir una primitiva" no es una habilidad nueva: es la habilidad del módulo 5, aplicada a un componente cuyo comportamiento no construiste tú.

Una analogía: el mismo taller de costura, otro molde

En el módulo 5 usaste la analogía del patrón de costura: un molde base con ejes (talla, color) que resuelve muchas combinaciones sin coser una prenda distinta para cada una. Ese taller —tus tijeras, tu máquina de coser, tu forma de trabajar— no cambia cuando el material que te llega es distinto. Si en vez de tela lisa te entregan un chaleco de seguridad certificado —ya con los refuerzos y las costuras reforzadas resueltas por el fabricante— tu trabajo sigue siendo el mismo: le agregas el forro, el color, el logo de la marca. No rediseñas el chaleco ni aprendes una técnica de costura nueva; usas tu taller de siempre sobre un material distinto.

Vestir Dialog.Content es coser sobre ese chaleco certificado. El molde (variants()) es el mismo; los ejes (base, size) son el mismo concepto; lo único que cambia es que el "material" —el elemento que recibe la clase final— ya trae el foco, el Escape y el role resueltos, en vez de ser un <div> en blanco. Guarda la imagen: vestir una primitiva es coser con tu taller de siempre sobre un material que ya viene reforzado.

Ejemplo trabajado: Dialog.Content de cero clases a los tokens de Mercado

Antes de vestirlo, así llega Dialog.Content a tus manos: className="", invisible salvo por el texto plano. Le aplicamos exactamente el variants() del módulo 5, con una config nueva pensada para un panel modal —fondo (bg-surface), texto (text-foreground), sombra, padding, y un eje size que controla el ancho máximo—:

// L4 - vestir la primitiva con tus tokens. variants() es EL MISMO motor del modulo 5 (cva);
// vestir un Dialog.Content no es distinto de vestir un Button: el patron ya lo sabes.

function variants(config, props = {}) {
  const classes = config.base ? [config.base] : [];
  const selected = {};
  for (const axis of Object.keys(config.variants ?? {})) {
    const value = props[axis] !== undefined ? props[axis] : config.defaultVariants?.[axis];
    selected[axis] = value;
    const cls = config.variants[axis]?.[value];
    if (cls) classes.push(cls);
  }
  return classes.join(' ');
}

// la primitiva (Dialog.Content de Radix) llega SIN una sola clase. esto es lo que le agregas:
const dialogContentVariants = {
  base: 'fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-md bg-surface text-foreground shadow-lg p-6 data-[state=open]:animate-in data-[state=closed]:animate-out',
  variants: {
    size: {
      sm: 'w-full max-w-sm',
      md: 'w-full max-w-md',
      lg: 'w-full max-w-lg',
    },
  },
  defaultVariants: { size: 'md' },
};

console.log('=== Dialog.Content sin estilo -> vestido con tokens de Mercado ===\n');
console.log('sin clases (la primitiva tal como llega): className=""\n');
console.log('con variants(), size="sm"  ->', variants(dialogContentVariants, { size: 'sm' }));
console.log('con variants(), size="lg"  ->', variants(dialogContentVariants, { size: 'lg' }));
console.log('con variants(), sin props  ->', variants(dialogContentVariants, {}));

Qué esperar. Al correr el archivo con Node, la salida es exactamente esta:

=== Dialog.Content sin estilo -> vestido con tokens de Mercado ===

sin clases (la primitiva tal como llega): className=""

con variants(), size="sm"  -> fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-md bg-surface text-foreground shadow-lg p-6 data-[state=open]:animate-in data-[state=closed]:animate-out w-full max-w-sm
con variants(), size="lg"  -> fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-md bg-surface text-foreground shadow-lg p-6 data-[state=open]:animate-in data-[state=closed]:animate-out w-full max-w-lg
con variants(), sin props  -> fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-md bg-surface text-foreground shadow-lg p-6 data-[state=open]:animate-in data-[state=closed]:animate-out w-full max-w-md

Lee las tres cadenas con la misma mirada que usaste en el módulo 5. Las tres empiezan idéntico —el base completo: posición fija centrada (fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2), esquinas redondeadas, los tokens de color de Mercado (bg-surface text-foreground), sombra, padding, y las clases data-[state=...] que leen el atributo que Radix expone (lección 3) para animar la entrada y salida—. Lo que cambia es el eje size: sm agrega w-full max-w-sm, lg agrega max-w-lg, y sin props el motor cae en el default (md) — el mismo comportamiento de defaultVariants que verificaste con el Button en el módulo 5, ahora aplicado a un panel modal.

El punto que esta lección quiere que te quede grabado: en ningún momento tocaste el foco, el Escape, el role o el focus-trap de Dialog.Content — esos siguen siendo responsabilidad de la primitiva, intactos, sin importar qué size elijas o qué colores uses. Estilar y comportarse son ejes completamente independientes: puedes cambiar bg-surface por cualquier otro token, o rounded-md por rounded-2xl, sin que el focus-trap se entere ni se rompa. Esa independencia es exactamente lo que se prometió en la lección 1 con la analogía del chasis y la carrocería: modificas la carrocería sin tocar el chasis.

Profundización: dónde vive la clase, y por qué no importa

Una pregunta que suele aparecer la primera vez: si Dialog.Content ya es un componente de Radix, ¿cómo le paso mi clase? La respuesta es la más simple posible: Radix acepta className como cualquier elemento HTML, y tú le pasas el resultado de variants() igual que se lo pasarías a un <div>:

// Dialog.jsx — la primitiva de L3, ahora vestida con la config de este ejemplo.
import * as Dialog from '@radix-ui/react-dialog';
import { cva } from 'class-variance-authority';

const dialogContentVariants = cva(
  'fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-md bg-surface text-foreground shadow-lg p-6 data-[state=open]:animate-in data-[state=closed]:animate-out',
  { variants: { size: { sm: 'w-full max-w-sm', md: 'w-full max-w-md', lg: 'w-full max-w-lg' } }, defaultVariants: { size: 'md' } }
);

function CartDialog({ size }) {
  return (
    <Dialog.Root>
      <Dialog.Trigger asChild><Button variant="primary" size="lg">Ver carrito</Button></Dialog.Trigger>
      <Dialog.Portal>
        <Dialog.Overlay className="fixed inset-0 bg-black/50" />
        <Dialog.Content className={dialogContentVariants({ size })}>
          <Dialog.Title className="text-lg font-medium">Tu carrito</Dialog.Title>
          {/* ... */}
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
}

No hay una prop style especial, ni un theme object que le tengas que pasar a la primitiva, ni una convención de nombres que aprender. className entra y sale exactamente como en cualquier elemento del DOM, porque por debajo, cada pieza de Radix es un elemento del DOM realDialog.Content termina siendo un <div> con role="dialog" y los atributos que ya viste—, y el className se aplica a ese <div> como a cualquier otro. Esto también significa que puedes seguir usando dark: (módulo 6) sobre esas mismas clases sin ningún cambio: dialogContentVariants puede tener bg-surface apuntando a un token que ya sabe resolver distinto bajo .dark, y la primitiva ni se entera. Las tres capas —comportamiento (primitiva), tema (módulo 6) y variantes de tamaño (esta lección)— son perpendiculares entre sí, igual que "responsive" y "dark" lo eran en el módulo 6.

Tres capas independientes sobre Dialog.Content:

  comportamiento  →  Radix  (role, focus-trap, Escape — no lo tocas)
  tema            →  bg-surface / dark:  (modulo 6 — resuelve por token)
  tamano          →  variants({ size })  (esta leccion — resuelve por eje)

  las tres conviven en la MISMA className, sin pisarse.

Errores comunes

Intentar pasarle un objeto de estilo en vez de clases de Tailwind. Qué pasa: alguien busca una prop theme={{ background: '...' }} o similar, esperando una API de theming propia de la primitiva. Por qué pasa: algunas librerías de componentes (las que vienen pre-estilizadas) sí exponen una API de theming así, y el hábito se transfiere. Cómo detectarlo: estás buscando en los docs de Radix algo que suena a "sistema de theming", en vez de simplemente pasar className. Cómo corregirlo: Radix no tiene, ni necesita, una API de theming propia — recibe className como cualquier elemento, y tu sistema de tokens (módulo 2) más variants() (módulo 5) son tu API de theming, ya construida. No hay una segunda que aprender.

Vestir Dialog.Overlay y Dialog.Content con la misma clase. Qué pasa: se copia la misma cadena de clases para el fondo oscurecido (Overlay) y para el panel (Content), y el fondo termina con esquinas redondeadas o el panel sin fondo oscurecido detrás. Por qué pasa: son piezas hermanas dentro del mismo Dialog.Portal, y es fácil tratarlas como una sola. Cómo detectarlo: el overlay se ve con bordes raros, o el panel no contrasta contra el fondo. Cómo corregirlo: cada pieza tiene su propia responsabilidad visual además de su responsabilidad de comportamiento — Overlay normalmente es fixed inset-0 bg-black/50 (cubre toda la pantalla, semitransparente); Content es el panel centrado con tus tokens de superficie. Vístelas por separado, como en el JSX de esta lección.

Pensar que cambiar el size puede romper el focus-trap. Qué pasa: dudar en agregar un eje size a Dialog.Content "por si acaso interfiere con el comportamiento de Radix". Por qué pasa: no queda claro, la primera vez, que estilo y comportamiento son capas independientes. Cómo detectarlo: evitas tocar las clases de un componente sobre una primitiva por miedo a romper algo que no tiene relación. Cómo corregirlo: el className nunca toca el comportamiento — Radix gestiona foco, teclado y roles mediante refs y manejadores de evento internos, completamente separados de las clases CSS. Puedes cambiar max-w-sm por max-w-2xl con total libertad; el focus-trap sigue funcionando exactamente igual, porque vive en una capa distinta.

Ejercicios

Ejercicio 1 — Qué cambia y qué no. Si en dialogContentVariants cambias bg-surface por bg-red-500 y rounded-md por rounded-none, ¿qué comportamiento del Dialog (foco, Escape, role) se ve afectado?

Ver solución

Ninguno. El comportamiento del Dialog —el focus-trap, el cierre con Escape, el role="dialog", el aria-modal— vive completamente separado de las clases CSS; Radix lo gestiona con refs, listeners de teclado y atributos ARIA que no tienen relación con className. Cambiar bg-surface por bg-red-500 cambia únicamente el color de fondo; el modal seguirá atrapando el foco y cerrando con Escape exactamente igual. Es la prueba práctica de la independencia entre las capas que muestra el diagrama de esta lección.

Ejercicio 2 — Predice la salida. Sin correr nada: si agregaras un cuarto valor al eje sizexl: 'w-full max-w-xl'— y lo hicieras el nuevo defaultVariants, ¿qué imprimiría variants(dialogContentVariants, {}) (sin pasar props)?

Ver solución

Imprimiría el base completo seguido de w-full max-w-xl — porque sin pasar size, el motor cae en defaultVariants.size, que ahora sería 'xl'. La cadena sería: fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-md bg-surface text-foreground shadow-lg p-6 data-[state=open]:animate-in data-[state=closed]:animate-out w-full max-w-xl. Es exactamente el mismo comportamiento de defaultVariants del Button en el módulo 5 — cambiar el default no requiere tocar la lógica del motor, solo el valor en la config.

Ejercicio 3 — Ubica la frontera. Un compañero dice: "ya que estamos vistiendo primitivas, aprovechemos y movamos toda la lógica de qué productos hay en el carrito adentro de Dialog.Content". ¿Es correcto? ¿Por qué sí o por qué no, según lo que sabes de la frontera de este módulo?

Ver solución

No es correcto, según la frontera que la lección 1 marcó explícitamente: la lógica de negocio —qué productos hay, el total, las llamadas al servidor para actualizar el carrito— no es asunto de esta guía ni de este módulo; vive en react-fundamentals (el estado) y frontend-state-and-data (los datos del servidor). Dialog.Content es responsable de cómo se comporta y se ve el panel modal —foco, teclado, role, y ahora también color y tamaño—, no de qué contenido de negocio muestra. El compañero está confundiendo "ya estamos tocando este componente" con "aprovechemos para meter todo aquí" — la capa de UI y la capa de datos siguen siendo responsabilidades distintas, primitiva o no.

Resumen y siguiente paso

En esta lección cerraste el círculo entre primitivas (L1-L3) y componentes con variantes (módulo 5): vestir una primitiva usa exactamente el mismo variants() que vestiste al Button; no hay una API de estilo nueva que aprender. Con el chaleco certificado y el mismo taller de costura viste por qué: el molde no cambia, el material sí. Y lo comprobaste ejecutando: Dialog.Content pasó de className="" a tres cadenas de clases completas —sm, lg, default— usando el motor sin una sola modificación.

Antes de avanzar deberías poder: explicar por qué className en una primitiva funciona igual que en cualquier elemento; nombrar las tres capas independientes que conviven en Dialog.Content (comportamiento, tema, tamaño); y decir por qué cambiar el color o el tamaño de un panel modal nunca rompe su focus-trap.

Ya sabes vestir una primitiva. La lección 5 da un paso al costado y responde una pregunta distinta, de las que se deciden una sola vez por proyecto: ¿de dónde sale esa primitiva? ¿La instalas como una dependencia de tu package.json, o copias su código directamente a tu repo, como hace shadcn/ui? Las dos opciones existen, tienen tradeoffs reales, y esta guía usa una — vas a entender cuál y por qué.

Recursos

  • Radix Primitives, "Styling" — radix-ui.com/primitives/docs/guides/styling. Cómo Radix espera que le pases clases —className estándar, sin API propia—; la base de esta lección. En inglés.
  • shadcn/ui, "Theming" — ui.shadcn.com/docs/theming. Cómo shadcn conecta las clases de un componente generado con tus tokens/variables CSS; el mismo principio de esta lección, en su forma de producción. En inglés.
  • cva (class-variance-authority), documentación oficial — cva.style/docs. El motor real que variants() emula, ahora aplicado a un panel modal en vez de a un botón. En inglés.
  • Tailwind CSS, "Hover, Focus, and Other States" (sección de selectores de atributo data-*) — tailwindcss.com/docs/hover-focus-and-other-states. Cómo data-[state=open]: lee el atributo que Radix expone, el mecanismo detrás de la animación de esta lección. En inglés.