El enfoque utility-first de Tailwind CSS puede llevar a JSX inflado si no se organiza correctamente.
class-variance-authority (cva): variantes con tipos
cva es la forma estándar de gestionar variantes de componentes en Tailwind.
// Using class-variance-authority (cva) for type-safe variants
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
// Base classes applied to every variant
'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
export function Button({ className, variant, size, ...props }: ButtonProps) {
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
// Usage:
// <Button variant="outline" size="sm">Click me</Button>
// <Button variant="destructive">Delete</Button>Cuándo usar @apply
@apply extrae combinaciones de clases Tailwind repetidas en clases CSS.
/* globals.css — using @apply to extract reusable patterns */
@layer components {
/* Card component pattern */
.card {
@apply rounded-lg border bg-card text-card-foreground shadow-sm;
}
.card-header {
@apply flex flex-col space-y-1.5 p-6;
}
.card-title {
@apply text-2xl font-semibold leading-none tracking-tight;
}
.card-content {
@apply p-6 pt-0;
}
/* Form field pattern */
.form-label {
@apply text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70;
}
.form-input {
@apply flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm
ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium
placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50;
}
/* Badge pattern */
.badge {
@apply inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold
transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2;
}
}
/* Use sparingly — prefer utility classes in JSX for maintainability */La utilidad cn(): fusionar clases de forma segura
clsx maneja clases condicionales, tailwind-merge resuelve conflictos de Tailwind.
// lib/utils.ts — the cn() helper (used everywhere in shadcn/ui)
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
// clsx: conditionally join class names
// twMerge: intelligently merge Tailwind classes (handles conflicts)
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// Examples:
cn('px-4 py-2', 'px-6') // 'py-2 px-6' (px-4 overridden)
cn('text-red-500', isActive && 'text-blue-500') // conditional
cn({ 'opacity-50': disabled, 'cursor-not-allowed': disabled })Headless UI: componentes interactivos accesibles
Headless UI proporciona componentes interactivos accesibles sin estilos.
// Using Headless UI (Tailwind Labs) for accessible components
import { Listbox, Transition } from '@headlessui/react';
import { CheckIcon, ChevronUpDownIcon } from '@heroicons/react/20/solid';
import { Fragment, useState } from 'react';
const people = [
{ id: 1, name: 'Durward Reynolds' },
{ id: 2, name: 'Kenton Towne' },
{ id: 3, name: 'Therese Wunsch' },
];
export function Select() {
const [selected, setSelected] = useState(people[0]);
return (
<Listbox value={selected} onChange={setSelected}>
<Listbox.Button className="relative w-full cursor-default rounded-lg bg-white py-2 pl-3 pr-10 text-left shadow-md focus:outline-none focus-visible:border-indigo-500 focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75 focus-visible:ring-offset-2 focus-visible:ring-offset-orange-300 sm:text-sm">
<span className="block truncate">{selected.name}</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
</span>
</Listbox.Button>
<Transition as={Fragment} leave="transition ease-in duration-100" leaveFrom="opacity-100" leaveTo="opacity-0">
<Listbox.Options className="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
{people.map((person) => (
<Listbox.Option key={person.id} value={person} className={({ active }) => cn('relative cursor-default select-none py-2 pl-10 pr-4', active ? 'bg-amber-100 text-amber-900' : 'text-gray-900')}>
{({ selected }) => (
<>
<span className={cn('block truncate', selected ? 'font-medium' : 'font-normal')}>{person.name}</span>
{selected ? <span className="absolute inset-y-0 left-0 flex items-center pl-3 text-amber-600"><CheckIcon className="h-5 w-5" /></span> : null}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</Listbox>
);
}Config Tailwind: design tokens y temas
Extender Tailwind con variables CSS permite cambiar temas sin JavaScript.
// tailwind.config.ts — extend with design tokens
import type { Config } from 'tailwindcss';
const config: Config = {
darkMode: ['class'],
content: ['./src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
// CSS variable-based colors for theming
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
keyframes: {
'accordion-down': {
from: { height: '0' },
to: { height: 'var(--radix-accordion-content-height)' },
},
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
},
},
},
plugins: [require('tailwindcss-animate')],
};
export default config;Enfoques de bibliotecas de componentes
| Library | Approach | Code Ownership | Theming | Accessibility |
|---|---|---|---|---|
| shadcn/ui | Copy-paste + Radix + CVA | You own the code | CSS variables | Radix primitives |
| Chakra UI | Component library | npm dependency | Theme object | Built-in |
| MUI (Material) | Component library | npm dependency | Theme provider | Built-in |
| Mantine | Component library | npm dependency | CSS variables | Built-in |
| DaisyUI | Tailwind plugin | Config only | CSS variables | Manual |
Mejores prácticas
- Usar cva para cualquier componente con 2+ variantes.
- Siempre usar cn() para fusionar clases.
- Evitar @apply para componentes.
- Design tokens via CSS variables para dark mode.
- Para dropdowns, modals — usar Headless UI o Radix UI.
Preguntas frecuentes
¿Tailwind o CSS-in-JS?
En 2026, Tailwind + CVA + shadcn/ui es el patrón dominante para nuevos proyectos React.
¿Qué es shadcn/ui?
shadcn/ui es una colección de componentes copy-paste construidos con Radix UI + Tailwind + CVA.
¿Cómo manejar dark mode con Tailwind?
Agregar darkMode: "class" en tailwind.config.ts y usar el prefijo dark:.
¿Cómo prevenir la purga de clases Tailwind en producción?
Configurar el array content en tailwind.config.ts para incluir todos los archivos.
¿Tailwind es bueno para sistemas de diseño?
Sí, pero requiere disciplina. Usar design tokens (variables CSS).