Módulo 8: Project Build Mercados Design System
Construye el Button y el ProductCard con variantes
Descripción
Tienes, después de cuatro lecciones, todo lo que un componente con variantes necesita: tokens que resuelven color por tema (lección 2), utilidades conectadas a esos tokens vía un colorMap (lección 3), y la certeza medida de que esos colores se leen (lección 4) — incluyendo el fix de button.text que la auditoría encontró. Esta lección empaqueta todo eso en dos componentes reales: el Button de Mercado, ya con el color corregido, y el ProductCard, que hasta ahora tenía sus utilidades sueltas en el marcado (lección 3) y ahora las empaqueta en un componente configurable.
Lo nuevo de esta lección no es el motor de variantes — es variants(), exactamente el del módulo 5, sin un carácter de diferencia — sino verlo aplicado dos veces, a dos componentes distintos, con configuraciones distintas. El Button tiene dos ejes (variant × size) y un compound. El ProductCard tiene un eje (layout) sin compound. La misma función, dos configs, dos resultados — la prueba de que variants() no es "el motor del Button", es un patrón general que cualquier componente del sistema puede usar.
Conexión con el módulo. Esta lección integra el módulo 5 completo — el motor base + variants + defaultVariants + compoundVariants, la disciplina de ejes en vez de booleanas — y consume directamente los resultados de las lecciones 2 a 4: el Button usa bg-primary text-on-primary (el par que la lección 4 verificó), y ambos componentes usan las utilidades que la lección 3 conectó a tokens. Es también la preparación directa de la lección 6: los componentes que aquí quedan con sus clases resueltas son los que la próxima lección hace responsive y dark.
Una analogía: la ficha técnica, ahora para dos productos de la misma línea
En el módulo 5, la ficha técnica era de un solo producto: la silla, con sus variantes de madera y altura. Una fábrica de muebles real no fabrica un solo producto — fabrica una línea: la silla y la mesa que hace juego, cada una con sus propias variantes, pero las dos usando el mismo proceso de diseño paramétrico y la misma calidad de acabado. La mesa no reinventa cómo se documentan las variantes — usa la misma plantilla de ficha técnica que la silla, rellenada con sus propios ejes (alto/bajo para la silla, dos/cuatro/seis plazas para la mesa)—. Eso es lo que esta lección hace: el Button y el ProductCard son dos productos de la misma línea de Mercado, y los dos pasan por la misma plantilla — variants() —, cada uno con sus propios ejes.
Ejemplo trabajado: dos configs, un solo motor
Primero, el motor — idéntico al del módulo 5:
// components.js — variants() del modulo 5, aplicado a DOS componentes de Mercado.
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(' ');
}
// ---------- Button: dos ejes (variant x size) + un compound. El texto ya corregido en L4. ----------
const buttonConfig = {
base: 'inline-flex items-center justify-center rounded-md font-medium transition-colors',
variants: {
variant: {
primary: 'bg-primary text-on-primary', // FIX de la L4: token, no "text-white" fijo
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' }],
};
const button = (props) => variants(buttonConfig, props);
console.log('=== 1) Button de Mercado: props -> clases resueltas ===\n');
const buttonCases = [
{ variant: 'primary', size: 'lg' }, // el CTA: dispara el compound, y lleva el texto ya arreglado
{}, // usa defaults: primary + md
{ variant: 'ghost' }, // ghost + md (default)
{ variant: 'secondary', size: 'sm' }, // secondary + sm
];
for (const props of buttonCases) console.log(JSON.stringify(props).padEnd(36) + ' -> ' + button(props));
// ---------- ProductCard: UN eje (layout), sin compound. El mismo motor, otra config. ----------
const cardConfig = {
base: 'flex flex-col rounded-md bg-surface text-foreground',
variants: {
layout: { default: 'p-4 gap-2', compact: 'p-2 gap-1' },
},
defaultVariants: { layout: 'default' },
};
const card = (props) => variants(cardConfig, props);
console.log('\n=== 2) ProductCard de Mercado: el MISMO variants() aplicado a otro componente ===\n');
const cardCases = [{}, { layout: 'compact' }];
for (const props of cardCases) console.log(JSON.stringify(props).padEnd(20) + ' -> ' + card(props));
Qué esperar. Al correr node components.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-on-primary text-lg px-6 py-3 shadow-lg
{} -> inline-flex items-center justify-center rounded-md font-medium transition-colors bg-primary text-on-primary 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) ProductCard de Mercado: el MISMO variants() aplicado a otro componente ===
{} -> flex flex-col rounded-md bg-surface text-foreground p-4 gap-2
{"layout":"compact"} -> flex flex-col rounded-md bg-surface text-foreground p-2 gap-1
Lee el primer bloque comparándolo con el módulo 5: la única diferencia visible es text-on-primary donde antes decía text-white — en la primera fila (primary lg), en la segunda ({}, que cae en primary md por default). El compound (shadow-lg) sigue apareciendo solo en la primera fila, exactamente igual que antes — el fix de la lección 4 cambió el color, no la lógica de variantes, y las dos son independientes: puedes corregir un color sin tocar el motor, y puedes cambiar el motor sin tocar los colores. Esa independencia es, en sí misma, la prueba de que el sistema está bien separado en capas.
El segundo bloque muestra algo que el módulo 5 no llegó a mostrar: el mismo variants(), con una config completamente distinta (un componente distinto, un eje distinto, sin compound), produce una ProductCard con dos densidades. La versión default lleva p-4 gap-2 (la que usaste en las lecciones 2 a 4); la compact lleva p-2 gap-1 — útil, por ejemplo, para una vista de catálogo con más productos por fila. Fíjate en que base no incluye ningún padding ni gap: viven dentro de cada opción del eje layout, no fuera — si p-4 gap-2 estuviera en base y compact agregara p-2 gap-1 encima, tendrías las dos declaraciones de padding en la misma cadena, el mismo problema de conflicto que el módulo 3 te enseñó a leer con la cascada. Aquí no hay conflicto porque cada opción del eje es excluyente — solo una entra en la cadena final—.
Profundización: cuándo un componente necesita variants() y cuándo no
El ProductCard de esta lección tiene un eje de variantes (layout), pero no tiene una variante para el badge de oferta — ese sigue siendo, como en la lección 3, un {product.onSale && <span>...} en el JSX, una decisión de React, no de cva. Es una distinción que vale la pena marcar: variants() es para apariencia que se elige por props al montar el componente — layout="compact" es una decisión de diseño, tan válida como layout="default"—. El badge no es una variante de apariencia, es contenido condicional: existe o no existe según si el producto está en oferta, no según una preferencia estética de quien usa el componente. Meter el badge dentro de variants()(por ejemplo, un ejebadge: { none, sale }) forzaría a tratar como "estilo elegible" algo que en realidad es "dato del producto" — la distinción que el módulo 5, lección 7 (composición sobre props) ya cubrió. La regla práctica: si la pregunta es "¿qué tan grande/de qué color/qué variante visual quiero?", es un eje de variants()`. Si la pregunta es "¿este dato existe o no?", es una condición de render.
Errores comunes
Repetir p-4 gap-2 en base y en default. Qué pasa: se escribe base: '... p-4 gap-2' y además variants.layout.default: 'p-4 gap-2', duplicando las clases. Por qué pasa: parece más explícito escribir el valor por defecto dos veces — una vez "por si acaso" en la base, otra vez en la opción default del eje. Cómo detectarlo: la cadena resuelta para {} tiene p-4 (o gap-2) dos veces, y aunque el navegador simplemente ignora la clase duplicada, la config es más difícil de leer y de mantener. Cómo corregirlo: lo que varía por eje vive solo en las opciones del eje; base lleva solo lo que es común a todas las variantes (flex flex-col rounded-md bg-surface text-foreground, en el caso del ProductCard). Si dudas si algo va en base o en una opción, pregúntate: ¿esta clase cambia si elijo otra opción del eje? Si sí, va en la opción, no en base.
Olvidar que el fix de contraste de la L4 también aplica a secondary. Qué pasa: se corrige variant.primary con text-on-primary, pero variant.secondary (bg-surface text-primary border border-primary) queda igual — y como su fondo es color.surface (blanco/negro, ya auditado con AAA para card.text), en este caso particular no hay bug, pero es fácil pensar que "ya audité el Button" cuando solo se auditó una variante. Cómo detectarlo: revisa qué par de color audita realmente la lección 4 — es button.text/button.bg, que corresponde específicamente a la variante primary (bg-primary), no a secondary ni a ghost, que usan bg-surface/bg-transparent. Cómo corregirlo: cada combinación real de fondo y texto que un componente puede mostrar necesita su propia entrada en la auditoría de contraste, no solo la variante que se probó. Por suerte, secondary y ghost reutilizan pares ya auditados en otro lugar del sistema (text-primary sobre surface, del ejercicio 1 de la lección 4) — pero eso hay que verificarlo, no asumirlo.
Meter contenido condicional (el badge) dentro de variants(). Qué pasa: se agrega un eje badge: { none: '', sale: 'ring-2 ring-danger' } al ProductCard, tratando la presencia del badge como una variante de apariencia. Por qué pasa: como el motor ya está ahí, parece natural usarlo para todo lo que cambia visualmente. Cómo detectarlo: el componente empieza a necesitar lógica adicional para decidir cuándo pasar badge="sale" — lógica que vive fuera del componente, duplicando la misma condición (product.onSale) que ya existe para renderizar el badge en sí. Cómo corregirlo: el badge se renderiza condicionalmente en el JSX ({product.onSale && <Badge>Sale</Badge>}), como en la lección 3; variants() queda reservado para las decisiones de apariencia que quien usa el componente elige a propósito (layout="compact"), no para datos que el componente ya recibe y debe reflejar.
Ejercicios
Ejercicio 1 — Agrega una tercera variante de layout. Mercado quiere una versión featured del ProductCard, para el producto destacado de la página principal, con más padding que default: p-8 gap-4. Agrégala a cardConfig y ejecuta card({ layout: 'featured' }).
Ver solución
const cardConfig = {
base: 'flex flex-col rounded-md bg-surface text-foreground',
variants: {
layout: {
default: 'p-4 gap-2',
compact: 'p-2 gap-1',
featured: 'p-8 gap-4',
},
},
defaultVariants: { layout: 'default' },
};
console.log(card({ layout: 'featured' }));
// flex flex-col rounded-md bg-surface text-foreground p-8 gap-4
Una opción más en el eje existente — ni una línea de lógica nueva, ni un componente FeaturedProductCard aparte. El mismo patrón que el Button con sus tres variant y tres size: agregar una opción es agregar una entrada al objeto de configuración.
Ejercicio 2 — Predice el compound que falta. Si quisieras que cualquier ProductCard featured (sin importar si además tiene onSale) llevara una sombra (shadow-lg), ¿lo resolverías con un compoundVariants o metiendo shadow-lg directo en variants.layout.featured? Justifica con lo que aprendiste en el módulo 5.
Ver solución
Directo en variants.layout.featured, no en un compound. Los compoundVariants existen para el cruce de dos o más ejes (como primary + lg → shadow-lg en el Button, que depende de dos props a la vez). Si shadow-lg debe aparecer cada vez que layout === 'featured', sin condición sobre ningún otro eje, es una regla de un solo eje — pertenece directamente a la opción featured: featured: 'p-8 gap-4 shadow-lg'. Usar un compoundVariants con una sola condición ({ layout: 'featured', class: 'shadow-lg' }) funcionaría también, pero es una vuelta innecesaria — el módulo 5 (lección 5, "Errores comunes") ya advirtió sobre el caso inverso (meter en un eje algo que pertenece a un compound); este es el error simétrico: usar un compound para algo que pertenece a un eje.
Ejercicio 3 — Verifica el orden de las clases del CTA. Sin correr nada, escribe la cadena completa que resuelve button({ variant: 'primary', size: 'lg' }), respetando el orden exacto en que variants() concatena: base, luego cada eje en el orden en que aparece en config.variants, luego los compounds.
Ver solución
inline-flex items-center justify-center rounded-md font-medium transition-colors bg-primary text-on-primary text-lg px-6 py-3 shadow-lg
El orden es: base completo primero (siete clases), después el eje variant (que aparece antes que size en config.variants, así que bg-primary text-on-primary va antes que text-lg px-6 py-3), y al final el compoundVariants que aplica (shadow-lg, porque variant === 'primary' y size === 'lg' coinciden con su condición). El orden de los ejes en la cadena final sigue el orden en que se declaran en config.variants — cambiar el orden de declaración de variant/size en la config cambiaría el orden de las clases en la salida, aunque el resultado visual sea idéntico (CSS no le importa el orden de clases dentro de un mismo class="", solo el orden de las reglas en la hoja, que es otro tema — el de la especificidad y el orden de fuente del módulo 3).
Resumen y siguiente paso
En esta lección construiste dos componentes de Mercado con el mismo motor de variantes del módulo 5: el Button, con el fix de contraste de la lección 4 ya incorporado (text-on-primary en vez de text-white, con el compound primary + lg → shadow-lg intacto), y el ProductCard, con un eje layout (default/compact) que demuestra que variants() no es exclusivo del Button — es un patrón que cualquier componente de Mercado puede adoptar. Con la fábrica que documenta dos productos de la misma línea con la misma plantilla entendiste por qué reutilizar el motor, en vez de escribir lógica de resolución de clases distinta para cada componente, es lo que hace que un sistema de diseño escale: agregar un componente nuevo es escribir una config nueva, no inventar un mecanismo nuevo.
Antes de avanzar deberías poder: explicar por qué el fix de la lección 4 solo tocó un color y no la lógica de variants(); distinguir cuándo algo es un eje de variants() (apariencia elegida por props) y cuándo es una condición de render (dato del componente, como el badge); y escribir la config de un tercer componente de Mercado (por ejemplo, un Badge reutilizable) con el mismo patrón base + variants + defaultVariants.
La lección 6 toma estos dos componentes —con sus clases ya resueltas y su color ya verificado— y los hace responsive y dark, con resolveClasses: el catálogo completo de Mercado, de 1 a 4 columnas según el ancho, con el color del Button cambiando de tema sin un solo dark: en el marcado, porque ya vive en los tokens.
Recursos
- cva (
class-variance-authority) — cva.style/docs. El motor real detrás devariants(); aplícalo a un segundo componente como hiciste aquí con elProductCard. En inglés. - shadcn/ui, "Card" — ui.shadcn.com/docs/components/card. Un componente de tarjeta de producción con estructura por partes (
CardHeader,CardContent), cercano alProductCardde esta lección. En inglés. - shadcn/ui, "Button" — ui.shadcn.com/docs/components/button. El
Buttonde referencia convariant×size, el mismo patrón que construiste aquí ya con el color corregido. En inglés. - React, "Conditional Rendering" — react.dev/learn/conditional-rendering. El mecanismo detrás del badge condicional del
ProductCard— la distinción entre variante de props y contenido condicional que esta lección marca. En inglés.