Módulo 5: Components With Variants
base, variants y defaultVariants
Descripción
La lección 3 te convenció de que la apariencia se modela con ejes (variant, size), no con booleanas. Pero dejó abierta la pregunta mecánica: ¿dónde vive el mapa de "opción → clases", y cómo se convierten unas props en una lista de clases? Esta lección construye ese motor, con tres piezas que son el esqueleto de todo componente con variantes:
base: las clases que todos los botones comparten, sin importar sus props (el molde de costura:inline-flex,rounded-md,font-medium).variants: un objeto con un eje por prop, y dentro de cada eje, el mapa de cada opción a sus clases (variant.primary → 'bg-primary text-white',size.lg → 'text-lg px-6 py-3').defaultVariants: qué opción toma cada eje cuando la prop no viene —la respuesta a "¿qué botón sale si renderizas<Button>pelado?"—.
Con esas tres piezas, resolver un componente es un algoritmo simple: empiezas con base, y por cada eje tomas la clase de la opción que pidieron las props —o, si no la pidieron, la de defaultVariants—. Vas a ejecutarlo sobre el Button de Mercado y ver la cadena de resolución paso a paso: base, luego la clase del variant, luego la del size, hasta la lista final.
Conexión con el módulo. La lección 3 dio los ejes; esta les da estructura y un motor que los resuelve. Es el núcleo técnico del módulo: variants(), el algoritmo que construyes aquí, es el que la lección 5 amplía con compound variants y la lección 6 revela como la mini-versión de cva. Todo lo que sigue opera sobre esta estructura base + variants + defaultVariants; domínala aquí y el resto es agregarle capacidades. Las clases que mapea cada opción salen de las escalas del módulo 4 (text-lg es un paso de la escala tipográfica; px-6 uno de la de espaciado) y apuntan a tus tokens del módulo 2 (bg-primary) —el motor solo decide cuáles aplicar—.
Una analogía: la máquina de café con botones
Piensa en una máquina de café de oficina, de esas con botones. La máquina tiene una base de preparación que hace todo café: calienta el agua, muele, presiona. Eso pasa siempre, elijas lo que elijas —es el molde común—. Encima, tiene dos hileras de botones. La primera hilera es el tipo: espresso, americano, capuchino (elige uno). La segunda es el tamaño: chico, mediano, grande (elige uno). Cada botón que presionas agrega algo a la base: "capuchino" agrega la leche espumada; "grande" agrega más agua.
Y aquí está la pieza que la mayoría olvida: la máquina tiene una selección por defecto. Si llegas con tu taza y presionas "servir" sin elegir nada, no se rompe ni te da un café vacío —te da el café predeterminado de la máquina, digamos "americano mediano"—. Alguien configuró qué sale cuando no eliges. Sin esa configuración, la máquina no sabría qué hacer con un pedido sin botones.
El motor de variantes es esa máquina. base es la preparación común. variants son las hileras de botones —variant (el tipo) y size (el tamaño)—, cada botón mapeado a lo que agrega. Y defaultVariants es la selección por defecto: qué opción toma cada hilera cuando no presionas nada. Resolver el componente es: empezar con la base, y por cada hilera, agregar lo del botón que presionaste —o, si no presionaste, lo del botón por defecto—. Vas a ver esa preparación paso a paso, ejecutada.
Ejemplo trabajado: la cadena de resolución del Button de Mercado
Vamos a construir variants(config, props) y correrlo con una versión que además imprime la traza: qué aporta cada paso. La config del Button de Mercado tiene base, dos ejes (variant con tres opciones, size con tres) y defaultVariants. Lo corremos con tres casos: uno que pasa ambos ejes, uno que no pasa nada (todo cae en defaults), y uno que pasa solo variant (el size cae en su default).
// L4 — base + variants + defaultVariants: resolver la lista de clases desde props.
// variants() es una mini-version pedagogica de cva (la libreria real llega en L6).
function variants(config, props = {}) {
const trace = [];
const classes = [];
if (config.base) { classes.push(config.base); trace.push('base -> ' + config.base); }
for (const axis of Object.keys(config.variants)) {
const fromProps = props[axis] !== undefined;
const value = fromProps ? props[axis] : config.defaultVariants?.[axis];
const cls = config.variants[axis]?.[value];
if (cls) { classes.push(cls); trace.push(axis + '=' + value + (fromProps ? '' : ' (default)') + ' -> ' + cls); }
}
return { className: classes.join(' '), trace };
}
const button = {
base: 'inline-flex items-center justify-center rounded-md font-medium',
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' },
};
function show(label, props) {
const { className, trace } = variants(button, props);
console.log(label + ' props=' + JSON.stringify(props));
for (const t of trace) console.log(' ' + t);
console.log(' = ' + className + '\n');
}
console.log('=== resolver el Button de Mercado ===\n');
show('primary lg :', { variant: 'primary', size: 'lg' });
show('sin props :', {});
show('ghost :', { variant: 'ghost' });
Qué esperar. Al correr el archivo con Node, la salida es exactamente esta:
=== resolver el Button de Mercado ===
primary lg : props={"variant":"primary","size":"lg"}
base -> inline-flex items-center justify-center rounded-md font-medium
variant=primary -> bg-primary text-white
size=lg -> text-lg px-6 py-3
= inline-flex items-center justify-center rounded-md font-medium bg-primary text-white text-lg px-6 py-3
sin props : props={}
base -> inline-flex items-center justify-center rounded-md font-medium
variant=primary (default) -> bg-primary text-white
size=md (default) -> text-base px-4 py-2
= inline-flex items-center justify-center rounded-md font-medium bg-primary text-white text-base px-4 py-2
ghost : props={"variant":"ghost"}
base -> inline-flex items-center justify-center rounded-md font-medium
variant=ghost -> bg-transparent text-primary
size=md (default) -> text-base px-4 py-2
= inline-flex items-center justify-center rounded-md font-medium bg-transparent text-primary text-base px-4 py-2
Lee cada bloque como una preparación de café. El primero (primary lg) presiona los dos botones: empieza con la base (inline-flex ... font-medium), agrega lo del variant=primary (bg-primary text-white) y lo del size=lg (text-lg px-6 py-3), y la lista final concatena los tres en orden. Fíjate en que la traza es la receta: base, luego un eje, luego el otro. Ese orden importa —lo retomamos en la profundización—.
El segundo (sin props, {}) es el más instructivo: no presionaste ningún botón, y aun así salió un café completo. La traza lo dice con la etiqueta (default): variant=primary (default) y size=md (default). Como las props venían vacías, el motor cayó en defaultVariants para ambos ejes y resolvió un Button primary mediano. Esto es lo que hace que <Button>Add to cart</Button> —sin una sola prop— renderice algo sensato en vez de un botón sin estilo. Sin defaultVariants, ese botón pelado no tendría ni variant ni size que resolver, y saldría solo con la base: un botón a medio vestir.
El tercero (ghost) es el caso mixto, el más común en la práctica: pasaste variant='ghost' pero omitiste size. La traza muestra que cada eje se resuelve por separado: variant=ghost (de las props, sin etiqueta) y size=md (default) (del default). No es "todo o nada": lo que pasas gana, lo que omites cae en su default, eje por eje. Por eso <Button variant="ghost"> te da un botón ghost de tamaño mediano —exactamente lo que esperarías—.
Una pregunta hacia la lección 5: el diseño de Mercado pide que el CTA principal —variant="primary" y size="lg"— lleve una sombra extra (shadow-lg). ¿Dónde la pones? Si la metes en variant.primary, el primary chico también la lleva (mal). Si la metes en size.lg, el ghost grande también (mal). La sombra pertenece al cruce de primary y lg, no a ninguno de los dos ejes por separado. El motor de esta lección no tiene dónde ponerla. (Guarda la pregunta: ese "dónde" es el compound variant de la lección 5.)
Profundización: por qué la base va primero y el orden de los ejes importa
El motor concatena las clases en un orden fijo: primero base, luego los ejes en el orden en que aparecen en variants. ¿Importa ese orden? Para la mayoría de las clases, no —inline-flex y text-lg no chocan, tocan propiedades distintas—. Pero importa cuando dos clases tocan la misma propiedad, y ahí conecta directo con el módulo 3.
Recuerda: todas las utilidades de Tailwind tienen la misma especificidad ([0,1,0]), así que cuando dos tocan la misma propiedad, gana la que va después en el CSS generado —no en el class—. Eso significa que si tu base trae text-base y tu size.lg trae text-lg, las dos definen font-size, y cuál gana depende del orden en el CSS, no del orden en que las concatenaste. Por eso un motor de variantes bien hecho pone las clases más específicas de la variante después de la base: quieres que la clase del eje pueda sobrescribir un default de la base cuando corresponda. La base es el piso común; los ejes ajustan encima. (En el ejemplo evitamos el choque a propósito —la base no define font-size, lo define solo el eje size— justo para no depender del orden. Es una buena práctica: que cada propiedad la decida un solo eje, para que el resultado no dependa de sutilezas de la cascada.)
La lección práctica: el motor ensambla la lista, pero quién gana cuando dos clases chocan sigue siendo la cascada del módulo 3. Por eso las librerías reales de variantes suelen incluir un paso extra —"merge" de clases de Tailwind— que resuelve estos choques de forma predecible; lo nombramos en la lección 6. Aquí basta con la regla: mantén cada propiedad bajo el control de un solo eje, y el orden deja de ser un problema.
Errores comunes
Olvidar defaultVariants y romper el botón sin props. Qué pasa: se define base y variants pero no defaultVariants, y <Button> (sin props) sale a medio vestir —solo con la base, sin color ni tamaño—. Por qué pasa: al construir el componente siempre se prueba pasando props, así que el caso "sin props" nunca se ve hasta que aparece en producción. Cómo detectarlo: un <Button> pelado no se ve como esperabas; la traza muestra solo la línea base y ningún eje. Cómo corregirlo: define un defaultVariants con la opción sensata de cada eje. Es el que responde "¿qué sale si no eliges?", y casi siempre quieres que sea algo, no nada. El botón por defecto de Mercado es primary mediano; sin defaultVariants, sería un botón sin estilo.
Meter en variant clases que pertenecen a base. Qué pasa: se repite rounded-md font-medium dentro de cada opción de variant (en primary, en secondary, en ghost). Por qué pasa: al escribir la primera variante uno mete todas las clases juntas, y las copia a las demás. Cómo detectarlo: las tres opciones de un eje comparten clases idénticas —esas clases son la base disfrazada—. Cómo corregirlo: lo que todas las opciones comparten va en base, una sola vez; en cada opción de variant van solo las clases que la distinguen. Repetir la base dentro de cada variante es el drift de la lección 2 reintroducido dentro del motor: cambiar el radio común te obliga a tocarlo en cada opción.
Poner una clase de cruce (primary + lg) en un solo eje. Qué pasa: el CTA primary grande necesita shadow-lg, y se mete en variant.primary —así el primary chico también la hereda, sin quererlo—. Por qué pasa: la sombra "va con el primary", parece natural ponerla ahí. Cómo detectarlo: una apariencia aparece en combinaciones donde no debería (el primary chico con sombra). Cómo corregirlo: las clases que pertenecen a una combinación de ejes no van en ningún eje individual —van en un compound variant (lección 5)—. Si una clase solo debe aplicar cuando dos ejes valen algo específico, ese es su lugar. Meterla en un eje suelto la derrama a combinaciones equivocadas.
Ejercicios
Ejercicio 1 — Ubica cada clase. Tienes un Badge con eje tone (success, error) y eje size (sm, lg). Para cada clase, di si va en base, en una opción de tone, o en una opción de size:
- (a)
inline-flex items-center(todo badge la lleva) - (b)
bg-green-100 text-green-800(solo elsuccess) - (c)
text-sm px-2(solo elsm) - (d)
rounded-full(todo badge la lleva)
Ver solución
- (a)
base. "Todo badge la lleva" es la definición debase: clases comunes a todas las opciones. - (b)
tone.success. Es lo que distingue al success de los demás tonos; va en su opción del ejetone. - (c)
size.sm. Distingue al tamaño chico; va en su opción del ejesize. - (d)
base. Como (a), la comparten todas las opciones. Si la metieras en cadatone, la estarías repitiendo —el error de "clases de base metidas en variant"—.
La pregunta guía: ¿todas las opciones la comparten? → base. ¿La lleva una opción de un eje? → esa opción. ¿La lleva solo una combinación de ejes? → compound (lección 5).
Ejercicio 2 — Resuelve a mano. Con la config del Button del ejemplo, sin correr nada, escribe la cadena de clases final (base incluida) para variants(button, { size: 'sm' }) (pasas solo size).
Ver solución
inline-flex items-center justify-center rounded-md font-medium bg-primary text-white text-sm px-3 py-1. La resolución, eje por eje: variant no lo pasaste, cae en el default primary → bg-primary text-white; size lo pasaste como sm → text-sm px-3 py-1. Concatenado sobre la base da la cadena de arriba. La clave: un solo eje omitido (variant) cae en su default, el otro (size) usa lo que pasaste —cada eje se resuelve por su cuenta—.
Ejercicio 3 — Predice la salida. Sin correr nada, di qué imprimiría el bloque sin props ({}) si defaultVariants fuera { variant: 'ghost', size: 'sm' } en vez de { variant: 'primary', size: 'md' }.
Ver solución
La traza y la cadena cambiarían a las opciones ghost y sm:
sin props : props={}
base -> inline-flex items-center justify-center rounded-md font-medium
variant=ghost (default) -> bg-transparent text-primary
size=sm (default) -> text-sm px-3 py-1
= inline-flex items-center justify-center rounded-md font-medium bg-transparent text-primary text-sm px-3 py-1
Como no pasas props, el motor toma ambos ejes de defaultVariants, y ahora esos defaults son ghost y sm. La lección: defaultVariants define literalmente cómo se ve tu componente en su forma más pelada (<Button>), así que elígelo con cuidado —es la apariencia que más va a aparecer si tu equipo suele omitir props—.
Resumen y siguiente paso
En esta lección construiste el motor del módulo: base (clases comunes) + variants (un eje por prop, cada opción mapeada a sus clases) + defaultVariants (qué opción toma cada eje sin props), resueltos concatenando base y la clase elegida de cada eje. Con la máquina de café viste las tres piezas: la preparación común, las hileras de botones, y la selección por defecto para cuando no eliges. Y lo ejecutaste sobre el Button de Mercado: primary lg presiona los dos botones; {} cae en ambos defaults (primary md) y aun así sale un botón completo; ghost mezcla lo pasado (ghost) con el default (md), resolviendo cada eje por separado. También viste, conectando con el módulo 3, que el motor ensambla pero la cascada decide los choques —así que conviene que cada propiedad la controle un solo eje—.
Antes de avanzar deberías poder: nombrar las tres piezas y qué va en cada una; explicar por qué defaultVariants evita el botón sin props a medio vestir; y resolver a mano la cadena de clases de una combinación dada.
La lección 5 le agrega al motor la pieza que le falta: los compound variants. Ya viste el hueco —la sombra del CTA primary grande no cabe en variant ni en size, porque pertenece a su cruce—. Vas a extender variants() para que aplique clases extra cuando una combinación de ejes se cumple, y ejecutarlo sobre el caso primary + lg → shadow-lg, viendo cómo el compound se dispara para esa combinación y para ninguna otra.
Recursos
- cva (
class-variance-authority), "Getting Started" — cva.style/docs/getting-started/variants. La forma real debase,variantsydefaultVariants; nuestro motor es su versión pedagógica. En inglés. - Tailwind CSS, "Theme" — tailwindcss.com/docs/theme. De dónde salen las clases que cada opción mapea (
text-lg,px-6,bg-primary): la config de escalas y tokens de los módulos 2–4. En inglés. - React, "Passing Props to a Component" (sección de valores por defecto) — react.dev/learn/passing-props-to-a-component. Cómo un componente asigna defaults a sus props —el equivalente en React de
defaultVariants—. En inglés. web-fundamentals-html-css— módulo 3, "La cascada y la especificidad". Por qué el orden en que el motor concatena las clases interactúa con quién gana un choque; el prerequisito de la profundización.