Módulo 5: Components With Variants

Compound variants

Descripción

El motor de la lección 4 resuelve cada eje por separado: la clase del variant, la del size, concatenadas sobre la base. Eso cubre la enorme mayoría de los casos —pero deja un hueco preciso, y esta lección lo llena—. El hueco aparece cuando una clase pertenece no a un eje, sino al cruce de dos: algo que solo debe aplicar cuando variant vale esto y size vale aquello. El caso canónico de Mercado: el CTA principal —variant="primary" y size="lg"— lleva una sombra extra (shadow-lg) que el primary chico no lleva y el ghost grande tampoco. La sombra no es del primary ni del lg: es de su combinación.

La pieza que resuelve esto es el compound variant: una regla que dice "cuando estos ejes tengan estos valores, agrega estas clases". No reemplaza a variants; se aplica después, sobre la selección ya resuelta. Vas a extender variants() con compoundVariants —una lista de reglas, cada una con sus condiciones y sus clases— y verlo dispararse solo para la combinación que cumple todas las condiciones, y quedarse callado para las demás.

Conexión con el módulo. La lección 4 construyó el motor de ejes; esta le agrega la capacidad de reglas por combinación, que es la última pieza estructural antes de conectar con cva en la lección 6. Con base + variants + defaultVariants + compoundVariants, tu variants() ya tiene la forma completa de la config de cva —la lección 6 solo revela que lo que construiste es, pieza por pieza, la librería real—. El compound es también el detalle "rojo + XL lleva bordado extra" de la analogía del patrón de costura de la presentación: la regla que vive en el cruce, no en un eje.

Una analogía: el combo del menú que trae algo extra

Piensa en un menú de comida rápida. Cada eje es una decisión independiente: el plato (hamburguesa, pollo, pescado) y el tamaño (chico, mediano, grande). Pides una combinación —"pollo, grande"— y recibes lo de cada eje: el pollo y la porción grande de papas. Hasta aquí, es el motor de la lección 4: cada eje aporta lo suyo.

Pero el menú tiene una regla especial, de esas que están en letra chica: "hamburguesa grande incluye postre gratis". Fíjate en lo particular de esa regla. El postre no viene con toda hamburguesa —la hamburguesa chica no lo trae—. Ni con todo lo grande —el pollo grande tampoco—. Viene solo con la combinación hamburguesa + grande. Es una promoción que vive en el cruce de dos ejes, no en ninguno de los dos por separado. Si intentaras meter "postre gratis" en el eje "hamburguesa", se lo darías también a la hamburguesa chica; si lo metieras en el eje "grande", se lo darías también al pollo grande. La única forma correcta de expresarlo es una regla aparte: "si plato = hamburguesa y tamaño = grande, agrega postre".

El compound variant es esa regla de letra chica. shadow-lg es el "postre gratis" del Button de Mercado: no lo lleva todo primary, ni todo lg, solo la combinación primary + lg. Y como en el menú, se expresa como una regla aparte con condiciones —no metiéndolo en un eje, donde se derramaría a combinaciones equivocadas—.

Ejemplo trabajado: la sombra que solo lleva el primary grande

Extendemos variants() con compoundVariants: una lista de reglas, cada una un objeto con las condiciones ({ variant: 'primary', size: 'lg' }) y su clase (class: 'shadow-lg'). Después de resolver los ejes, el motor recorre las reglas y aplica la clase de cada una cuya condición se cumpla por completo sobre la selección. Lo corremos con tres casos: uno que dispara el compound (primary + lg), y dos que no (primary + md, y ghost + lg) —para ver que la regla es exigente: falla si cualquier condición no coincide—.

// L5 — compound variants: una clase extra que solo aplica a una COMBINACION.
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);
  }
  const applied = [];
  for (const compound of config.compoundVariants ?? []) {
    const { class: cls, ...conditions } = compound;
    const matches = Object.entries(conditions).every(([axis, val]) => selected[axis] === val);
    if (matches && cls) { classes.push(cls); applied.push(JSON.stringify(conditions) + ' -> ' + cls); }
  }
  return { className: classes.join(' '), applied };
}

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' },
  // el CTA primario grande de Mercado lleva una sombra extra que ni "primary" ni "lg" por si solos piden.
  compoundVariants: [{ variant: 'primary', size: 'lg', class: 'shadow-lg' }],
};

function show(label, props) {
  const { className, applied } = variants(button, props);
  console.log(label + '  props=' + JSON.stringify(props));
  console.log('  compound aplicado: ' + (applied.length ? applied.join(', ') : 'ninguno'));
  console.log('  = ' + className + '\n');
}

console.log('=== compound variant: primary + lg lleva shadow-lg ===\n');
show('primary lg :', { variant: 'primary', size: 'lg' });   // dispara el compound
show('primary md :', { variant: 'primary', size: 'md' });   // NO lo dispara (size != lg)
show('ghost lg   :', { variant: 'ghost', size: 'lg' });     // NO lo dispara (variant != primary)

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

=== compound variant: primary + lg lleva shadow-lg ===

primary lg :  props={"variant":"primary","size":"lg"}
  compound aplicado: {"variant":"primary","size":"lg"} -> shadow-lg
  = inline-flex items-center justify-center rounded-md font-medium bg-primary text-white text-lg px-6 py-3 shadow-lg

primary md :  props={"variant":"primary","size":"md"}
  compound aplicado: ninguno
  = inline-flex items-center justify-center rounded-md font-medium bg-primary text-white text-base px-4 py-2

ghost lg   :  props={"variant":"ghost","size":"lg"}
  compound aplicado: ninguno
  = inline-flex items-center justify-center rounded-md font-medium bg-transparent text-primary text-lg px-6 py-3

Lee los tres bloques como tres pedidos del menú. El primero (primary lg) es la hamburguesa grande: cumple las dos condiciones del compound (variant='primary' y size='lg'), así que la regla se dispara y agrega shadow-lg al final de la lista. La línea compound aplicado lo confirma: la condición coincidió y aportó su clase. El CTA principal de Mercado recibe su sombra —el postre gratis—.

Los otros dos bloques muestran que la regla es exigente: falla si cualquier condición no coincide. El segundo (primary md) cumple variant='primary' pero no size='lg' (es md), así que el compound no se dispara —compound aplicado: ninguno— y el botón sale sin sombra. Es la hamburguesa chica: mismo plato, tamaño equivocado, sin postre. El tercero (ghost lg) cumple size='lg' pero no variant='primary' (es ghost), y tampoco dispara: es el pollo grande, mismo tamaño, plato equivocado, sin postre. Solo la combinación exacta —las dos condiciones a la vez— trae la sombra. Ahí está la razón por la que shadow-lg no podía vivir en un eje: si estuviera en variant.primary, el segundo bloque la tendría; si estuviera en size.lg, el tercero la tendría. En un compound, solo el primero.

Mira cómo se declara el compound en la config real de cva (esto se muestra, es idéntico en forma a lo que construiste):

// la config del Button, con el compound declarado (forma real de cva).
const buttonVariants = cva(
  'inline-flex items-center justify-center rounded-md font-medium', // base
  {
    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' },
    // la regla de letra chica: solo primary + lg lleva shadow-lg.
    compoundVariants: [{ variant: 'primary', size: 'lg', class: 'shadow-lg' }],
  }
);

Profundización: el compound se aplica DESPUÉS, y por qué eso importa

Fíjate en un detalle del motor: el compound no participa en la resolución de los ejes; se aplica después, cuando la selección ya está decidida. Ese orden no es casual —es lo que hace que un compound pueda ajustar el resultado de una combinación sin ensuciar los ejes—. Los ejes responden "¿qué es este botón?" (primary, grande); el compound responde "¿esta combinación específica necesita algo más?". Separar esas dos preguntas mantiene los ejes limpios: variant.primary sigue siendo "las clases de todo primary", sin excepciones incrustadas.

Esto también explica por qué los compound variants son la excepción, no la regla. Si te encuentras escribiendo un compound por cada par de opciones, algo está mal modelado —probablemente una de las clases que crees "de combinación" es en realidad de un eje—. El compound es para lo genuinamente cruzado: un detalle que emerge de la combinación y no pertenece a ninguna parte por separado (la sombra del CTA destacado, un borde especial del ghost deshabilitado, un espaciado que solo el ícono-más-grande necesita). Un buen componente tiene muchos valores de eje y pocos compounds; si tienes más compounds que opciones de eje, revisa el modelo.

Un matiz de las condiciones: un compound puede depender de más de dos ejes, o de un solo eje con varios valores (algunas librerías aceptan size: ['md', 'lg'] para "md o lg"). La mecánica es la misma —todas las condiciones declaradas deben cumplirse—; nuestro motor pedagógico maneja el caso de igualdad exacta por eje, que es el 90% de los usos. Lo importante es el principio: el compound coincide solo si la selección satisface todas sus condiciones, y por eso es tan preciso.

Errores comunes

Meter la clase de combinación en un eje "porque casi siempre va con él". Qué pasa: shadow-lg se mete en variant.primary porque "el CTA suele ser primary". Por qué pasa: la clase se asocia mentalmente con un eje, y meterla ahí es más fácil que declarar un compound. Cómo detectarlo: una apariencia se derrama a combinaciones que no la querían —el primary chico aparece con sombra—. Cómo corregirlo: si la clase no la lleva todo el primary (solo el primary grande), no es del eje variant; es un compound. La prueba: ¿el primary chico la lleva? Si no, no va en variant.primary.

Escribir un compound cuando la clase sí es de un eje. Qué pasa: se declara compoundVariants: [{ variant: 'ghost', class: 'text-primary' }] cuando todo ghost lleva text-primary. Por qué pasa: se abusa del compound como "regla para poner clases", sin notar que la condición cubre todas las combinaciones de ese eje. Cómo detectarlo: tienes un compound con una sola condición que coincide con todas las opciones del otro eje —es decir, con todo ese valor de eje—. Cómo corregirlo: si la clase la lleva toda una opción de un eje (todo ghost, todo lg), va en el eje, no en un compound. El compound es para lo que necesita dos o más condiciones; una sola condición es una variante normal disfrazada.

Esperar que un compound se dispare con coincidencia parcial. Qué pasa: se asume que { variant: 'primary', size: 'lg' } se aplica también al primary chico "porque cumple el variant". Por qué pasa: se confunde "cumple una condición" con "cumple la regla". Cómo detectarlo: tu predicción da la clase del compound en combinaciones que solo coinciden en parte. Cómo corregirlo: el compound exige todas sus condiciones a la vez —es un AND, no un OR—. primary + md no lo dispara porque size no es lg; ghost + lg no lo dispara porque variant no es primary. Solo la coincidencia total cuenta, como viste en los bloques dos y tres del ejemplo.

Ejercicios

Ejercicio 1 — ¿Eje o compound? Para cada clase, di si va en un eje (¿la lleva toda una opción?) o en un compound (¿solo una combinación?):

  • (a) shadow-lg, que lleva solo el botón primary grande.
  • (b) bg-primary, que lleva todo botón primary.
  • (c) uppercase, que lleva solo el botón ghost pequeño.
  • (d) rounded-md, que lleva todo botón.
Ver solución
  • (a) Compound (variant: 'primary', size: 'lg'). Solo la combinación; el primary chico no la lleva.
  • (b) Eje (variant.primary). Todo primary la lleva, sin importar el tamaño. Una sola condición que cubre todas las opciones del otro eje → es del eje.
  • (c) Compound (variant: 'ghost', size: 'sm'). Solo el ghost y pequeño; ni todo ghost ni todo sm.
  • (d) base. Todo botón la lleva, sin importar ningún eje → es base, no compound ni eje.

La escalera de decisión: ¿la lleva todo botón? → base. ¿toda una opción de un eje? → ese eje. ¿solo una combinación de ejes? → compound.

Ejercicio 2 — Predice el disparo. Con la config del ejemplo (compound primary + lg → shadow-lg), sin correr nada, di si el compound se dispara para: (a) { variant: 'primary', size: 'lg' }, (b) { variant: 'primary' } (size cae en default md), (c) {} (ambos en default).

Ver solución
  • (a) Sí. Cumple las dos condiciones (primary y lg) → agrega shadow-lg.
  • (b) No. variant='primary' cumple, pero size cae en su default md, no lg. La condición size: 'lg' falla → sin sombra.
  • (c) No. Ambos ejes caen en defaults: primary y md. variant cumple, size no (md ≠ lg) → sin sombra.

Ojo con (b) y (c): el compound se evalúa sobre la selección ya resuelta, defaults incluidos. Que un eje venga de un default no lo hace especial —cuenta como cualquier valor—; simplemente md no es lg, así que no dispara.

Ejercicio 3 — Predice la salida. Sin correr nada, di qué imprimiría un cuarto caso show('primary lg extra:', { variant: 'primary', size: 'lg' }) si a compoundVariants le agregas una segunda regla { variant: 'primary', size: 'lg', class: 'ring-2' } (dos compounds con la misma condición, distinta clase).

Ver solución

Se disparan ambos compounds, porque los dos cumplen la condición primary + lg. La salida sería:

primary lg extra:  props={"variant":"primary","size":"lg"}
  compound aplicado: {"variant":"primary","size":"lg"} -> shadow-lg, {"variant":"primary","size":"lg"} -> ring-2
  = inline-flex items-center justify-center rounded-md font-medium bg-primary text-white text-lg px-6 py-3 shadow-lg ring-2

El motor recorre toda la lista de compoundVariants y aplica cada regla que coincida —no se detiene en la primera—. Por eso ambas clases (shadow-lg y ring-2) terminan en la lista. La lección: los compounds no son excluyentes entre sí; si dos reglas coinciden con la misma selección, las dos aportan sus clases. Normalmente juntarías ambas en un solo compound (class: 'shadow-lg ring-2'), pero saber que se acumulan te evita sorpresas.

Resumen y siguiente paso

En esta lección completaste el motor con su última pieza estructural: los compound variants, reglas que agregan clases cuando una combinación de ejes se cumple —un AND de condiciones evaluado sobre la selección ya resuelta—. Con el combo del menú viste el porqué: el "postre gratis" de la hamburguesa grande no pertenece al plato ni al tamaño, sino a su cruce, y meterlo en un eje lo derramaría a combinaciones equivocadas. Y lo ejecutaste: primary + lg disparó el compound y sumó shadow-lg; primary + md y ghost + lg —que coinciden solo en parte— no lo dispararon, dejando el botón sin sombra. También viste que el compound es la excepción (pocos por componente) y que se aplica después de los ejes, para ajustar sin ensuciarlos.

Antes de avanzar deberías poder: distinguir una clase de eje (toda una opción) de una de compound (una combinación); explicar por qué el compound exige todas sus condiciones; y decir por qué un buen componente tiene pocos compounds y muchos valores de eje.

La lección 6 cierra el círculo: cva en la práctica. Ya construiste, pieza por pieza, base + variants + defaultVariants + compoundVariants. Resulta que eso es la config de cva (class-variance-authority), la librería estándar de la industria para esto. Vas a ver la API real —cva(base, { variants, defaultVariants, compoundVariants }) que devuelve una función buttonVariants(props)—, cómo se usa dentro de un Button de verdad, y por qué tu variants() fue todo el tiempo una mini-versión pedagógica de ella. Lo que aprendiste no fue un juguete: fue cva por dentro.

Recursos

  • cva (class-variance-authority), "Compound Variants" — cva.style/docs/getting-started/variants#compound-variants. La forma real de compoundVariants, idéntica a la que construiste; incluye el caso de condiciones con múltiples valores. En inglés.
  • shadcn/ui, "Button" — ui.shadcn.com/docs/components/button. Un Button de producción; observa cuántos valores de eje tiene frente a cuántos compounds (pocos) —la proporción de la profundización—. En inglés.
  • Tailwind CSS, "Box Shadow" — tailwindcss.com/docs/box-shadow. La utilidad shadow-lg que el compound de Mercado agrega; de dónde sale la sombra del CTA. En inglés.
  • Joe Bell, "Introducing CVA" — joebell.co.uk/blog/introducing-cva. El autor de cva explica por qué existe la librería y el problema de las variantes que resuelve. En inglés.