
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.
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 abg-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 elementobutton
(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.