Módulo 5: Components With Variants
Proyecto: el Button de Mercado con variantes
Descripción
Las siete lecciones anteriores te enseñaron a entender la capa de componentes por partes: por qué empaquetar utilidades en una fuente única (L2), por qué modelar la apariencia como ejes y no como booleanas (L3), cómo el motor base + variants + defaultVariants resuelve las clases (L4), cómo un compound cubre el cruce de dos ejes (L5), cómo todo eso es cva (L6), y cuándo componer en vez de agregar props (L7). Este proyecto te pone a construirlo y verificarlo. Vas a definir, en un solo archivo de Node, el Button de Mercado completo —base, dos ejes (variant × size), defaults, y un compound (primary + lg → shadow-lg)— y luego comprobar la lista de clases resuelta para varias combinaciones de props, incluyendo la que dispara el compound. Después lo montas en el CTA del product-card, cerrando la capa de componentes sobre las de tokens, utilidades y escalas de los módulos anteriores.
El entregable tiene dos partes, y las dos importan. La parte 1 es la verificación del motor: correr variants() sobre el Button con cuatro combinaciones de props y ver, medido, la cadena de clases que cada una produce —el CTA con su sombra, el botón pelado cayendo en defaults, el ghost, el secondary chico—. La parte 2 es la aplicación: montar ese Button como el CTA "Add to cart" del product-card, con la combinación del CTA principal (primary lg), y ver la lista de clases que el marcado recibe. Juntas prueban que dominas el módulo: no solo qué es un componente con variantes, sino cómo definir el Button de un componente real y verificar qué clases resuelve cada combinación antes de que se repita por todo el storefront.
Conexión con el módulo. Este proyecto es la síntesis de las siete lecciones. Empaqueta las utilidades en una fuente única (L2), modela la apariencia con ejes (L3), resuelve con base + variants + defaultVariants (L4), agrega el compound del CTA (L5), con la forma exacta de cva (L6), y lo aplica al product-card que en el módulo 3 vestiste con utilidades sueltas —ahora su botón sale de un componente con variantes—. Es también el cierre de la capa 3 del sistema: los componentes que el módulo 6 hará responsive y dark, y que el módulo 7 conectará con las primitivas accesibles. Al terminar, el Button de Mercado deja de ser una cadena de clases suelta y se vuelve un componente con variantes verificadas.
Qué vas a construir
El entregable es un archivo de Node —button.js— que, al correrse, imprime dos bloques:
- Las combinaciones de props → clases resueltas (parte 1): para cuatro combinaciones del
Button(primary lg,{},ghost,secondary sm), la lista de clases final quevariants()produce —con el compound disparándose solo enprimary lg—. - El CTA del
product-card(parte 2): el marcado del botón "Add to cart" montado con<Button variant="primary" size="lg">, mostrando la lista de clases que el<button>recibe.
Como en todo el módulo, el JSX del componente y su cva(...) se muestran —es lo que escribes—, y la resolución de clases se ejecuta en Node. El navegador y JSX no corren en un agente, así que mostramos lo que se escribe y ejecutamos lo que lo valida. Recuerda que variants() es una mini-versión pedagógica de cva: en producción usarías class-variance-authority (L6), pero la lógica de resolución es idéntica —y ejecutarla es lo que prueba que entiendes qué clases produce cada prop—.
Una analogía: la ficha técnica del componente antes de mandarlo a producción
Una fábrica de muebles que va a producir su silla estrella —la que se repetirá en miles de salas— no manda el molde a la línea de producción sin antes emitir su ficha técnica: una hoja que lista, para cada variante de la silla (roble/nogal, alta/baja), exactamente qué piezas lleva. La ficha no es decoración burocrática: es la prueba de que el molde parametrizado produce lo correcto para cada combinación antes de que la prensa lo repita mil veces. Si la variante "nogal alta" debía llevar un refuerzo extra y la ficha muestra que no lo lleva, se corrige el molde —no la silla número mil—.
Tu proyecto es esa ficha técnica. La parte 1 lista, para cada combinación de props del Button, exactamente qué clases lleva —la prueba de que el motor de variantes produce lo correcto—. Y encuentra, como toda buena ficha, si el detalle especial está donde debe: la variante primary lg (el CTA destacado) debe llevar shadow-lg, y la ficha lo confirma; las demás no deben, y la ficha lo confirma también. La parte 2 toma la variante verificada y la monta en su sitio —el CTA del product-card—, como la fábrica que, una vez validada la ficha, instala la silla en la sala modelo. Un fabricante que emite su ficha antes de producir repite con confianza; uno que manda el molde sin verificar descubre el refuerzo faltante en la silla mil. Tú verificas ejecutando, antes de que el Button se repita por todo Mercado.
Especificación del proyecto
Tu button.js debe cumplir esto:
Parte 1 — el Button y su ficha de clases.
- Define
variants(config, props)que implemente el patrón cva:base+variants(ejes) +defaultVariants+compoundVariants. - Define la config del
Buttonde Mercado:basecomún; ejevariantconprimary/secondary/ghost; ejesizeconsm/md/lg;defaultVariants={ variant: 'primary', size: 'md' }; y un compound{ variant: 'primary', size: 'lg', class: 'shadow-lg' }. - Corre
variants()sobre al menos estas cuatro combinaciones e imprime la cadena resuelta de cada una:{ variant: 'primary', size: 'lg' },{},{ variant: 'ghost' },{ variant: 'secondary', size: 'sm' }.
Parte 2 — el CTA del product-card.
- Resuelve las clases del CTA con
variants(buttonConfig, { variant: 'primary', size: 'lg' }). - Imprime el marcado del
product-cardcon su<button class="...">Add to cart</button>usando esa cadena.
Restricciones (las convenciones del módulo):
- Todo identificador, prop, clase, config y valor en inglés; solo comentarios y textos en español.
- Sin dependencias: puro JavaScript, corre con
node button.js. - Salida literal y reproducible.
- El compound solo debe aparecer en
primary lg; las clases comunes van enbase, no repetidas en cada variante.
Solución de referencia
Aquí está una solución completa que cumple la especificación. Estúdiala después de intentarlo por tu cuenta; el valor del proyecto está en construirlo tú, no en leer la respuesta:
// L8 proyecto — el Button de Mercado con variantes (variant x size + un compound),
// aplicado al CTA del product-card. variants() es una mini-version pedagogica de cva.
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);
}
for (const compound of config.compoundVariants ?? []) {
const { class: cls, ...conditions } = compound;
if (Object.entries(conditions).every(([axis, val]) => selected[axis] === val) && cls) classes.push(cls);
}
return classes.join(' ');
}
const buttonConfig = {
base: 'inline-flex items-center justify-center rounded-md font-medium transition-colors',
variants: {
variant: {
primary: 'bg-primary text-white',
secondary: 'bg-surface text-primary border border-primary',
ghost: 'bg-transparent text-primary',
},
size: { sm: 'text-sm px-3 py-1', md: 'text-base px-4 py-2', lg: 'text-lg px-6 py-3' },
},
defaultVariants: { variant: 'primary', size: 'md' },
// el CTA principal grande de Mercado lleva sombra extra.
compoundVariants: [{ variant: 'primary', size: 'lg', class: 'shadow-lg' }],
};
const button = (props) => variants(buttonConfig, props);
// 1) las combinaciones de props -> lista de clases resuelta.
console.log('=== 1) Button de Mercado: props -> clases resueltas ===\n');
const cases = [
{ variant: 'primary', size: 'lg' }, // el CTA de la card: dispara el compound
{}, // usa defaults: primary + md
{ variant: 'ghost' }, // ghost + md (default)
{ variant: 'secondary', size: 'sm' }, // secondary + sm
];
for (const props of cases) console.log(JSON.stringify(props).padEnd(36) + ' -> ' + button(props));
// 2) el CTA del product-card usa <Button variant="primary" size="lg">.
console.log('\n=== 2) el product-card monta su CTA con el Button ===\n');
const cta = button({ variant: 'primary', size: 'lg' });
console.log('<article class="product-card">');
console.log(' ...');
console.log(' <button class="' + cta + '">Add to cart</button>');
console.log('</article>');
Y así se ve el Button de Mercado como componente real, con cva (esto se muestra —es tu entregable de marcado—):
// Button.jsx — el Button de Mercado con cva, listo para el storefront.
import { cva } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md font-medium transition-colors',
{
variants: {
variant: {
primary: 'bg-primary text-white',
secondary: 'bg-surface text-primary border border-primary',
ghost: 'bg-transparent text-primary',
},
size: { sm: 'text-sm px-3 py-1', md: 'text-base px-4 py-2', lg: 'text-lg px-6 py-3' },
},
defaultVariants: { variant: 'primary', size: 'md' },
compoundVariants: [{ variant: 'primary', size: 'lg', class: 'shadow-lg' }],
}
);
function Button({ variant, size, className, ...props }) {
return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />;
}
// y el product-card monta su CTA con el Button (no copia clases):
// <article className="...">
// ...
// <Button variant="primary" size="lg">Add to cart</Button>
// </article>
Qué esperar. Al correr node button.js, la salida es exactamente esta:
=== 1) Button de Mercado: props -> clases resueltas ===
{"variant":"primary","size":"lg"} -> inline-flex items-center justify-center rounded-md font-medium transition-colors bg-primary text-white text-lg px-6 py-3 shadow-lg
{} -> inline-flex items-center justify-center rounded-md font-medium transition-colors bg-primary text-white text-base px-4 py-2
{"variant":"ghost"} -> inline-flex items-center justify-center rounded-md font-medium transition-colors bg-transparent text-primary text-base px-4 py-2
{"variant":"secondary","size":"sm"} -> inline-flex items-center justify-center rounded-md font-medium transition-colors bg-surface text-primary border border-primary text-sm px-3 py-1
=== 2) el product-card monta su CTA con el Button ===
<article class="product-card">
...
<button class="inline-flex items-center justify-center rounded-md font-medium transition-colors bg-primary text-white text-lg px-6 py-3 shadow-lg">Add to cart</button>
</article>
Lee la salida como el entregable que es, en sus dos partes.
La parte 1 es tu ficha técnica del Button, una fila por combinación. La primera (primary lg) es el CTA destacado: base + primary + lg + shadow-lg —el compound se disparó, y ahí está la sombra al final, exactamente donde debe—. La segunda ({}) es el botón pelado: sin props, cayó en los defaults (primary md) y salió un botón completo y sensato —<Button> renderiza algo con sentido, no un botón a medio vestir—. La tercera (ghost) mezcla lo pasado (ghost → bg-transparent text-primary) con el default (md), resolviendo cada eje por su cuenta. La cuarta (secondary sm) muestra el secondary con su borde (bg-surface text-primary border border-primary) en tamaño chico. Fíjate en lo que la ficha confirma: shadow-lg aparece solo en la primera fila —ni el botón pelado (que es primary pero md), ni el ghost, ni el secondary la llevan—. El detalle especial está exactamente en la combinación que lo pide, y en ninguna otra. Eso es lo que verificas antes de producir.
La parte 2 es la variante verificada, montada en su sitio. El CTA del product-card usa <Button variant="primary" size="lg">, y el <button> recibe la cadena completa de la primera fila —base, primary, lg y la sombra—. Compáralo con el módulo 3, donde el mismo botón del product-card llevaba sus clases sueltas en el marcado (flex p-4 rounded-md bg-primary text-lg): ahora esas clases viven en la config del Button, y el product-card solo escribe <Button variant="primary" size="lg">. La capa 3 (componentes) sobre la capa 2 (utilidades) sobre la 1 (tokens): el botón del storefront ya no copia clases, las consume de un componente con variantes.
Junta las dos partes y tienes la capa de componentes de Mercado cerrada: el Button definido como un patrón con ejes y un compound (parte 1, verificado con su ficha de clases) y montado en el product-card real (parte 2). Es la teoría del módulo convertida en un componente de sistema, verificado antes de repetirse.
Extensiones (opcionales, para ir más lejos)
Si quieres exprimir el proyecto, prueba estas ampliaciones —cada una refuerza una lección del módulo—:
- Agrega un eje
state(L4). Suma un tercer eje alButton—state: { default: '', disabled: 'opacity-50 cursor-not-allowed' }— con su default. Verifica que{ variant: 'primary', state: 'disabled' }agrega las clases de deshabilitado sin tocar los otros ejes. Un eje nuevo, cero contradicciones. - Agrega un segundo compound (L5). Haz que
variant: 'ghost', size: 'sm'lleveunderline. Corre las combinaciones y confirma que el nuevo compound se dispara solo en ghost + sm, y que el deprimary + lgsigue intacto —los compounds no se pisan—. - Modela el
asChilddel CTA (L7). Reusa elrenderButtonde la lección 7 y monta el CTA "Ver producto" como un<a>con el estilo del Button, en vez de un<button>. Verás la misma variante prestando su estilo a otro elemento, sin una prop nueva. - Detecta el drift que el componente elimina (L2). Antes de tener el
Button, modela el CTA copiado enProductCard,ProductPageyMiniCartcon una copia drifteada, cuenta las versiones distintas, y muestra que al pasar al componente colapsan a una. Cierra el arco del módulo: de copy-paste a fuente única con variantes.
Ninguna es necesaria para cumplir el proyecto; todas son buen entrenamiento para el resto de la guía.
Errores comunes
Meter shadow-lg en variant.primary en vez de en un compound. Qué pasa: la sombra se pone en la opción primary del eje variant, así que el botón primary chico (el segundo caso sería primary md) también la hereda. Por qué pasa: la sombra "va con el CTA", que es primary, y meterla en variant.primary es lo más directo. Cómo detectarlo: en tu ficha de clases, shadow-lg aparece en más de una fila —en el botón pelado (primary md) además del CTA (primary lg)—. Cómo corregirlo: la sombra pertenece al cruce primary + lg, no a todo primary; va en compoundVariants, como en la solución. La prueba está en la ficha: shadow-lg debe aparecer en exactamente una fila (primary lg). Si aparece en dos, la metiste en un eje —el error que la lección 5 existe para desarmar—.
Olvidar defaultVariants y romper el botón pelado. Qué pasa: se define base y variants pero no defaultVariants, y el caso {} (segunda fila de la ficha) sale solo con la base —sin color ni tamaño—. Por qué pasa: al construir el Button siempre se prueba pasando props, así que el caso sin props nunca se ve hasta que aparece en el marcado. Cómo detectarlo: la fila de {} en tu salida no tiene bg-primary ni text-base —solo la base—. Cómo corregirlo: define defaultVariants: { variant: 'primary', size: 'md' }. Es lo que hace que <Button>Add to cart</Button> —sin props— renderice un botón completo. La segunda fila de la ficha es la prueba de que tus defaults funcionan; si sale a medio vestir, faltan.
Inventar la salida en vez de ejecutarla. Qué pasa: se escribe el "Qué esperar" a mano, armando las cadenas de clases mentalmente. Por qué pasa: parece que uno "ya sabe" qué clases va a dar cada combinación. Cómo detectarlo: tu salida reportada no coincide carácter por carácter con la real —una clase en distinto orden, un shadow-lg de más o de menos, una variante mal resuelta—. Cómo corregirlo: corre node button.js de verdad y pega su salida literal. Todo el módulo se para sobre la honestidad de "esto es lo que la máquina imprimió". Una cadena de clases mal armada a mano es exactamente el tipo de error que verificar la ficha existe para atrapar; no lo reintroduzcas en la verificación.
Rúbrica de autoevaluación
Marca cada punto; si todos están, dominaste el módulo:
- Motor completo.
variants()implementabase+variants+defaultVariants+compoundVariants. - Ejes, no booleanas. El
Buttonse configura convariantysize(props excluyentes), no con banderas booleanas que se contradicen. - Base sin repetir. Las clases comunes viven en
baseuna vez, no duplicadas en cada opción devariant. - Defaults que funcionan. El caso
{}resuelve un botón completo (primary md), no uno a medio vestir. - Compound en su sitio.
shadow-lgaparece en exactamente una fila de la ficha (primary lg), en ninguna otra. - Montado en el product-card. El CTA "Add to cart" usa
<Button variant="primary" size="lg">y recibe la cadena verificada. - Salida literal. Corriste
node button.jsde verdad y la salida coincide con lo que reportas —no inventaste el output—.
Resumen y cierre del módulo
Con este proyecto cerraste el módulo 5 haciendo, no solo leyendo. Definiste el Button de Mercado con variantes —base, dos ejes (variant × size), defaultVariants y un compound (primary + lg → shadow-lg)— y verificaste su ficha de clases: cuatro combinaciones, con la sombra apareciendo solo en el CTA destacado y en ninguna otra. Luego lo montaste en el product-card, cambiando las clases sueltas del módulo 3 por un <Button variant="primary" size="lg"> que consume el componente. Con el fabricante que emite su ficha técnica antes de producir viste por qué se hace así: verificas qué clases lleva cada variante antes de que el botón se repita por todo el storefront, y confirmas que el detalle especial está exactamente donde debe.
Da un paso atrás y mira lo que aprendiste en las ocho lecciones. Sabes que un componente empaqueta utilidades en una fuente única que elimina el drift del copy-paste (L2). Sabes que la apariencia se modela como ejes —props excluyentes— y no como booleanas que explotan en contradicciones (L3). Sabes que el motor es base + variants + defaultVariants, que resuelve las clases concatenando la base y la opción elegida de cada eje (L4). Sabes que un compound variant cubre el cruce de dos ejes sin ensuciarlos (L5). Sabes que todo eso es cva, la librería estándar, con cn/tailwind-merge como su compañero para los choques (L6). Y sabes cuándo componer —slots, asChild— en vez de acumular props, y que la lógica de negocio no vive en la UI (L7). La capa de componentes, sobre los cimientos de tokens, utilidades y escalas de los módulos 2–4, ya no es teoría: el Button de Mercado es un componente de sistema, verificado.
Lo que no hiciste todavía —a propósito— es hacer ese Button responsive y dark. Definiste sus variantes de apariencia por props, pero no cómo cambia con el tamaño de la pantalla (¿el CTA es size="md" en móvil y size="lg" en desktop?) ni con el tema (¿el bg-primary del botón se ve bien en modo oscuro?). Eso empieza ahora. El módulo 6 — Responsive y dark mode toma los componentes que construiste y les da las variantes de breakpoint (md:, lg:) y de tema (dark:) que el sistema resuelve por tokens, sin duplicar el componente. El Button con variantes que verificaste aquí empieza a responder al tamaño y al tema —cerrando el sistema de diseño que estas capas fueron construyendo—.
Recursos
- cva (
class-variance-authority), documentación oficial — cva.style/docs. La librería real que tuvariants()emula; la que usarías para elButtonde este proyecto en producción. En inglés. - shadcn/ui, "Button" — ui.shadcn.com/docs/components/button. Un
Buttonde producción convariant×sizesobre cva + tokens; el espejo real de tu entregable, listo para comparar. En inglés. - React, "Passing Props to a Component" — react.dev/learn/passing-props-to-a-component. El prerequisito de
react-fundamentals: cómo elButtonrecibevariantysizecomo props —la entrada del motor de variantes—. En inglés. - Tailwind CSS, "Styling with utility classes" (sección "Reusing styles") — tailwindcss.com/docs/styling-with-utility-classes. Cómo empaquetar utilidades en un componente reutilizable; el paso de clases sueltas (módulo 3) a componente con variantes (este). En inglés.