• Skip to primary navigation
  • Skip to main content
  • Skip to footer

Codemotion Magazine

We code the future. Together

  • Discover
    • Events
    • Community
    • Partners
    • Become a partner
    • Hackathons
  • Magazine
    • DevOps
    • Carreras tech
    • Frontend
    • Inteligencia Artificial
    • Dev life
    • Desarrollo web
  • Talent
    • Discover Talent
    • Jobs
    • Manifiesto
  • Companies
  • For Business
    • EN
    • IT
    • ES
  • Sign in

jmlwebenero 20, 2025 4 min read

Construyendo componentes agnósticos con CVA y Tailwind

Diseño/UX
facebooktwitterlinkedinreddit

Tailwind es genial, pero ¿presenta algún punto débil?

El uso de Tailwind presenta numerosas ventajas:

  • Acelera el desarrollo, manteniendo las capas de contenido y presentación cerca, evitando la carga cognitiva de otorgar nombre a las clases y evitando casi por completo las colisiones entre estilos.
  • Refuerza la consistencia, mediante el uso de tokens.
  • Produce archivos CSS pequeños, extrayendo solo aquellas clases que se usan.

Sin embargo, presenta una clara desventaja en comparación a un enfoque CSS tradicional, como es la repetición de código. Para reducirla, la propia documentación de Tailwind nos sugiere el uso de componentes cuando sea posible.

Recommended article
enero 13, 2025

React y Tailwind CSS: La dupla perfecta para tus interfaces de usuario

Orli Dun

Orli Dun

Diseño/UX

Otra de las desventajas es que perdemos la semántica. Cuando aplicamos la clase btn--lg a un elemento podemos intuir que le estamos aplicando la variante «lg», cuando aplicamos las clases px-6 py-4 text-lg perdemos este contexto.

¿Como solucionar este problema con React?

Una forma de solucionar este problema a nivel de librería de UI (React en este caso) podría ser crear un componente Button que recibiese las variantes como props y que mapease estas props a clases de Tailwind.

import type { ReactNode } from 'react';

const sizeStyles = {
  sm: 'px-3 py-1 text-sm',
  md: 'px-4 py-2 text-base',
  lg: 'px-6 py-4 text-lg',
};

const intentStyles = {
  primary: 'bg-blue-600 text-blue-50',
  secondary: 'bg-orange-600 text-orange-50',
  default: 'text-slate-600 text-slate-50',
};

interface Props {
  size: keyof typeof sizeStyles;
  intent: keyof typeof intentStyles;
  children: ReactNode;
}

export const Button = ({ size, intent, children }) => (
  <button className={`inline-block border-0 ${sizeStyles[size]} ${intentStyles[intent]}`}>
    {children}
  </button>
)Lenguaje del código: JavaScript (javascript)

Esta solución:

  • Nos ahorra el tener que escribir todas las clases individuales de tailwind cada vez que queramos pintar un botón.
  • No necesitamos recordar que primary equivale a bg-blue-600 text-blue-50
  • Mantiene los estilos centralizados, si queremos actualizarlos no tenemos que estar navegando por todos los ficheros.

Ahora bien, ¿podemos considerar el trabajo terminado? Ni mucho menos:

  • Nuestro componente no permite pasar un evento onClick, de hecho, nos gustaría poder pasar cualquier prop que pueda recibir un elemento button (y tener el soporte de TypeScript para esas props)
  • Dentro de estas props nos gustaría poder enviar clases adicionales mediante la prop className, pero como el componente ya usa esa prop internamente queremos concatenar las clases externas e internas antes de asignarlas
  • Al ser un componente común, como un botón, probablemente nos interese poder trabajar de forma imperativa con él, por ejemplo para poner el foco en el elemento (o quitarlo de él). Por lo tanto tenemos que propagar la referencia con forwardRef.
  • Podría ser que nos interesase pintar otro elemento distinto a button, pero con la misma apariencia (componente polimórfico)

¿Por qué es necesario solucionar este problema usando React?

Pensemos un momento: ¿Cuál es la cantidad mínima de código que cumple con los requisitos? Dicho de otra manera, ¿de cuánto código me puedo desprender evitando ciclos de procesamiento, previniendo posibles errores, etc?

La realidad es que solo necesitaría un código similar a este para seguir manteniendo los estilos del botón centralizados, para permitirme llamar a esas clases de una forma semántica y para evitarme el código duplicado:

const sizeStyles = {
  sm: 'px-3 py-1 text-sm',
  md: 'px-4 py-2 text-base',
  lg: 'px-6 py-4 text-lg',
};

const intentStyles = {
  primary: 'bg-blue-600 text-blue-50',
  secondary: 'bg-orange-600 text-orange-50',
  default: 'text-slate-600 text-slate-50',
};

export const button = ({
  size,
  intent,
}: {
  size: keyof typeof sizeStyles;
  intent: keyof typeof intentStyles;
}) => `inline-block border-0 ${sizeStyles[size]} ${intentStyles[intent]}`Lenguaje del código: JavaScript (javascript)

La forma de pintar un botón desde cualquiera de mis componentes ahora pasaría a ser algo tan simple como className={button({ intent: 'primary', size: 'lg' })} y no tendría que preocuparme del prop-drilling o de problemas similares.

Además, puesto que ahora solo estoy usando una función de JavaScript, ¿qué me impediría usar la misma solución para mis componentes de Vue, de Svelte o de cualquier otra librería o framework?

Acelerando el desarrollo con CVA

CVA (o class-variance-authority) es una librería de JavaScript que nos permite abstraer el trabajo de construir componentes CSS basados en variantes. Veamos cómo podríamos resolver el ejemplo anterior:

import { cva } from 'class-variance-authority';

export const button = cva('inline-block border-0', {
  variants: {
    size: {
      sm: 'px-3 py-1 text-sm',
      md: 'px-4 py-2 text-base',
      lg: 'px-6 py-4 text-lg',
    },
    intent: {
      primary: 'bg-blue-600 text-blue-50',
      secondary: 'bg-orange-600 text-orange-50',
      default: 'text-slate-600 text-slate-50',
    },
  },
});Lenguaje del código: JavaScript (javascript)

La forma de invocar a estos estilos seguiría siendo: className={button({ intent: 'primary', size: 'lg' })}

Extendiendo las clases de nuestro componente

Si quisiéramos aplicar clases adicionales a nuestro componente, tan solo tendríamos que hacer a través de la propiedad class (también soporta className para facilitar la propagación de clases en React):

className={button({
  intent: 'primary',
  size: 'lg',
  class: 'text-blue-600'
})}Lenguaje del código: JavaScript (javascript)

Extrayendo los tipos de nuestro componente

En algún momento nos puede interesar extraer los tipos correspondientes a las variantes de nuestro componente. Para ello podemos recurrir a la utility-type VariantProps:

import type { VariantProps } from 'class-variance-authority';
import { cva } from 'class-variance-authority';

export const button = cva(/* ... */);

export type ButtonProps = VariantProps<typeof button>;Lenguaje del código: JavaScript (javascript)

Es importante reseñar que por defecto ninguna de las variantes está marcada como requerida. Si quisiéramos hacerlo a nivel de tipos tendríamos que crear una utility-type.

Valores por defecto para las variantes (defaultVariants)

export const button = cva('inline-block border-0', {
  variants: {
    size: {
      sm: 'px-3 py-1 text-sm',
      md: 'px-4 py-2 text-base',
      lg: 'px-6 py-4 text-lg',
    },
    intent: {
      primary: 'bg-blue-600 text-blue-50',
      secondary: 'bg-orange-600 text-orange-50',
      default: 'text-slate-600 text-slate-50',
    },
  },
  defaultVariants: {
    size: 'md',
    intent: 'default',
  },
});Lenguaje del código: JavaScript (javascript)

Variantes compuestas (compoundVariants)

Imaginemos que evolucionamos nuestro componente button y definimos una nueva variante con un valor boolean llamada outline.

Cuando la variante outline tenga el valor true, queremos que el borde y el texto tengan el acento de color. Cuando tenga el valor false, queremos que el acento lo tenga el color de fondo.

Este acento de color vendrá dictado por la variante intent.

Para conservar las dimensiones de nuestro botón, queremos pintar siempre un borde (cuyo grosor vendrá dictado por la variante size). En el caso de que la variante outline sea false, este será transparente, independientemente de la variante intent.

export const button = cva('inline-block border-0', {
  variants: {
    size: {
      sm: 'px-3 py-1 text-sm border',
      md: 'px-4 py-2 text-base border-2',
      lg: 'px-6 py-4 text-lg border-4',
    },
    outline: {
      false: 'border-transparent',
      true: null,
    },
    intent: {
      primary: null,
      secondary: null,
      default: null,
    },
  },
  compoundVariants: [
    {
      intent: 'primary',
      outline: false,
      class: 'bg-blue-600 text-blue-50',
    },
    {
      intent: 'primary',
      outline: true,
      class: 'bg-blue-50 text-blue-600 border-blue-600',
    },
    /* ... */
  ],
  defaultVariants: {
    size: 'md',
    intent: 'default',
    outline: false,
  },
});Lenguaje del código: JavaScript (javascript)

Solventando colisiones de clases con Tailwind Merge

Por mucho cuidado que pongamos, es inevitable que en algún momento nos enfrentemos al problema de las colisiones de clases. «¿Qué pasa si el componente declara internamente la clase p-4 y quiero sobreescribirla desde fuera?»

Podríamos vernos tentados a aplicarle un important, por ejemplo !p-2 pero esto no haría más que esconder el problema, porque si necesito extender a su vez este otro componente ya no me quedarían más mecanismos de escape.

La librería tailwind-merge podría ayudarnos en estos casos, ya que aplica la prevalencia sobre las últimas clases recibidas como argumento:

twMerge('p-3', 'p-4'); // p-4
twMerge('block', 'flex'); // flex
twMerge('p-3 hover:p-4', 'p-2'); // p-2 hover:p-4
twMerge('p-4', 'p-[25px]'); // p-[25px]Lenguaje del código: JavaScript (javascript)

Conclusión

Como ya hemos visto, construir un componente en cualquier librería de UI que cumpla con todos los requisitos funcionales no es una tarea liviana y en el caso de componentes puramente presentacionales no ofrece ninguna ventaja en comparación al esfuerzo requerido.

En casos como este, cva puede ser una alternativa interesante y eficiente, presentando además la ventaja de ser compatible con cualquier framework JavaScript.

Artículos relacionados

Glassmorphism example

Glassmorphism con CSS: ¡un nuevo enfoque!

Massimo Avvisati
octubre 29, 2024
Gamification is dead

La gamificación está muerta

Arnaldo Morena
agosto 27, 2024
Share on:facebooktwitterlinkedinreddit

Tags:Consejos de carrera

jmlweb
Novedades tecnológicas de 2025
Artículo anterior
Creamos un diseño de caja bento usando CSS moderno
Próximo artículo

Footer

Discover

  • Events
  • Community
  • Partners
  • Become a partner
  • Hackathons

Magazine

  • Tech articles

Talent

  • Discover talent
  • Jobs

Companies

  • Discover companies

For Business

  • Codemotion for companies

About

  • About us
  • Become a contributor
  • Work with us
  • Contact us

Follow Us

© Copyright Codemotion srl Via Marsala, 29/H, 00185 Roma P.IVA 12392791005 | Privacy policy | Terms and conditions