Módulo 5: Components With Variants
cva en la práctica
Descripción
Durante cuatro lecciones construiste un motor: base, luego variants con ejes, luego defaultVariants, luego compoundVariants. Esta lección revela lo que quizás ya sospechas: eso que construiste es, pieza por pieza, la librería estándar de la industria para esto —cva, class-variance-authority—. Tu variants(config, props) fue todo el tiempo una mini-versión pedagógica de ella: misma forma de config, misma resolución. Aquí ves la API real, cómo se usa dentro de un Button de verdad, y por qué apoyarte en la librería —en vez de mantener tu propio motor— es lo correcto para producción.
La diferencia de forma es mínima y vale la pena verla. cva no recibe la config y las props juntas; recibe solo la config y devuelve una función —la llamas buttonVariants— que luego invocas con las props. Es decir, cva(base, options) te da una función reutilizable ligada a ese componente, y buttonVariants({ variant, size }) te da la cadena de clases. Vas a emular exactamente eso —una función buttonVariants(props) cerrada sobre la config del Button de Mercado— y correrla con cuatro combinaciones, incluyendo la que dispara el compound.
Conexión con el módulo. Las lecciones 2–5 construyeron el motor desde cero para que entiendas cómo funciona; esta lo conecta con la herramienta real que usarás. Es el puente entre "sé cómo se resuelven las variantes" y "sé usar la librería que lo hace". El proyecto (lección 8) construye el Button de Mercado apoyándose en este patrón, y la lección 7 (composición) y el módulo 7 (primitivas/shadcn) parten de que cva es la pieza para vestir componentes. Aquí también cerramos el cabo suelto de la lección 4 —los choques de clases— nombrando el compañero de cva: tailwind-merge.
Una analogía: el molde industrial que ya venden hecho
Durante el módulo has estado, en efecto, fabricando tu propio molde de costura: entendiendo cómo se corta la base, cómo se parametriza la talla, cómo se marca el detalle del cruce. Eso fue lo correcto para aprender —un sastre que entiende cómo se hace un molde puede modificar cualquiera y detectar uno mal hecho—. Pero cuando llega la hora de producir a escala, no fabricas tu molde artesanal cada vez: usas el molde industrial estándar que ya venden hecho, probado por miles de talleres, con las tolerancias resueltas y los casos raros contemplados.
cva es ese molde industrial. Tiene la misma forma que el tuyo —base, ejes, defaults, cruces— porque resuelve el mismo problema; pero está pulido por uso masivo: maneja tipos en TypeScript (te autocompleta las opciones válidas de cada eje y te marca en rojo un variant="primry" mal escrito), contempla condiciones de compound con múltiples valores, y se integra con el resto del ecosistema (shadcn/ui lo usa por debajo). No tirar tu conocimiento del molde artesanal: por eso lo construiste. Es que en producción te apoyas en el molde probado, entendiendo perfectamente lo que hace porque tú fabricaste uno igual. Saber cómo funciona por dentro es lo que te deja usarlo bien —y arreglarlo cuando algo no cuadra—.
Ejemplo trabajado: buttonVariants(props), la forma de cva
Emulamos la API de cva: cva(base, options) devuelve una función; aquí la reproducimos cerrando variants(buttonConfig, props) dentro de buttonVariants. La config es la del Button de Mercado completa —base, tres variantes, tres tamaños, defaults, y el compound primary + lg → shadow-lg—. La corremos con cuatro combinaciones: la del CTA (dispara el compound), la pelada (defaults), una secondary chica, y una ghost (size cae en default).
// L6 — variants(): la mini-version pedagogica de cva. Misma forma de config que la libreria real.
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(' ');
}
// la config del Button de Mercado (identica en forma a lo que recibe cva()).
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' },
compoundVariants: [{ variant: 'primary', size: 'lg', class: 'shadow-lg' }],
};
// cva() devuelve una funcion; aqui la emulamos cerrando sobre la config.
const buttonVariants = (props) => variants(buttonConfig, props);
console.log('=== buttonVariants(props) -> className ===\n');
for (const props of [
{ variant: 'primary', size: 'lg' },
{},
{ variant: 'secondary', size: 'sm' },
{ variant: 'ghost' },
]) {
console.log(JSON.stringify(props).padEnd(34) + ' -> ' + buttonVariants(props));
}
Qué esperar. Al correr el archivo con Node, la salida es exactamente esta:
=== buttonVariants(props) -> className ===
{"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":"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
{"variant":"ghost"} -> inline-flex items-center justify-center rounded-md font-medium transition-colors bg-transparent text-primary text-base px-4 py-2
Cada línea es una llamada a buttonVariants(props) —la función que cva te da—. La primera (primary lg) resuelve base + primary + lg + el compound shadow-lg: el CTA completo. La segunda ({}) cae en defaults (primary md) y sale un botón sensato sin pasar nada. La tercera (secondary sm) muestra el secondary con su borde (bg-surface text-primary border border-primary) en tamaño chico. La cuarta (ghost) toma ghost del prop y md del default. Es exactamente la salida que produciría la cva real con esta config —porque tu variants() implementa el mismo algoritmo—.
Lo importante no es que la salida coincida (ya lo esperabas), sino la forma de la llamada: buttonVariants({ variant, size }). Eso es lo que escribirás en producción. No llamas a un motor genérico con la config cada vez; creas una función buttonVariants ligada a la config del botón, y la invocas con las props. Cada componente tiene la suya: badgeVariants, cardVariants, alertVariants. La config vive una vez (en la definición del componente); la función se llama muchas.
Así se ve el Button de Mercado real, usando cva de verdad (esto se muestra —es lo que escribes en tu proyecto—):
// Button.jsx — el Button de Mercado con cva real.
import { cva } from 'class-variance-authority';
import { cn } from '@/lib/utils'; // helper que junta y resuelve clases (ver profundizacion)
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md font-medium transition-colors', // base
{
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 }) {
// buttonVariants(...) da la cadena; cn() le permite al que llama anadir/sobrescribir clases.
return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />;
}
Fíjate en dos cosas. Una: buttonVariants se define fuera del componente —la config no cambia entre renders, así que no hay razón de recrearla cada vez—. Dos: el Button acepta un className extra y lo pasa por cn(...) junto a la salida de buttonVariants —eso deja que quien usa el botón le añada una clase puntual (<Button className="mt-4">) sin romper las variantes—. Ese cn es el cabo que atamos ahora.
Profundización: el compañero de cva — tailwind-merge y cn
En la lección 4 quedó un cabo suelto: cuando dos clases de Tailwind tocan la misma propiedad, gana la que va después en el CSS —no en el class—, así que ensamblar clases a mano puede dar resultados sorpresa. Ejemplo: si buttonVariants produce px-4 (del size) y el que llama pasa className="px-8" esperando sobrescribir el padding, terminas con px-4 px-8 en el atributo, y cuál gana depende del orden en el CSS de Tailwind, no de tu intención. cva por sí sola concatena; no resuelve ese choque.
Ahí entra tailwind-merge, envuelto casi siempre en un helper llamado cn (por classnames). cn(a, b) junta las clases y, cuando dos tocan la misma propiedad, se queda con la última —descartando la anterior de forma predecible—. Así cn('px-4', 'px-8') da px-8 (no px-4 px-8): la intención de sobrescribir se respeta. El patrón de producción es siempre el par: cva para resolver las variantes en una cadena, cn/tailwind-merge para juntar esa cadena con clases externas resolviendo los choques. Es el mismo problema de la cascada del módulo 3, resuelto en la capa de JavaScript antes de que llegue al navegador. No necesitas construir cn en este módulo —solo saber que existe, que es el complemento de cva, y por qué—: sin él, el className extra del Button sería una fuente de choques silenciosos.
Un apunte sobre por qué esto es la práctica estándar y no una moda: shadcn/ui —el modelo de componentes que verás en el módulo 7— construye todos sus componentes con exactamente este par (cva + cn). Cuando en el módulo 7 copies un componente de shadcn a tu proyecto, lo que copias es una config de cva sobre tokens con cn para los choques. Lo que aprendiste aquí es, literalmente, cómo está hecho por dentro cada botón, card y badge de ese ecosistema.
Errores comunes
Recrear buttonVariants dentro del componente en cada render. Qué pasa: se llama cva(...) dentro de la función Button, así que la config se reconstruye en cada render. Por qué pasa: parece natural tener todo junto dentro del componente. Cómo detectarlo: const buttonVariants = cva(...) está dentro de function Button. Cómo corregirlo: define buttonVariants a nivel de módulo, fuera del componente. La config no depende de las props ni del estado —es estática—, así que crearla una vez y reusarla es correcto y más eficiente. Es un patrón, no una micro-optimización: la config es del componente, no de cada instancia.
Usar cva pero olvidar cn/tailwind-merge para los choques. Qué pasa: se pasa un className extra al componente concatenándolo con + ' ' en vez de con cn, y una clase externa que debería sobrescribir no lo hace (o lo hace de forma impredecible). Por qué pasa: cva resuelve las variantes tan bien que uno olvida que juntar su salida con clases externas es otro problema. Cómo detectarlo: <Button className="px-8"> no cambia el padding, o cambia según el orden. Cómo corregirlo: junta siempre con cn(buttonVariants(props), className). cva es para las variantes; cn es para fusionar con lo externo resolviendo choques. Son dos piezas, y en producción van juntas.
Reimplementar tu propio motor de variantes en producción "porque ya lo entiendes". Qué pasa: como construiste variants() en el módulo, se decide mantenerlo en la app real en vez de usar cva. Por qué pasa: funciona y es tuyo. Cómo detectarlo: tu proyecto tiene un helper casero de variantes en vez de la dependencia class-variance-authority. Cómo corregirlo: el motor casero fue para aprender; en producción usa cva —tiene los tipos de TypeScript (autocompletado y errores en opciones inválidas), los casos raros contemplados, y la integración con el ecosistema—. Entender el motor por dentro es exactamente lo que te habilita a usar la librería bien; no es un argumento para reinventarla. El molde artesanal enseña; el industrial produce.
Ejercicios
Ejercicio 1 — Traduce a cva. Tienes esta config de tu motor: base: 'rounded', variants: { tone: { ok: 'bg-green', bad: 'bg-red' } }, defaultVariants: { tone: 'ok' }. Escribe (a) cómo se declara con cva(...) y (b) cómo obtienes la cadena para tone: 'bad'.
Ver solución
(a) La declaración:
const badgeVariants = cva('rounded', {
variants: { tone: { ok: 'bg-green', bad: 'bg-red' } },
defaultVariants: { tone: 'ok' },
});
(b) La cadena para bad: badgeVariants({ tone: 'bad' }), que devuelve 'rounded bg-red'. La diferencia con tu motor es solo la forma de la llamada: cva(base, options) devuelve la función badgeVariants, y le pasas las props aparte —en vez de variants(config, props) todo junto—. La config y la resolución son idénticas.
Ejercicio 2 — ¿cva o cn? Para cada tarea, di si la resuelve cva o cn/tailwind-merge:
- (a) Elegir las clases del botón según
variantysize. - (b) Juntar la cadena del botón con un
className="mt-4"que pasó quien lo usa. - (c) Hacer que
<Button className="px-8">sobrescriba elpx-4de la variante, sin dejarpx-4 px-8. - (d) Aplicar
shadow-lgsolo a la combinación primary + lg.
Ver solución
- (a) cva. Resolver las variantes en una cadena de clases es justo lo que hace cva.
- (b)
cn. Juntar la salida de cva con clases externas es trabajo decn/tailwind-merge. - (c)
cn. Resolver el choque entrepx-4(de la variante) ypx-8(externo), quedándose con el último, es exactamente lo que tailwind-merge hace; cva solo concatenaríapx-4 px-8. - (d) cva. Los compound variants son parte de la config de cva; el cruce primary + lg → shadow-lg lo resuelve ella.
La división: cva decide qué clases pone la variante; cn decide qué gana cuando la salida de cva se junta con clases externas que chocan.
Ejercicio 3 — Predice la salida. Sin correr nada, di qué imprimiría la línea de { variant: 'ghost', size: 'lg' } si se agregara a la lista del ejemplo (con la config completa, incluyendo el compound primary + lg).
Ver solución
{"variant":"ghost","size":"lg"} -> inline-flex items-center justify-center rounded-md font-medium transition-colors bg-transparent text-primary text-lg px-6 py-3
Resuelve base + ghost (bg-transparent text-primary) + lg (text-lg px-6 py-3). El compound no se dispara: exige variant: 'primary', y aquí es ghost —coincide el size pero no el variant, y el compound necesita las dos condiciones—. Por eso no hay shadow-lg al final. Es el "pollo grande" de la lección 5: mismo tamaño que el CTA, plato distinto, sin postre.
Resumen y siguiente paso
En esta lección conectaste tu motor con la herramienta real: cva (class-variance-authority) es la librería estándar para variantes, con la misma config que construiste (base + variants + defaultVariants + compoundVariants); cva(base, options) devuelve una función buttonVariants(props) que resuelve la cadena de clases. Con el molde industrial viste por qué: construir el motor artesanal fue para entenderlo, pero en producción te apoyas en la librería probada —con tipos, casos raros e integración con el ecosistema—. Lo ejecutaste emulando buttonVariants sobre el Button de Mercado completo, con cuatro combinaciones que dan exactamente lo que produciría cva. Y cerraste el cabo de la lección 4: los choques de clases los resuelve cn/tailwind-merge, el compañero inseparable de cva —cva elige las clases, cn las funde con lo externo resolviendo conflictos—.
Antes de avanzar deberías poder: escribir una config con cva(...) y obtener la cadena con buttonVariants(props); explicar por qué buttonVariants va fuera del componente; y decir qué hace cva y qué hace cn, y por qué en producción van juntas.
La lección 7 abre la otra mitad de construir componentes de sistema: la composición sobre las props. Las variantes resuelven cómo se ve un componente, pero no todo se modela como una variante —a veces necesitas que el Button sea un enlace (<a>), o que una Card tenga zonas configurables (un encabezado, un cuerpo, un pie)—. Llenar eso de props (isLink, href, hasHeader, headerContent…) te devuelve la explosión de la lección 3. La salida es componer: slots, children, y asChild. Vas a ver, ejecutado, cómo asChild fusiona las clases del Button en otro elemento —el mismo estilo, distinto tag— sin agregar una sola prop.
Recursos
- cva (
class-variance-authority), documentación oficial — cva.style/docs. La API completa:cva(base, options), la función que devuelve, los tipos de TypeScript. La referencia de esta lección. En inglés. - tailwind-merge — github.com/dcastil/tailwind-merge. El resolvedor de choques de clases de Tailwind que envuelve
cn; por quépx-4 px-8colapsa apx-8. En inglés. - shadcn/ui, "components.json / Button" — ui.shadcn.com/docs/components/button. Un
Buttonde producción construido con cva +cnsobre tokens; el patrón exacto de esta lección, listo para copiar. En inglés. - Joe Bell, "Introducing CVA" — joebell.co.uk/blog/introducing-cva. El autor explica el diseño de cva y por qué separa la resolución de variantes del merge de clases. En inglés.