Más allá de las utilidades básicas
Si llevas tiempo usando Tailwind CSS, probablemente ya dominas las clases de utilidad básicas: flex, p-4, text-lg, bg-blue-500. Pero Tailwind ofrece mucho más que eso. En proyectos profesionales con Next.js 16, necesitas técnicas avanzadas para mantener un design system consistente, crear animaciones fluidas y escalar tu CSS sin que se convierta en un caos.
Esta guía cubre los patrones avanzados que uso en producción: desde la configuración de temas custom hasta la creación de componentes reutilizables con variantes, pasando por animaciones CSS, plugins y estrategias de performance.
Antes de empezar, asegúrate de tener Tailwind CSS correctamente instalado en tu proyecto Next.js 16:
npm install tailwindcss @tailwindcss/postcss postcssConfigurar un tema custom
El sistema de temas de Tailwind es extremadamente flexible. En lugar de usar los colores por defecto, puedes definir tu propia paleta de marca extendiendo el tema base:
// tailwind.config.ts
import type { Config } from "tailwindcss";
const config: Config = {
content: [
"./app/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./content/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
primary: {
50: "#f0f9ff",
100: "#e0f2fe",
200: "#bae6fd",
300: "#7dd3fc",
400: "#38bdf8",
500: "#0ea5e9",
600: "#0284c7",
700: "#0369a1",
800: "#075985",
900: "#0c4a6e",
950: "#082f49",
DEFAULT: "#0c4a6e",
},
secondary: {
DEFAULT: "#082f49",
light: "#0c4a6e",
dark: "#041c2c",
},
accent: {
DEFAULT: "#f59e0b",
light: "#fbbf24",
dark: "#d97706",
},
},
fontFamily: {
sans: ["Inter", "system-ui", "sans-serif"],
mono: ["JetBrains Mono", "Fira Code", "monospace"],
display: ["Cal Sans", "Inter", "sans-serif"],
},
spacing: {
18: "4.5rem",
88: "22rem",
128: "32rem",
},
borderRadius: {
"4xl": "2rem",
},
maxWidth: {
"8xl": "88rem",
},
fontSize: {
"2xs": ["0.625rem", { lineHeight: "1rem" }],
},
},
},
plugins: [],
};
export default config;
Al usar extend, mantienes todos los valores por defecto de Tailwind y solo añades o sobreescribes los que necesitas. Esto es preferible a reemplazar completamente una propiedad, ya que conservas la utilidad de clases como text-gray-500 o bg-white.
Variables CSS para temas dinámicos
Para soportar temas dinámicos (como dark mode o temas por marca), puedes combinar Tailwind con variables CSS custom:
/* styles/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--color-background: 255 255 255;
--color-foreground: 15 23 42;
--color-primary: 12 74 110;
--color-accent: 245 158 11;
--radius: 0.5rem;
}
.dark {
--color-background: 15 23 42;
--color-foreground: 248 250 252;
--color-primary: 56 189 248;
--color-accent: 251 191 36;
}
}// tailwind.config.ts
const config: Config = {
theme: {
extend: {
colors: {
background: "rgb(var(--color-background) / <alpha-value>)",
foreground: "rgb(var(--color-foreground) / <alpha-value>)",
primary: "rgb(var(--color-primary) / <alpha-value>)",
accent: "rgb(var(--color-accent) / <alpha-value>)",
},
borderRadius: {
custom: "var(--radius)",
},
},
},
};
Ahora puedes usar clases como bg-background, text-foreground o bg-primary/80 y los colores cambiarán automáticamente según el tema activo.
Animaciones CSS con Tailwind
Tailwind permite definir animaciones CSS personalizadas directamente en la configuración del tema. Esto es ideal para mantener las animaciones centralizadas y reutilizables.
// tailwind.config.ts
const config: Config = {
theme: {
extend: {
keyframes: {
gradient: {
"0%, 100%": { backgroundPosition: "0% 50%" },
"50%": { backgroundPosition: "100% 50%" },
},
float: {
"0%, 100%": { transform: "translateY(0px)" },
"50%": { transform: "translateY(-10px)" },
},
"fade-in": {
from: { opacity: "0", transform: "translateY(10px)" },
to: { opacity: "1", transform: "translateY(0)" },
},
"fade-out": {
from: { opacity: "1", transform: "translateY(0)" },
to: { opacity: "0", transform: "translateY(10px)" },
},
"slide-in-right": {
from: { transform: "translateX(100%)", opacity: "0" },
to: { transform: "translateX(0)", opacity: "1" },
},
"slide-in-left": {
from: { transform: "translateX(-100%)", opacity: "0" },
to: { transform: "translateX(0)", opacity: "1" },
},
shimmer: {
"0%": { backgroundPosition: "-200% 0" },
"100%": { backgroundPosition: "200% 0" },
},
"scale-up": {
from: { transform: "scale(0.95)", opacity: "0" },
to: { transform: "scale(1)", opacity: "1" },
},
},
animation: {
gradient: "gradient 6s ease infinite",
float: "float 5s ease-in-out infinite",
"fade-in": "fade-in 0.5s ease-out forwards",
"fade-out": "fade-out 0.3s ease-in forwards",
"slide-in-right": "slide-in-right 0.4s ease-out",
"slide-in-left": "slide-in-left 0.4s ease-out",
shimmer: "shimmer 2s linear infinite",
"scale-up": "scale-up 0.3s ease-out",
},
},
},
};Con estas definiciones, puedes usar las animaciones como cualquier clase de Tailwind:
// Fondo con gradiente animado
<div className="animate-gradient bg-gradient-to-r from-primary via-accent to-primary
bg-[length:200%_100%]">
<h1 className="text-white text-5xl font-bold">Hola mundo</h1>
</div>
// Icono flotante
<div className="animate-float">
<IconRocket size={48} />
</div>
// Elemento que aparece con fade
<section className="animate-fade-in">
<p>Este contenido aparece con una animación suave</p>
</section>
// Skeleton loading con efecto shimmer
<div className="animate-shimmer h-4 w-full rounded bg-gradient-to-r
from-gray-200 via-gray-100 to-gray-200 bg-[length:200%_100%]">
</div>Respetar prefers-reduced-motion
Es fundamental respetar las preferencias de accesibilidad del usuario. Tailwind incluye el modificador motion-reduce y motion-safe para esto:
// Animación solo si el usuario no ha indicado preferencia por movimiento reducido
<div className="motion-safe:animate-float motion-reduce:animate-none">
<IconRocket size={48} />
</div>
// Transiciones condicionales
<button
className="transition-transform motion-safe:hover:scale-105
motion-reduce:transition-none"
>
Hover me
</button>También puedes configurar esto globalmente en tu CSS:
@layer base {
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
}El utility cn() con clsx
Cuando trabajas con Tailwind en componentes React, necesitas una forma limpia de combinar clases condicionales. La combinación de clsx y tailwind-merge es el estándar de la industria:
npm install clsx tailwind-merge// utils/classNames.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
La función cn() hace dos cosas importantes:
- clsx: Permite combinar clases de forma condicional con una API limpia (strings, objetos, arrays).
- tailwind-merge: Resuelve conflictos entre clases de Tailwind, asegurando que la última clase prevalezca. Por ejemplo,
cn("px-4", "px-6")devuelve"px-6".
Ejemplos prácticos de uso:
import { cn } from "@/utils/classNames";
// Clases condicionales con objetos
<button
className={cn(
"rounded-lg px-6 py-3 font-semibold transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500",
{
"bg-sky-900 text-white hover:bg-sky-800": variant === "primary",
"border border-sky-900 text-sky-900 hover:bg-sky-50": variant === "secondary",
"text-sky-900 underline hover:text-sky-700": variant === "link",
"cursor-not-allowed opacity-50": disabled,
}
)}
>
{children}
</button>
// Sobreescritura de clases desde props
interface CardProps {
children: React.ReactNode;
className?: string;
}
function Card({ children, className }: CardProps) {
return (
<div className={cn(
"rounded-xl border border-gray-200 bg-white p-6 shadow-sm",
className // Permite sobreescribir desde el componente padre
)}>
{children}
</div>
);
}
// Uso: la clase del padre sobreescribe correctamente
<Card className="bg-gray-50 p-8">Contenido</Card>
// Resultado: "rounded-xl border border-gray-200 bg-gray-50 p-8 shadow-sm"Plugins de Tailwind
Los plugins extienden Tailwind con nuevas utilidades, componentes o variantes. Los plugins oficiales más útiles son:
@tailwindcss/typography
Añade la clase prose para estilizar contenido HTML generado dinámicamente (blogs, documentación, Markdown renderizado):
npm install @tailwindcss/typography// tailwind.config.ts
import typography from "@tailwindcss/typography";
const config: Config = {
plugins: [typography],
theme: {
extend: {
typography: {
DEFAULT: {
css: {
maxWidth: "none",
color: "var(--tw-prose-body)",
a: {
color: "#0c4a6e",
fontWeight: "600",
textDecoration: "underline",
"&:hover": {
color: "#075985",
},
},
"code::before": { content: '""' },
"code::after": { content: '""' },
code: {
backgroundColor: "#f1f5f9",
padding: "0.25rem 0.375rem",
borderRadius: "0.25rem",
fontWeight: "500",
},
},
},
},
},
},
};// Uso en un componente de blog
<article className="prose prose-lg prose-sky mx-auto dark:prose-invert">
<div dangerouslySetInnerHTML={{ __html: blogContent }} />
</article>Crear plugins personalizados
Puedes crear tus propios plugins para añadir utilidades específicas de tu proyecto:
// tailwind.config.ts
import plugin from "tailwindcss/plugin";
const config: Config = {
plugins: [
plugin(function ({ addUtilities, addComponents, theme }) {
// Utilidades personalizadas
addUtilities({
".text-balance": {
"text-wrap": "balance",
},
".text-pretty": {
"text-wrap": "pretty",
},
".scrollbar-hide": {
"-ms-overflow-style": "none",
"scrollbar-width": "none",
"&::-webkit-scrollbar": {
display: "none",
},
},
});
// Componentes personalizados
addComponents({
".btn-primary": {
backgroundColor: theme("colors.primary.DEFAULT"),
color: "#fff",
padding: `${theme("spacing.3")} ${theme("spacing.6")}`,
borderRadius: theme("borderRadius.lg"),
fontWeight: theme("fontWeight.semibold"),
transition: "background-color 0.2s",
"&:hover": {
backgroundColor: theme("colors.primary.800"),
},
"&:focus-visible": {
outline: "none",
boxShadow: `0 0 0 3px ${theme("colors.primary.300")}`,
},
},
});
}),
],
};Dark mode con next-themes
Tailwind CSS soporta dark mode de forma nativa. Para integrarlo con Next.js 16 de forma robusta (con persistencia, sin flash de contenido y SSR-safe), usamos next-themes:
npm install next-themes// tailwind.config.ts
const config: Config = {
darkMode: "class", // Usar la estrategia de clase
// ...
};// components/providers/ThemeProvider.tsx
"use client";
import { ThemeProvider as NextThemesProvider } from "next-themes";
export default function ThemeProvider({
children,
}: {
children: React.ReactNode;
}) {
return (
<NextThemesProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</NextThemesProvider>
);
}// app/[locale]/layout.tsx
import ThemeProvider from "@/components/providers/ThemeProvider";
export default async function LocaleLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html suppressHydrationWarning>
<body>
<ThemeProvider>
{children}
</ThemeProvider>
</body>
</html>
);
}Componente toggle de tema
// components/common/ThemeToggle.tsx
"use client";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { IconSun, IconMoon, IconDeviceDesktop } from "@tabler/icons-react";
export default function ThemeToggle() {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
// Evitar hydration mismatch
useEffect(() => setMounted(true), []);
if (!mounted) return null;
const themes = [
{ value: "light", icon: IconSun, label: "Tema claro" },
{ value: "dark", icon: IconMoon, label: "Tema oscuro" },
{ value: "system", icon: IconDeviceDesktop, label: "Tema del sistema" },
];
return (
<div className="flex items-center gap-1 rounded-lg bg-gray-100 p-1 dark:bg-gray-800">
{themes.map(({ value, icon: Icon, label }) => (
<button
key={value}
onClick={() => setTheme(value)}
aria-label={label}
aria-pressed={theme === value}
className={cn(
"rounded-md p-2 transition-colors",
theme === value
? "bg-white text-sky-900 shadow-sm dark:bg-gray-700 dark:text-sky-400"
: "text-gray-500 hover:text-gray-700 dark:text-gray-400"
)}
>
<Icon size={18} />
</button>
))}
</div>
);
}
Con esta configuración, puedes usar el prefijo dark: en cualquier clase de Tailwind:
<div className="bg-white text-gray-900 dark:bg-gray-900 dark:text-gray-100">
<h1 className="text-primary dark:text-sky-400">Título</h1>
<p className="text-gray-600 dark:text-gray-300">Contenido</p>
<button className="bg-sky-900 text-white dark:bg-sky-600 dark:hover:bg-sky-500">
Acción
</button>
</div>Patrones de componentes reutilizables
Para crear componentes con múltiples variantes de forma escalable, el patrón cva() (Class Variance Authority) es el estándar de la industria:
npm install class-variance-authority// components/ui/Button.tsx
"use client";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/utils/classNames";
import { forwardRef } from "react";
const buttonVariants = cva(
// Base styles
"inline-flex items-center justify-center rounded-lg font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
primary:
"bg-primary text-white hover:bg-primary/90 focus-visible:ring-primary",
secondary:
"border-2 border-primary text-primary hover:bg-primary hover:text-white focus-visible:ring-primary",
ghost:
"text-primary hover:bg-primary/10 focus-visible:ring-primary",
destructive:
"bg-red-600 text-white hover:bg-red-700 focus-visible:ring-red-500",
link:
"text-primary underline-offset-4 hover:underline p-0 h-auto",
},
size: {
sm: "h-9 px-3 text-sm",
md: "h-11 px-6 text-base",
lg: "h-13 px-8 text-lg",
icon: "h-10 w-10",
},
fullWidth: {
true: "w-full",
},
},
defaultVariants: {
variant: "primary",
size: "md",
},
}
);
interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
children: React.ReactNode;
}
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, fullWidth, children, ...props }, ref) => {
return (
<button
ref={ref}
className={cn(buttonVariants({ variant, size, fullWidth }), className)}
{...props}
>
{children}
</button>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };El uso del componente es limpio y type-safe:
// Uso del componente Button
<Button variant="primary" size="lg">
Empezar proyecto
</Button>
<Button variant="secondary" size="sm">
Más información
</Button>
<Button variant="ghost" size="icon" aria-label="Menú">
<IconMenu size={20} />
</Button>
<Button variant="destructive" fullWidth>
Eliminar cuenta
</Button>
// Sobreescritura de estilos cuando es necesario
<Button className="rounded-full">Custom</Button>Este patrón se puede aplicar a cualquier componente: badges, cards, inputs, alerts, etc. La clave es definir las variantes una vez y reutilizarlas en toda la aplicación.
Performance: purge y optimización
Una de las mayores ventajas de Tailwind es que el CSS final solo incluye las clases que realmente usas. Pero para que esto funcione correctamente, necesitas configurar el content de forma precisa.
Configurar content correctamente
// tailwind.config.ts
const config: Config = {
content: [
// Páginas y layouts
"./app/**/*.{js,ts,jsx,tsx,mdx}",
// Componentes
"./components/**/*.{js,ts,jsx,tsx,mdx}",
// Contenido estático (blog posts con clases Tailwind en HTML)
"./content/**/*.{js,ts,jsx,tsx}",
// Utilidades que generan clases dinámicas
"./utils/**/*.{js,ts}",
],
// NO incluir node_modules ni archivos innecesarios
};Errores comunes que provocan CSS inflado o clases faltantes:
- Clases dinámicas:
bg-${color}-500no funciona porque Tailwind no puede detectar la clase en build time. En su lugar, usa un mapeo estático o safelist. - Content demasiado amplio: No incluyas
node_modulesen el content. Esto aumenta el tiempo de build y puede incluir clases no deseadas. - Archivos de datos olvidados: Si tus archivos de contenido (como blog posts en HTML strings) usan clases de Tailwind, inclúyelos en el content.
// Ejemplo correcto: mapeo estático de colores
const colorMap: Record<string, string> = {
success: "bg-green-100 text-green-800 border-green-200",
warning: "bg-amber-100 text-amber-800 border-amber-200",
error: "bg-red-100 text-red-800 border-red-200",
info: "bg-blue-100 text-blue-800 border-blue-200",
};
function Alert({ type }: { type: keyof typeof colorMap }) {
return (
<div className={cn("rounded-lg border p-4", colorMap[type])}>
{/* contenido */}
</div>
);
}Safelist para clases dinámicas
Si necesitas clases que no aparecen literalmente en tu código, usa safelist:
// tailwind.config.ts
const config: Config = {
safelist: [
// Clases específicas
"bg-red-500",
"bg-green-500",
// Patrón con regex
{
pattern: /^(bg|text|border)-(red|green|blue|amber)-(100|500|800)$/,
},
],
};Advertencia: Usa safelist con moderación. Cada clase añadida al safelist se incluye en el CSS final aunque no se use. Prefiere siempre el mapeo estático sobre el safelist.
Responsive design avanzado
Más allá de los breakpoints estándar (sm, md, lg, xl, 2xl), Tailwind ofrece herramientas avanzadas para diseño responsive.
Breakpoints personalizados
// tailwind.config.ts
const config: Config = {
theme: {
screens: {
xs: "475px",
sm: "640px",
md: "768px",
lg: "1024px",
xl: "1280px",
"2xl": "1536px",
"3xl": "1920px",
},
},
};Container queries
Las container queries permiten que un componente responda al tamaño de su contenedor, no del viewport. Esto es especialmente útil para componentes reutilizables que pueden aparecer en contextos de diferentes tamaños:
npm install @tailwindcss/container-queries// tailwind.config.ts
import containerQueries from "@tailwindcss/container-queries";
const config: Config = {
plugins: [containerQueries],
};// Uso de container queries
<div className="@container">
<div className="flex flex-col @md:flex-row @lg:gap-8">
<div className="@md:w-1/3">
<img src="/image.jpg" alt="Proyecto" className="rounded-lg" />
</div>
<div className="@md:w-2/3">
<h3 className="text-lg @lg:text-2xl font-bold">Título</h3>
<p className="hidden @md:block">Descripción completa</p>
</div>
</div>
</div>
Con container queries, el componente ProjectCard adapta su layout según el espacio disponible, independientemente del tamaño de la ventana. Esto es perfecto para grids donde las cards pueden tener diferentes anchos.
Grupos y peers responsive
Combina los prefijos group y peer con breakpoints para interacciones responsive:
// Card con hover effect solo en desktop
<div className="group relative overflow-hidden rounded-xl">
<img
src="/project.jpg"
alt="Proyecto"
className="transition-transform duration-300 md:group-hover:scale-105"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/70
opacity-100 md:opacity-0 md:group-hover:opacity-100
transition-opacity duration-300">
<div className="absolute bottom-4 left-4">
<h3 className="text-white text-xl font-bold">Nombre del proyecto</h3>
</div>
</div>
</div>Buenas prácticas
-
Usa
cn()siempre: No concatenes strings manualmente para clases condicionales.cn()contailwind-mergepreviene conflictos de clases y mejora la legibilidad. -
Extiende, no reemplaces: Usa
theme.extenden lugar de sobreescribir completamente propiedades del tema. Mantén acceso a las utilidades por defecto de Tailwind. - Componentes con cva(): Para cualquier componente con más de dos variantes, usa Class Variance Authority. Es type-safe, mantenible y escala perfectamente.
-
Respeta prefers-reduced-motion: Usa
motion-safe:ymotion-reduce:en todas las animaciones. Es un requisito de WCAG 2.2. - Evita clases dinámicas: Nunca construyas clases de Tailwind con interpolación de strings. Usa mapeos estáticos o safelist cuando necesites variaciones dinámicas.
-
Organiza el CSS en layers: Usa
@layer base,@layer componentsy@layer utilitiespara CSS personalizado que no se puede expresar con clases de Tailwind. -
Optimiza el content: Sé preciso con los paths en
content. No incluyas archivos innecesarios que ralenticen el build. - Audita el bundle CSS: Periodicamente revisa el tamaño del CSS generado. Si crece significativamente, busca clases duplicadas o safelist innecesario.
- Documenta tu design system: Cuando configures colores, tipografías y espaciados custom, documéntalos para que todo el equipo use los mismos tokens.
Consejo final: Tailwind CSS brilla cuando se combina con un design system bien pensado. No te limites a usar clases sueltas: define tu paleta, tus animaciones, tus variantes de componentes y tus plugins desde el principio. Un tema bien configurado acelera el desarrollo y garantiza la consistencia visual en toda la aplicación.