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.

Without tailwind-merge
// Both bg-red-500 and bg-blue-500 apply - unpredictable result
<Button className="bg-blue-500" />
// Renders: className="bg-red-500 bg-blue-500"
With tailwind-merge
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:

Using clsx
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:

component.tsx
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:

lib/utils.ts
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:

@/components/ui/button.tsx
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:

  1. Estilos base (siempre aplicados)
  2. Estilos de variante (basados en props)
  3. Estilos condicionales (basados en estado)
  4. 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:

utils/styles.ts
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

Before - CSS Modules
import styles from './Button.module.css';

<button className={`${styles.button} ${styles[variant]} ${className}`} />
After - cn + Tailwind
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

Before - styled-components
const Button = styled.button<{ $primary?: boolean }>`
  padding: 8px 16px;
  background: ${props => props.$primary ? 'blue' : 'gray'};
`;
After - cn + Tailwind
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:

  1. Define variants fuera de los componentes - Las variantes de CVA deben definirse fuera del componente para evitar recrearlas en cada render.

  2. Memoiza cálculos complejos - Si tienes lógica condicional costosa, considera memoizarla:

const className = useMemo(
  () => cn(
    baseStyles,
    expensiveComputation(props),
    className
  ),
  [props, className]
);
  1. Usa variables CSS para valores dinámicos - En lugar de generar clases dinámicamente, usa variables CSS:
Prefer CSS variables
// 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.