Tailwind es rápido la primera semana y un lío al sexto mes si cada pantalla es un montón suelto de utilities. Los equipos que siguen a gusto con él comparten hábitos: diseñan sistemas de layout, extraen componentes cuando un patrón se repite, cablean el dark mode una vez en la raíz y rechazan strings infinitas de clases sin estructura.

Esto no es un tutorial de inicio. Asume que ya usas Tailwind. El objetivo es código que aún tenga sentido cuando un tercer ingeniero abra el PR.


El class soup es un problema de proceso, no de CSS

El "class soup" se ve así: cuarenta utilities en un solo div, tres copias casi iguales de la misma card en tres rutas, espaciados que bailan (p-4 junto a p-5 junto a px-3.5) y variantes dark pegadas en cada hoja en vez de heredarse.

El soup suele significar una de estas:

  1. Sin primitivas de layout compartidas. Cada página reinventó max-width, gutters y ritmo vertical.
  2. Sin regla de extracción. El mismo botón o panel se reescribió en lugar de volverse componente.
  3. Los design tokens nunca salieron de los defaults. Los valores arbitrarios (w-[347px], text-[#3a7]) se volvieron normales.
  4. El dark mode fue un añadido tardío. Cada elemento recibió su par dark: en vez de una superficie de tema.

Las utilities están bien. El copy-paste sin límite de utilities no. La solución es estructura, no "volver a BEM".


Sistemas de layout: deja de rediseñar el shell de la página

Antes de los componentes, fija el chrome de la página. La mayoría de productos solo necesita unas pocas piezas de layout:

Primitiva Función Utilities típicas
Container Max width + padding horizontal mx-auto w-full max-w-6xl px-4 sm:px-6
Stack Ritmo vertical flex flex-col gap-4 (o gap-6 / gap-8 como variantes con nombre)
Cluster Fila con wrap y gap flex flex-wrap items-center gap-2
Grid Columnas responsive grid gap-6 md:grid-cols-2 lg:grid-cols-3
Section Padding vertical de sección py-12 md:py-16

Ejemplo en React (misma idea en Vue, Svelte o Angular):

export function Container({
  children,
  className = "",
}: {
  children: React.ReactNode;
  className?: string;
}) {
  return (
    <div className={`mx-auto w-full max-w-6xl px-4 sm:px-6 ${className}`}>
      {children}
    </div>
  );
}

export function Stack({
  children,
  gap = "md",
  className = "",
}: {
  children: React.ReactNode;
  gap?: "sm" | "md" | "lg";
  className?: string;
}) {
  const gaps = { sm: "gap-2", md: "gap-4", lg: "gap-8" } as const;
  return (
    <div className={`flex flex-col ${gaps[gap]} ${className}`}>{children}</div>
  );
}

Reglas que aguantan:

  • Una escala de max-width para contenido (max-w-3xl prosa, max-w-6xl app, full-bleed solo a propósito).
  • Espaciado de una escalera corta (2 / 4 / 6 / 8 / 12), no todos los números de la escala.
  • Gutters que crecen una vez en sm o md y luego se quedan.
  • Prefiere gap frente a margin en los hijos de un stack. El gap compone; el margin colapsa y pelea con los componentes.

Una página que solo es Container + Stack + una grilla de cards ya es más fácil de revisar que un árbol libre de divs.

Layout con CSS grid y estructura lógica

Para shells de dashboard, describe las regiones una vez:

<div class="min-h-screen grid grid-cols-1 lg:grid-cols-[16rem_1fr]">
  <aside class="border-r border-slate-200 bg-slate-50 dark:border-slate-800 dark:bg-slate-950">
    <!-- nav -->
  </aside>
  <div class="flex min-h-0 flex-col">
    <header class="border-b border-slate-200 px-4 py-3 dark:border-slate-800">
      <!-- top bar -->
    </header>
    <main class="flex-1 overflow-auto p-4 md:p-6">
      <!-- page content -->
    </main>
  </div>
</div>

Pon bordes y fondo del shell en el shell. Deja cards y formularios a los componentes. Mezclar el chrome del shell en cada página es cómo el dark mode y la nav responsive se rompen en una ruta y no en otra.


Extracción de componentes: cuándo dejar de pegar utilities

Extrae cuando dos o más sitios compartan estructura e intención, no cuando dos sitios compartan flex por casualidad. Una buena extracción tiene un nombre que un diseñador reconocería: Button, Card, Field, Badge, Alert, Modal, EmptyState.

Extrae la API de variantes, no un cajón de clases

Patrón malo: un Button que solo acepta className y cada call site reconstruye la variante:

// Frágil: cada call site inventa el botón otra vez
<button className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500">
  Save
</button>

Mejor patrón: un mapa pequeño de variantes (a mano o con cva / tailwind-variants):

const buttonVariants = {
  base: "inline-flex items-center justify-center rounded-lg text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 disabled:pointer-events-none disabled:opacity-50",
  variant: {
    primary: "bg-indigo-600 text-white hover:bg-indigo-500",
    secondary: "bg-slate-100 text-slate-900 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700",
    ghost: "bg-transparent text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800",
  },
  size: {
    sm: "h-8 px-3",
    md: "h-10 px-4",
    lg: "h-11 px-6",
  },
} as const;

type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
  variant?: keyof typeof buttonVariants.variant;
  size?: keyof typeof buttonVariants.size;
};

export function Button({
  variant = "primary",
  size = "md",
  className = "",
  ...props
}: ButtonProps) {
  return (
    <button
      className={`${buttonVariants.base} ${buttonVariants.variant[variant]} ${buttonVariants.size[size]} ${className}`}
      {...props}
    />
  );
}

Los call sites se quedan cortos. El review de diseño ocurre en un archivo. Cuando cambia el color de marca, editas el mapa una vez.

@apply es una herramienta, no un estilo de vida

En CSS modules o una capa de componentes:

@layer components {
  .card {
    @apply rounded-xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900;
  }
}

Usa @apply para superficies estables y repetidas que no necesitas parametrizar en cada uso. Prefiere componentes con props cuando las variantes importan. Evita convertir Tailwind en un segundo BEM donde cada clase es un @apply enorme que nadie puede sobrescribir.

La vía de escape className

Permite className en primitivas para ajustes de layout (className="mt-6 w-full"). No dejes que los call sites re-pinten el componente tirando veinte utilities de color. Si hace falta un look nuevo, añade una variante.


Design tokens: domina la escala antes de que te domine a ti

Los defaults son un punto de partida. Las apps en producción suelen necesitar una capa fina de tokens en tailwind.config (v3) o @theme (v4):

// tailwind.config.js (estilo v3)
module.exports = {
  theme: {
    extend: {
      colors: {
        brand: {
          50: "#eef2ff",
          500: "#6366f1",
          600: "#4f46e5",
          700: "#4338ca",
        },
        surface: {
          DEFAULT: "#ffffff",
          muted: "#f8fafc",
          inverse: "#0f172a",
        },
      },
      borderRadius: {
        card: "0.75rem",
      },
      boxShadow: {
        card: "0 1px 2px 0 rgb(15 23 42 / 0.06)",
      },
      maxWidth: {
        content: "42rem",
        app: "72rem",
      },
    },
  },
};

Luego escribes bg-brand-600, rounded-card, max-w-app. Los valores arbitrarios deberían dar un poco de vergüenza en review: bien para un hero de marketing puntual, mal para spacing y color de marca del día a día.

Si diseño entrega tokens de Figma, mapéalos una vez al theme. No copies hex a mano en JSX para siempre.


Dark mode sin caos

Elige una estrategia y documéntala:

  1. Estrategia de clase (class en html): mejor para toggles de usuario y apps SSR que fijan la preferencia desde cookie o local storage antes del paint.
  2. Estrategia media (prefers-color-scheme): válida para sitios de contenido sin toggle.
// v3
module.exports = {
  darkMode: "class",
  // ...
};

Setup en la raíz (conceptual):

<html class="h-full antialiased">
  <body class="min-h-full bg-white text-slate-900 dark:bg-slate-950 dark:text-slate-100">
    ...
  </body>
</html>

Superficies de tema vencen al dark: hoja por hoja

Prefiere superficies semánticas:

// Las superficies controlan light/dark; los hijos heredan el color de texto
export function Card({ children, className = "" }: { children: React.ReactNode; className?: string }) {
  return (
    <div
      className={`rounded-card border border-slate-200 bg-white text-slate-900 shadow-card dark:border-slate-800 dark:bg-slate-900 dark:text-slate-100 ${className}`}
    >
      {children}
    </div>
  );
}

Dentro de la card, usa text-slate-600 dark:text-slate-300 solo donde el contraste pide un segundo escalón. No repitas el fondo en cada div anidado.

Las variables CSS combinan bien con Tailwind para cambios de tema que van más allá de blanco/negro:

:root {
  --color-bg: 255 255 255;
  --color-fg: 15 23 42;
}
.dark {
  --color-bg: 2 6 23;
  --color-fg: 241 245 249;
}
// colores de tema como canales rgb para que funcionen los modificadores de opacidad
colors: {
  canvas: "rgb(var(--color-bg) / <alpha-value>)",
  ink: "rgb(var(--color-fg) / <alpha-value>)",
}

Luego bg-canvas text-ink cambia con la clase raíz. Así se mantienen sanas las apps multi-marca o multi-tema.

Evita el flash del tema incorrecto

Si hay toggle, fija la clase antes del primer paint (script inline en head, o clase renderizada en servidor desde cookie). Un useEffect de React que cambia el tema tras la hidratación dejará un flash blanco a usuarios en dark mode. Eso es un bug de producto, no de Tailwind.

El contraste forma parte de la API

text-slate-400 sobre bg-slate-900 puede pasar a ojo y fallar WCAG. Revisa texto primario, secundario, bordes y focus rings en ambos temas. Pon los estilos de focus en los mapas de variantes compartidos para que nadie publique un control custom sin ring.


Hábitos que mantienen legibles las utilities

Agrupa las clases con un orden fijo

Elige un orden y cúmplelo. Uno habitual:

  1. Layout / display (flex, grid, block)
  2. Posición / tamaño (relative, w-full, h-10)
  3. Spacing (p-4, gap-2, m-0)
  4. Tipografía
  5. Color / fondo / borde
  6. Efectos (shadow, transition)
  7. Interacción (hover:, focus-visible:)
  8. Variantes dark / responsive al final, o agrupadas con su propiedad

El plugin de Prettier prettier-plugin-tailwindcss impone el orden y baja el ruido en review.

Prefiere variantes responsive y de estado en el componente, no en cada hoja

// Mejor
<section className="grid gap-4 md:grid-cols-2">
  <Card />
  <Card />
</section>

// Evita repetir md: en cada card para el mismo trabajo de grilla

Parte strings largas cuando el componente sigue siendo local

Si un bloque puntual es largo pero aún no merece un componente compartido, divídelo con una variable o un helper clsx/cn:

const panel = cn(
  "rounded-xl border border-slate-200 bg-white p-6",
  "dark:border-slate-800 dark:bg-slate-900",
  emphasized && "ring-2 ring-brand-500",
);

Multi-línea legible gana a un atributo de 300 caracteres.

No pelees la cascada con ! en todas partes

!flex y !p-0 en diez sitios significan que la API del componente está mal o un padre está sobre-estilado. Arregla la propiedad de los estilos. Los modificadores important son para escapes de terceros, no para el estilo de casa.

Defaults de contenido y formularios

Usa @tailwindcss/typography (prose) para markdown/CMS largo en vez de estilar a mano cada h2 y p. Usa un Field compartido para label + control + error. Los formularios son donde el class soup se multiplica más rápido.


Escaneo y tamaño de CSS en producción

Tailwind solo emite las clases que encuentra en las rutas de content. El dolor en producción suele venir de:

  • Construcción dinámica de clases que el scanner no ve:
// Mal: bg-indigo-600 nunca aparece como string completo
const color = "indigo";
return <div className={`bg-${color}-600`} />;
// Bien: nombres de clase completos en el source
const colors = { indigo: "bg-indigo-600", rose: "bg-rose-600" } as const;
return <div className={colors.indigo} />;
  • Globs de content faltantes para paquetes del monorepo (./packages/ui/src/**/*.{ts,tsx}).
  • Safelists enormes que reintroducen CSS sin usar. Prefiere strings completas en el código frente a regex amplias de safelist.

Mantén el paquete de design system dentro del content del scanner. Una UI library privada que Tailwind no ve es cómo aparecen "estilos que faltan solo en prod".


Checklist práctico de extracción

Cuando un PR parece soup, recorre esta lista:

  1. ¿Es shell de página o contenido? El shell va a primitivas de layout.
  2. ¿Ya existe un control con nombre? Úsalo; amplía variantes si hace falta.
  3. ¿El spacing y la tipografía salen de la escalera acordada? Sustituye valores únicos.
  4. ¿Los colores son tokens de marca o ruido de la paleta cruda?
  5. ¿El dark mode lo resuelve una superficie, o 15 pares dark: en las hojas?
  6. ¿Un cn() de dos líneas haría legible este bloque local sin un archivo nuevo?
  7. ¿Un componente compartido ahorraría los próximos tres call sites, no solo este?

Si solo extraes en el tercer call site, igual ganas. Abstraer demasiado pronto cada flex gap-2 es el fallo contrario.


Cómo se ve un buen Tailwind a los seis meses

  • Las primitivas de layout (Container, Stack, shell de la app) son aburridas y se reutilizan.
  • Botones, fields, cards y alerts tienen una API corta de variantes.
  • Los tokens de tema cubren marca, superficies y radios; los valores arbitrarios son raros.
  • El dark mode es preocupación de la raíz; los componentes casi siempre heredan.
  • Las strings de clases están ordenadas, a menudo multi-línea, y cortas en los call sites.
  • El scanner ve cada paquete que emite nombres de clase.

Tailwind no elimina los design systems. Hace más barato expresar uno ligero. El patrón de producción es el de cualquier UI stack: estructura compartida, variantes claras, tokens en vez de números mágicos, y la disciplina de extraer cuando el copy-paste empieza a mentir.