Módulo 5: Components With Variants

Presentación del módulo: componentes con variantes

De "utilidades sueltas" a "componentes que las empaquetan con variantes"

En los cuatro módulos anteriores construiste las dos capas de abajo del sistema de Mercado. El módulo 2 te dio los tokens (color.primary, space-4, text-lg); el módulo 3, las utilidades que los consumen (bg-primary, p-4, text-lg); y el módulo 4, las escalas de donde salen sus valores y el contraste que garantiza que se leen. Con todo eso, hoy sabes vestir un elemento: escribes class="inline-flex items-center rounded-md bg-primary text-white px-4 py-2" en un botón y queda estilado, con clases que apuntan a tus tokens y salen de tu escala.

Pero fíjate en lo que no tienes todavía. Esa cadena de clases vive suelta en el marcado, y el botón de Mercado no aparece una vez: aparece en el product-card, en la página de producto, en el mini-carrito, decenas de veces por todo el storefront. Ahora mismo, cada aparición hardcodea la misma cadena. Y cuando alguien pida "el botón secundario del checkout" o "un botón chico para el filtro", no tienes un mecanismo: copias la cadena, cambias unas clases a mano, y rezas para que no se te escape una. Las utilidades resolvieron cómo estilar un elemento; no resolvieron cómo tener un botón —una sola pieza, con un solo look de marca— que se use cien veces y admita variaciones controladas.

Esa es la capa 3 del sistema, y es de lo que trata este módulo: componentes. Un componente empaqueta un conjunto de utilidades bajo un nombre (Button, ProductCard) para que la cadena de clases viva en un solo lugar y todo el storefront la consuma. Y lo que hace a un componente de sistema —no un simple copy-paste con nombre— son las variantes: ejes de configuración (variant: primary/secondary/ghost; size: sm/md/lg) que el componente resuelve por props, no por copiar código. Un solo Button, muchas apariencias, todas salidas del mismo patrón. Vas a aprender el patrón que la industria usa para esto —cva (class-variance-authority)—: una config con base, variants, defaultVariants y compoundVariants que, dadas unas props, resuelve la lista de clases final.

Conexión con el módulo. Esta es la lección-mapa del módulo 5. No entra a fondo en ninguna pieza: instala la tesis (un componente empaqueta utilidades bajo un nombre, y las variantes lo configuran por props en vez de por copiar código), da el mapa de las ocho lecciones, y ejecuta un primer teaser del patrón variants() —la mini-versión pedagógica de cva que usarás todo el módulo—. La lección 2 muestra por qué empaquetar utilidades en un componente (la fuente única). La 3 presenta el patrón de variantes y por qué le gana a las props booleanas. La 4 construye base + variants + defaultVariants. La 5 agrega los compound variants. La 6 conecta con cva real. La 7 abre la composición (slots, asChild). Y la 8 te pone a construir el Button de Mercado con variantes y aplicarlo al product-card.

Este módulo asume react-fundamentals: sabes que un componente recibe props y que renderiza JSX. Aquí no se re-enseña React —ni useState, ni eventos, ni cómo se monta un componente—; se estila y se varía. Todo lo ejecutable es lógica pura en Node (la resolución de clases), porque el navegador y JSX no corren en un agente: el JSX del componente y su cva(...) se muestran en los bloques (es lo que escribes), y la resolución de clases se ejecuta para que veas, medido, qué clases produce cada combinación de props.

Una analogía: un patrón de costura con tallas y colores

Piensa en cómo una marca de ropa produce una camiseta. Hay dos formas de encarar la variedad —tallas S/M/L/XL, colores negro/blanco/azul— y solo una escala.

La forma ingenua es coser una prenda distinta desde cero para cada caso: un molde para la camiseta negra chica, otro molde separado para la negra grande, otro para la azul chica… Con 4 tallas y 3 colores serían 12 moldes independientes. Si mañana decides cambiar el cuello, tienes que rehacer los 12 moldes a mano, y basta que se te escape uno para que la negra grande tenga un cuello distinto al resto. Es caro, es lento y garantiza inconsistencia.

La forma de un sistema es un solo patrón de costura con parámetros. Hay un molde base de la camiseta —las costuras, el cuello, las mangas—, y encima dos ejes que lo ajustan: un eje de talla (el molde se escala) y un eje de color (la misma tela en otro tinte). No coses 12 prendas distintas: tienes un patrón y le pides "talla L, color azul", y de ahí sale esa combinación. Cambiar el cuello es tocar el molde base una vez, y las 12 combinaciones lo heredan. Nunca hay una prenda con un cuello distinto "por accidente", porque solo existe un molde.

Un componente con variantes es exactamente ese patrón de costura. El Button tiene un molde base (las clases que todos los botones comparten: inline-flex, rounded-md, font-medium) y ejes que lo ajustan: variant (el "color" —primary, secondary, ghost—) y size (la "talla" —sm, md, lg—). No defines nueve botones distintos: defines un Button con dos ejes, y pides variant="primary" size="lg". Dos piezas más de la analogía cierran el módulo. La talla por defecto —la que la marca manda si no pides otra— es defaultVariants: si no pasas size, el Button asume md. Y ese detalle de que "la combinación rojo + XL lleva un bordado extra" que ninguna talla ni color por sí solo pide —una regla que vive en el cruce de dos ejes— es un compound variant: en Mercado, el botón primary y grande (el CTA principal) lleva una sombra extra que ni "primary" ni "lg" por separado justifican.

Guarda la imagen: un componente con variantes es un patrón de costura, no doce moldes sueltos. Base = el molde común; variants = los ejes (talla, color); defaultVariants = la talla por defecto; compound variant = el detalle extra de una combinación específica. Todo el módulo desarrolla esas cuatro piezas.

Ejemplo trabajado: un patrón, muchas combinaciones

El corazón del módulo es una función: dado un componente definido como una config (base + variants + defaultVariants) y unas props, resuelve la lista de clases final. Este teaser la muestra con lo mínimo. Definimos el Button de Mercado como una config —un molde base y dos ejes— y le pedimos tres combinaciones distintas. Fíjate en que no hay tres botones: hay una config y tres llamadas.

// L1 intro — teaser: de utilidades sueltas a un componente que las empaqueta con variantes.
// El componente ya no es una cadena fija de clases: es una funcion de props -> clases.

// variants() es una mini-version pedagogica de cva (la libreria real llega en L6).
function variants(config, props = {}) {
  const classes = config.base ? [config.base] : [];
  for (const axis of Object.keys(config.variants)) {
    const value = props[axis] ?? config.defaultVariants?.[axis];
    const cls = config.variants[axis]?.[value];
    if (cls) classes.push(cls);
  }
  return classes.join(' ');
}

// el Button de Mercado: un patron, muchas combinaciones.
const button = {
  base: 'inline-flex items-center justify-center rounded-md font-medium',
  variants: {
    variant: { primary: 'bg-primary text-white', ghost: 'bg-transparent text-primary' },
    size:    { md: 'text-base px-4 py-2', lg: 'text-lg px-6 py-3' },
  },
  defaultVariants: { variant: 'primary', size: 'md' },
};

console.log('=== un patron, muchas combinaciones ===\n');
console.log('primary lg  ->', variants(button, { variant: 'primary', size: 'lg' }));
console.log('(sin props) ->', variants(button, {}));
console.log('ghost       ->', variants(button, { variant: 'ghost' }));

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

=== un patron, muchas combinaciones ===

primary lg  -> inline-flex items-center justify-center rounded-md font-medium bg-primary text-white text-lg px-6 py-3
(sin props) -> inline-flex items-center justify-center rounded-md font-medium bg-primary text-white text-base px-4 py-2
ghost       -> inline-flex items-center justify-center rounded-md font-medium bg-transparent text-primary text-base px-4 py-2

Lee las tres líneas como el resumen del módulo. Las tres empiezan igual —inline-flex items-center justify-center rounded-md font-medium—: ese es el molde base, las clases que todo botón de Mercado comparte. Lo que cambia después sale de las props. La primera pidió variant="primary" size="lg" y recibió, encima de la base, las clases del primary (bg-primary text-white) y las del lg (text-lg px-6 py-3). La segunda no pidió nada ({}) y aun así recibió un botón completo: variant="primary" y size="md" salieron de defaultVariants —la talla por defecto—. La tercera pidió solo ghost y el size volvió a caer en su default md. Una config, tres combinaciones, tres cadenas de clases distintas, y cero copy-paste: no escribiste tres botones, describiste uno con ejes.

Fíjate en lo que este pequeño motor te ahorra. Sin él, "el botón ghost del filtro" es copiar la cadena del botón primary y cambiar bg-primary text-white por bg-transparent text-primary a mano, cruzando los dedos. Con él, es variant="ghost" —y la base, el tamaño y el default los pone el patrón—. Esa es la diferencia entre coser doce prendas y tener un patrón con dos ejes.

Una pregunta para cargar por el resto del módulo: si variant="primary" size="lg" es el CTA principal de Mercado y el diseño pide que esa combinación —y solo esa— lleve una sombra extra, ¿dónde pones esa clase? No puede ir en primary (el primary chico no la lleva) ni en lg (el ghost grande tampoco). Vive en el cruce de los dos ejes. (Adelanto: es un compound variant, y lo construyes en la lección 5.)

El mapa del módulo

Guarda esta ruta; es cómo cada lección construye una parte de la capa de componentes:

Idea                                       Lección   Concepto clave
─────────────────────────────────────────  ────────  ─────────────────────────────────────
De utilidades a componentes                L2        empaquetar la cadena de clases en UNA
                                                     fuente; evitar el drift del copy-paste
El patron de variantes                     L3        ejes (variant, size) por props; por que
                                                     le gana a las props booleanas que explotan
base + variants + defaults                 L4        molde base + ejes con opciones +
                                                     defaultVariants; resolver clases desde props
Compound variants                          L5        una clase extra para una COMBINACION
                                                     (primary + lg -> shadow-lg)
cva en la practica                         L6        class-variance-authority: la API real;
                                                     variants() es su mini-version pedagogica
Composicion sobre props                    L7        slots y asChild; componer en vez de mil
                                                     props; la UI no lleva logica de negocio
─────────────────────────────────────────  ────────  ─────────────────────────────────────
Proyecto: el Button de Mercado             L8        Button con variant x size + un compound,
                                                     aplicado al CTA del product-card

La frontera: qué NO entra en este módulo

Saber la frontera te evita mezclar lo que otras lecciones y guías cubren:

  • La mecánica de React —qué es un componente, cómo se define, useState, eventos, el flujo de props— es el prerequisito react-fundamentals. Aquí asumimos que un componente recibe props y renderiza JSX; lo que hacemos es estilarlo y variarlo. Si props, children o "componente" te suenan nuevos, ese es el módulo que falta, no este.
  • Responsive y dark mode —los prefijos md:/lg:, la variante dark:, cómo un componente responde al tamaño y al tema sin duplicarse— es el módulo 6. Aquí las variantes son de apariencia por props (variant, size); las de breakpoint y tema son allá.
  • Primitivas y librerías —el modelo shadcn/Radix, componentes sin estilo pero accesibles que tú vistes, asChild a fondo, foco y teclado— es el módulo 7. Aquí abrimos la composición como idea (lección 7) y nombramos asChild; el ecosistema de primitivas accesibles es el módulo siguiente.
  • La lógica de negocio del componente —qué pasa al hacer clic en "Add to cart", el estado del carrito, las llamadas al servidor— no es de esta guía: es react-fundamentals (el estado) y frontend-state-and-data (los datos). El Button de este módulo se ve de cierta forma según sus props; qué hace al presionarlo no es asunto de la capa de UI.

Errores comunes

Creer que un componente es "copiar la cadena de clases y ponerle un nombre". Qué pasa: se define un Button que devuelve una cadena fija de clases, y para el botón secundario se hace un SecondaryButton copiando y cambiando dos clases. Por qué pasa: "componente" suena a "trozo reutilizable", y copiar-con-nombre parece reutilizar. Cómo detectarlo: tienes Button, SecondaryButton, GhostButton, SmallButton… un componente por apariencia. Cómo corregirlo: un componente de sistema tiene un Button con ejes (variant, size) resueltos por props; las apariencias son valores de un eje, no componentes distintos. Duplicar el componente por variante es el error que este módulo entero existe para eliminar —lo desarmas de raíz en la lección 3—.

Meter la variación en props booleanas (isPrimary, isLarge, isGhost). Qué pasa: para variar el botón se agregan banderas booleanas, una por look. Por qué pasa: es lo primero que se le ocurre a cualquiera —"si es primario, pon estas clases"—. Cómo detectarlo: tu Button acepta isPrimary y isGhost a la vez, dos banderas que se contradicen y nada lo impide. Cómo corregirlo: usa ejes con opciones excluyentes (variant="primary" | "secondary" | "ghost"), donde por construcción solo hay un valor. La lección 3 mide cuántas combinaciones contradictorias generan las booleanas (spoiler: la mayoría) frente a cero de los ejes.

Esperar re-aprender React aquí. Qué pasa: alguien abre el módulo esperando entender props, estado o eventos. Por qué pasa: los componentes se sienten "de React", así que parece que aquí se enseñan. Cómo detectarlo: te trabas en qué es una prop, no en cómo estilar con ella. Cómo corregirlo: este módulo asume react-fundamentals; aquí las props son la entrada del motor de variantes, y lo que construimos es la resolución de clases. Si la mecánica de props te falta, repásala en su guía y vuelve —aquí la damos por sabida a propósito, para concentrarnos en el estilo—.

Ejercicios

Ejercicio 1 — Componente o copy-paste. Para cada situación, di si describe un componente con variantes (un patrón con ejes) o un copy-paste con nombre (moldes duplicados):

  • (a) Un Button que recibe variant y size y de ahí resuelve sus clases.
  • (b) Tres archivos: PrimaryButton, SecondaryButton, GhostButton, cada uno con su cadena de clases fija.
  • (c) Un ProductCard que recibe product y siempre se ve igual.
  • (d) Un Badge con una prop tone (success / warning / error) que elige el color.
Ver solución
  • (a) Componente con variantes. Un patrón con dos ejes (variant, size) resueltos por props; una fuente, muchas apariencias.
  • (b) Copy-paste con nombre. Tres moldes duplicados, uno por apariencia. Un cambio al look común hay que hacerlo tres veces, y basta olvidar uno para que se desincronicen. Es justo lo que el módulo reemplaza.
  • (c) Componente, sin variantes (todavía). Recibe datos (product) pero su apariencia no varía. Es un componente válido; simplemente aún no tiene ejes de estilo. Si mañana necesitas una versión "compacta" para el mini-carrito, ahí aparece una variante.
  • (d) Componente con variantes. tone es un eje con tres opciones excluyentes; el mismo patrón del variant del Button. Una fuente, tres apariencias controladas.

La pregunta guía: ¿la variación entra por una prop de un patrón único, o por duplicar el componente? Lo primero es sistema; lo segundo es la estantería que crece.

Ejercicio 2 — Predice la resolución. El teaser definió el Button con defaultVariants: { variant: 'primary', size: 'md' }. Sin correr nada, di qué clases —después de la base— recibiría variants(button, { size: 'lg' }) (pasas solo size, no variant).

Ver solución

Recibiría, encima de la base: bg-primary text-white (del variant) y text-lg px-6 py-3 (del size). La clave está en variant: no lo pasaste, así que el motor cae en el default primary y toma sus clases. size sí lo pasaste (lg), así que ignora el default md y usa lg. La cadena completa sería inline-flex items-center justify-center rounded-md font-medium bg-primary text-white text-lg px-6 py-3. La lección: cada eje se resuelve por separado —lo que pasas gana, lo que omites cae en su default—; no es "todo o nada".

Ejercicio 3 — Ubica la pieza en su capa. El módulo 1 te dio las cuatro capas (tokens → utilidades → componentes → patrones). Para cada afirmación sobre este módulo 5, di si es verdadera o falsa y por qué:

  • (a) "Este módulo construye la capa de componentes, sobre las utilidades del módulo 3."
  • (b) "Las variantes reemplazan a las utilidades: ya no se usan clases de Tailwind."
  • (c) "Aquí se aprende cómo un componente maneja su estado con useState."
Ver solución
  • (a) Verdadera. Es exactamente el lugar del módulo: la capa 3. Un componente empaqueta utilidades (capa 2, módulo 3) que consumen tokens (capa 1, módulo 2) con valores de escalas (módulo 4). No reemplaza nada; se apoya en todo lo anterior.
  • (b) Falsa. Las variantes organizan las utilidades, no las reemplazan. Cada opción de una variante (primary → bg-primary text-white) es un conjunto de utilidades de Tailwind; el patrón de variantes solo decide cuáles aplicar según las props. Sigues escribiendo utilidades —ahora dentro de la config del componente—.
  • (c) Falsa. El estado (useState, eventos) es react-fundamentals, el prerequisito. Aquí el componente se estila y se varía por props; qué hace al interactuar no es asunto de la capa de UI de este módulo. La frontera lo marca explícito.

Resumen y siguiente paso

En esta lección instalaste la tesis del módulo 5: un componente empaqueta utilidades bajo un nombre para tener una sola fuente de verdad, y las variantes lo configuran por props —no por copiar código—. Con el patrón de costura viste la distinción que estructura el módulo: un sistema no cose doce prendas distintas, tiene un molde base y ejes (talla, color) que lo ajustan, con una talla por defecto y algún detalle extra en cruces específicos. Y lo comprobaste ejecutando: una sola config del Button de Mercado resolvió tres combinaciones de props (primary lg, defaults, ghost) en tres cadenas de clases distintas, todas compartiendo el mismo molde base y sin un solo copy-paste.

Antes de avanzar deberías poder: explicar por qué un componente con variantes le gana a duplicar el componente por apariencia; nombrar las cuatro piezas del patrón (base, variants, defaultVariants, compound); y ubicar este módulo como la capa 3 (componentes) sobre las utilidades del módulo 3.

La lección 2 baja al primer paso concreto: de utilidades sueltas a un componente. Antes de las variantes hay que ver por qué empaquetar la cadena de clases en un componente vale la pena —qué problema resuelve tener una sola fuente de verdad— midiendo el "drift" que aparece cuando la misma cadena se copia por medio storefront. Es el cimiento sobre el que las variantes tienen sentido.

Recursos

  • cva (class-variance-authority), documentación oficial — cva.style/docs. El patrón que este módulo enseña, en su forma real: base, variants, defaultVariants, compoundVariants. Nuestro variants() es su mini-versión pedagógica. En inglés.
  • shadcn/ui, "Components" — ui.shadcn.com/docs/components/button. Un Button de producción construido con cva sobre tokens; el espejo real del que construyes en el proyecto. En inglés.
  • React, "Passing Props to a Component" — react.dev/learn/passing-props-to-a-component. El prerequisito de react-fundamentals: cómo un componente recibe props —la entrada del motor de variantes—. En inglés.
  • Tailwind CSS, "Styling with utility classes" — tailwindcss.com/docs/styling-with-utility-classes. Cómo reutilizar estilos empaquetándolos en componentes; el punto de partida de este módulo. En inglés.