Estilos
Estilado condicional y componible con clases de Tailwind CSS.
Las bibliotecas de componentes modernas necesitan sistemas de estilos flexibles que puedan manejar requisitos complejos sin sacrificar la experiencia del desarrollador. La combinación de Tailwind CSS con la fusión inteligente de clases ha surgido como un patrón poderoso para construir componentes personalizables.
Este enfoque resuelve la tensión fundamental entre proporcionar valores predeterminados sensatos y permitir una personalización completa, un desafío que ha afectado a las bibliotecas de componentes durante años.
El problema con los estilos tradicionales
Los enfoques tradicionales de CSS a menudo conducen a guerras de especificidad, conflictos de estilos y anulaciones impredecibles. Cuando pasas className="bg-blue-500" a un componente que ya tiene bg-red-500, ¿cuál gana?
Sin un manejo adecuado, ambas clases se aplican y el resultado depende de muchos factores: el orden de origen del CSS, la especificidad de las clases, el algoritmo de fusión de clases del bundler, etc.
Fusionar clases de forma inteligente
La librería tailwind-merge resuelve esto al entender la estructura de clases de Tailwind y resolver los conflictos de forma inteligente. Cuando dos clases apuntan a la misma propiedad CSS, conserva solo la última.
// Both bg-red-500 and bg-blue-500 apply - unpredictable result
<Button className="bg-blue-500" />
// Renders: className="bg-red-500 bg-blue-500"import { twMerge } from 'tailwind-merge';
// bg-blue-500 wins as it comes last
const className = twMerge('bg-red-500', 'bg-blue-500');
// Returns: "bg-blue-500"Esto funciona para todas las utilidades de Tailwind:
twMerge('px-4 py-2', 'px-8'); // Returns: "py-2 px-8"
twMerge('text-sm', 'text-lg'); // Returns: "text-lg"
twMerge('hover:bg-red-500', 'hover:bg-blue-500'); // Returns: "hover:bg-blue-500"La librería también entiende el sistema de modificadores de Tailwind:
// Modifiers are handled correctly
twMerge('hover:bg-red-500 focus:bg-red-500', 'hover:bg-blue-500');
// Returns: "focus:bg-red-500 hover:bg-blue-500"Clases condicionales
A menudo necesitas aplicar clases condicionalmente según props o estado. La librería clsx proporciona una API limpia para esto:
import clsx from 'clsx';
// Basic conditionals
clsx('base', isActive && 'active');
// Returns: "base active" (if isActive is true)
// Object syntax
clsx('base', {
'active': isActive,
'disabled': isDisabled,
});
// Arrays
clsx(['base', isLarge ? 'text-lg' : 'text-sm']);
// Mixed
clsx(
'base',
['array-item'],
{ 'object-conditional': true },
isActive && 'conditional'
);Un patrón común es fusionar un conjunto predeterminado de clases con las que llegan por props, así como cualquier lógica personalizada que tengamos:
const Component = ({ className, ...props }: ComponentProps) => {
const [isOpen, setIsOpen] = useState(false);
return (
<div
className={cn(
"rounded-lg border bg-white shadow-sm",
isOpen && "bg-blue-500",
className
)}
{...props}
/>
);
};La función utilitaria cn
La función cn, popularizada por shadcn/ui, combina clsx y tailwind-merge para ofrecerte tanto lógica condicional como fusión inteligente:
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}El poder proviene del orden: primero los estilos base, segundo las condicionales, y al final las anulaciones del usuario. Esto asegura un comportamiento predecible al tiempo que mantiene la personalización completa.
Class Variance Authority (CVA)
Para componentes complejos con muchas variantes, gestionar manualmente las clases condicionales se vuelve inmanejable. Class Variance Authority (CVA) proporciona una API declarativa para definir variantes de componentes.
Por ejemplo, aquí hay un extracto del componente Button de shadcn/ui:
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)Mejores prácticas
1. El orden importa
Siempre aplica las clases en este orden:
- Estilos base (siempre aplicados)
- Estilos de variante (basados en props)
- Estilos condicionales (basados en estado)
- Anulaciones del usuario (prop className)
className={cn(
'base-styles', // 1. Base
variant && variantStyles, // 2. Variants
isActive && 'active', // 3. Conditionals
className // 4. User overrides
)}2. Documenta tus variantes
Usa TypeScript y JSDoc para documentar lo que hace cada variante:
type ButtonProps = {
/**
* The visual style of the button
* @default "primary"
*/
variant?: 'primary' | 'secondary' | 'destructive' | 'ghost';
/**
* The size of the button
* @default "md"
*/
size?: 'sm' | 'md' | 'lg';
};3. Extrae patrones repetidos
Si te encuentras escribiendo la misma lógica condicional repetidamente, extráela:
export const focusRing = 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500';
export const disabled = 'disabled:pointer-events-none disabled:opacity-50';
// Use in components
className={cn(focusRing, disabled, className)}Guía de migración
Si estás migrando desde un enfoque de estilos diferente, aquí tienes cómo adaptar patrones comunes:
Desde CSS Modules
import styles from './Button.module.css';
<button className={`${styles.button} ${styles[variant]} ${className}`} />import { cn } from '@/lib/utils';
<button className={cn(
'px-4 py-2 rounded-lg',
variant === 'primary' && 'bg-blue-500 text-white',
className
)} />Desde styled-components
const Button = styled.button<{ $primary?: boolean }>`
padding: 8px 16px;
background: ${props => props.$primary ? 'blue' : 'gray'};
`;function Button({ primary, className, ...props }) {
return (
<button
className={cn(
'px-4 py-2',
primary ? 'bg-blue-500' : 'bg-gray-500',
className
)}
{...props}
/>
);
}Consideraciones de rendimiento
Tanto clsx como tailwind-merge están altamente optimizados, pero ten en cuenta estos consejos:
-
Define variants fuera de los componentes - Las variantes de CVA deben definirse fuera del componente para evitar recrearlas en cada render.
-
Memoiza cálculos complejos - Si tienes lógica condicional costosa, considera memoizarla:
const className = useMemo(
() => cn(
baseStyles,
expensiveComputation(props),
className
),
[props, className]
);- Usa variables CSS para valores dinámicos - En lugar de generar clases dinámicamente, usa variables CSS:
// Good
<div
className="bg-[var(--color)]"
style={{ '--color': dynamicColor } as React.CSSProperties}
/>
// Avoid
<div className={`bg-[${dynamicColor}]`} />La combinación de Tailwind CSS, fusión inteligente de clases y APIs de variantes proporciona una base robusta para el estilado de componentes. Este enfoque escala desde botones simples hasta sistemas de diseño complejos manteniendo la previsibilidad y la experiencia del desarrollador.