Arquitectura CSS: Utility-First y Capas por Componente
Cómo organizar CSS con clases utility-first y capas con ámbito de componente. Cubre Tailwind CSS, capas CSS, BEM, CSS modules y design tokens.
Visión General
Una buena arquitectura CSS mantiene los estilos ordenados a medida que la app crece — sin ella, cada componente nuevo hace el código más difícil de tocar. El patrón utility-first con capas por componente combina Tailwind CSS (o un framework de utilidades similar) para el layout y el espaciado rápido con CSS con ámbito por componente para los componentes complejos y reutilizables. @layer mantiene la cascada bajo control — las utilidades ganan a los estilos de componente, los estilos de componente ganan a los estilos base, y nunca necesitas !important. En la práctica obtienes la velocidad de las clases de utilidad para el layout diario y la mantenibilidad de los estilos con ámbito donde los componentes se complican.
Cuándo Usar
- Aplicaciones con más de 50 componentes que necesitan estilos consistentes
- Equipos donde varios desarrolladores escriben CSS a la vez
- Design systems que necesitan escalar entre proyectos
- Proyectos que usan Tailwind CSS, UnoCSS o un framework utility-first similar
- Cuando necesitas control de cascada — estilos base < estilos de componente < overrides de utilidad
Cuándo No Usar
- Landing pages simples con menos de 10 componentes
- Proyectos donde el CSS ya está bien organizado con BEM u otra metodología
- Proyectos legacy donde introducir una arquitectura CSS nueva sería costoso
- Sitios estáticos sin componentes reutilizables — mira el patrón de progressive enhancement en su lugar
/* styles.css — CSS cascade layers */
@layer base, components, utilities;
/* Base layer — resets, typography, element defaults */
@layer base {
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: var(--font-sans);
line-height: 1.6;
color: var(--color-text);
background-color: var(--color-bg);
}
h1, h2, h3, h4, h5, h6 {
font-weight: 700;
line-height: 1.2;
}
a {
color: var(--color-primary);
text-decoration: none;
}
}
/* Components layer — reusable component styles */
@layer components {
.btn {
display: inline-flex;
align-items: center;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-weight: 500;
transition: all 0.15s;
}
.btn-primary {
background-color: var(--color-primary);
color: white;
}
.btn-primary:hover {
background-color: var(--color-primary-dark);
}
.card {
background-color: white;
border: 1px solid var(--color-border);
border-radius: 0.5rem;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
}
/* Utilities layer — single-purpose helpers (highest priority) */
@layer utilities {
.text-center { text-align: center; }
.text-right { text-align: right; }
.hidden { display: none; }
.mt-4 { margin-top: 1rem; }
.mb-4 { margin-bottom: 1rem; }
.w-full { width: 100%; }
}
Configuración de Tailwind CSS v4
/* app.css — Tailwind CSS v4 CSS-first configuration */
@import "tailwindcss";
@theme {
--color-brand-50: #eff6ff;
--color-brand-100: #dbeafe;
--color-brand-200: #bfdbfe;
--color-brand-300: #93c5fd;
--color-brand-400: #60a5fa;
--color-brand-500: #3b82f6;
--color-brand-600: #2563eb;
--color-brand-700: #1d4ed8;
--color-brand-800: #1e40af;
--color-brand-900: #1e3a8a;
--font-sans: "Inter", system-ui, sans-serif;
--font-mono: "JetBrains Mono", monospace;
--radius-card: 0.5rem;
--radius-button: 0.375rem;
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.1);
--shadow-card-hover: 0 4px 12px rgba(0, 0, 0, 0.15);
}
/* Component classes usando @apply */
@layer components {
.btn {
@apply inline-flex items-center px-4 py-2 rounded-button font-medium transition-all;
}
.btn-primary {
@apply bg-brand-500 text-white hover:bg-brand-600;
}
.btn-secondary {
@apply bg-white text-slate-700 border border-slate-300 hover:bg-slate-50;
}
.btn-danger {
@apply bg-red-500 text-white hover:bg-red-600;
}
.card {
@apply bg-white border border-slate-200 rounded-card p-6 shadow-card;
}
.card-hover {
@apply hover:shadow-card-hover hover:border-brand-400 transition-all;
}
.input {
@apply w-full px-3 py-2 border border-slate-300 rounded-button
focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent;
}
.badge {
@apply inline-flex items-center px-2 py-0.5 text-xs font-medium rounded;
}
.badge-brand {
@apply bg-brand-100 text-brand-700;
}
}
CSS modules para estilos con ámbito de componente
/* Button.module.css — scoped component styles */
.button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: var(--radius-button);
font-weight: 500;
transition: all 0.15s ease;
border: none;
cursor: pointer;
}
.primary {
background-color: var(--color-brand-500);
color: white;
}
.primary:hover {
background-color: var(--color-brand-600);
}
.secondary {
background-color: white;
color: var(--color-slate-700);
border: 1px solid var(--color-slate-300);
}
.secondary:hover {
background-color: var(--color-slate-50);
}
.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.icon {
width: 1rem;
height: 1rem;
}
// Button.jsx — usando CSS module
import styles from './Button.module.css';
function Button({ variant = 'primary', disabled, icon: Icon, children, ...props }) {
const className = [
styles.button,
styles[variant],
disabled && styles.disabled,
].filter(Boolean).join(' ');
return (
<button className={className} disabled={disabled} {...props}>
{Icon && <Icon className={styles.icon} />}
{children}
</button>
);
}
export default Button;
Metodología BEM para componentes complejos
/* search-widget.css — BEM naming convention */
.search-widget {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1rem;
background: white;
border: 1px solid var(--color-border);
border-radius: var(--radius-card);
}
.search-widget__input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-button);
font-size: 0.875rem;
}
.search-widget__input--focused {
border-color: var(--color-brand-500);
box-shadow: 0 0 0 2px var(--color-brand-100);
}
.search-widget__input--error {
border-color: var(--color-red-500);
}
.search-widget__suggestions {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: white;
border: 1px solid var(--color-border);
border-radius: var(--radius-button);
max-height: 200px;
overflow-y: auto;
z-index: 10;
}
.search-widget__suggestion {
padding: 0.5rem 0.75rem;
cursor: pointer;
}
.search-widget__suggestion:hover,
.search-widget__suggestion--active {
background-color: var(--color-brand-50);
}
.search-widget__clear {
position: absolute;
right: 0.75rem;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
cursor: pointer;
color: var(--color-slate-400);
}
Design tokens como custom properties CSS
/* tokens.css — design tokens */
:root {
/* Colors — Brand */
--color-brand-50: #eff6ff;
--color-brand-500: #3b82f6;
--color-brand-600: #2563eb;
--color-brand-700: #1d4ed8;
/* Colors — Neutral */
--color-slate-50: #f8fafc;
--color-slate-100: #f1f5f9;
--color-slate-200: #e2e8f0;
--color-slate-300: #cbd5e1;
--color-slate-600: #475569;
--color-slate-700: #334155;
--color-slate-900: #0f172a;
/* Colors — Semantic */
--color-bg: var(--color-slate-50);
--color-text: var(--color-slate-900);
--color-text-muted: var(--color-slate-600);
--color-border: var(--color-slate-200);
--color-primary: var(--color-brand-500);
--color-primary-dark: var(--color-brand-600);
/* Typography */
--font-sans: "Inter", system-ui, -apple-system, sans-serif;
--font-mono: "JetBrains Mono", "Fira Code", monospace;
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
--font-size-lg: 1.125rem;
--font-size-xl: 1.25rem;
--font-size-2xl: 1.5rem;
/* Spacing */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-6: 1.5rem;
--space-8: 2rem;
/* Radius */
--radius-sm: 0.25rem;
--radius-button: 0.375rem;
--radius-card: 0.5rem;
--radius-full: 9999px;
/* Shadows */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.15);
/* Transitions */
--transition-fast: 150ms ease;
--transition-base: 200ms ease;
--transition-slow: 300ms ease;
}
/* Dark mode tokens */
@media (prefers-color-scheme: dark) {
:root {
--color-bg: var(--color-slate-900);
--color-text: #f1f5f9;
--color-text-muted: var(--color-slate-600);
--color-border: #1e293b;
}
}
Combinando utility-first con clases de componente
// ProductCard.jsx — utility classes para layout, component class para complex styles
function ProductCard({ product }) {
return (
<div className="card card-hover p-6 flex flex-col gap-4">
<img
src={product.image}
alt={product.name}
className="w-full h-48 object-cover rounded-card"
/>
<div className="flex flex-col gap-2">
<h3 className="text-lg font-bold text-slate-900">{product.name}</h3>
<p className="text-sm text-slate-600">{product.description}</p>
</div>
<div className="flex items-center justify-between mt-auto">
<span className="text-2xl font-bold text-brand-600">
${product.price}
</span>
<button className="btn btn-primary">
Add to Cart
</button>
</div>
</div>
);
}
Patrones de utilidades responsive
/* responsive.css — responsive utilities con container queries */
@layer utilities {
/* Flex utilities */
.flex { display: flex; }
.flex-col { flex-direction: column; }
.items-center { align-items: center; }
.justify-center { justify-content: center; }
.justify-between { justify-content: space-between; }
.gap-2 { gap: 0.5rem; }
.gap-4 { gap: 1rem; }
/* Grid utilities */
.grid { display: grid; }
.grid-cols-1 { grid-template-columns: repeat(1, 1fr); }
.grid-cols-2 { grid-template-columns: repeat(2, 1fr); }
.grid-cols-3 { grid-template-columns: repeat(3, 1fr); }
/* Responsive — media queries */
@media (min-width: 768px) {
.md\:flex-row { flex-direction: row; }
.md\:grid-cols-2 { grid-template-columns: repeat(2, 1fr); }
.md\:grid-cols-3 { grid-template-columns: repeat(3, 1fr); }
.md\:text-lg { font-size: 1.125rem; }
}
/* Container queries */
@container (min-width: 400px) {
.@container\:grid-cols-2 { grid-template-columns: repeat(2, 1fr); }
}
}
Variantes
Si tu app mezcla islas renderizadas en servidor con widgets interactivos, el patrón de arquitectura de islas encaja bien con este enfoque por capas.
Metodología CUBE CSS
/* CUBE CSS — Composition, Utility, Block, Exception */
/* Composition — layout patterns */
.stack {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.cluster {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
/* Utility — single purpose */
.bg-brand-500 { background-color: var(--color-brand-500); }
.text-white { color: white; }
.p-4 { padding: 1rem; }
/* Block — component */
.product-card { /* ... */ }
/* Exception — state modifiers */
.product-card:where(.is-featured) {
border-color: var(--color-brand-500);
}
Atomic CSS con UnoCSS
<!-- UnoCSS — atomic classes generated on demand -->
<div class="flex flex-col items-center gap-4 p-6 bg-white rounded-lg shadow-md">
<h2 class="text-xl font-bold text-gray-900">Title</h2>
<p class="text-sm text-gray-600">Description</p>
</div>
Buenas Prácticas
- Declara
@layer base, components, utilitiesuna sola vez — a partir de ahí las utilidades siempre ganan sin peleas de especificidad - Mantén los design tokens en
:root— colores, espaciado, fuentes y sombras como custom properties CSS - Reserva
@applypara clases de componente reales — construir una app entera con él recrea el problema que las utilidades resolvieron - Prefiere clases de utilidad para el layout — flex, grid, padding y margin son más rápidos como utilidades
- Usa clases de componente para patrones complejos — botones, tarjetas e inputs con estados hover/focus
- Limita el ámbito de los estilos de componente con CSS modules — evita colisiones de nombres de clase en apps grandes
- Usa nombres de token semánticos —
--color-primaryy no--color-blue-500, para poder retematizar sin tocar los usos - Obtén el modo oscuro gratis con overrides de tokens — cambia los valores en una media query y los componentes siguen solos
Ficheros base listos para usar (design tokens, capas de cascada, un botón con ámbito) en el repositorio companion.
Errores Comunes
- Sin capas de cascada: las utilidades y los estilos de componente pelean por especificidad.
@layerlo resuelve declarativamente. - Hardcodear valores:
color: #3b82f6en lugar decolor: var(--color-brand-500). Con un rebranding toca buscar y reemplazar por todo el código. - Abusar de
@apply: convertir cada combinación de utilidades en una clase de componente — anula el propósito de las utilidades. - Prescindir de CSS modules para componentes: nombres globales como
.buttonacaban colisionando..module.csso estilos con ámbito lo evitan. - Ignorar el modo oscuro: construir solo en claro y colgar el oscuro después. Con tokens desde el día uno, cuesta casi nada.
Preguntas frecuentes
¿Qué es el enfoque utility-first en CSS?
Escribir estilos con clases pequeñas de propósito único como flex, p-4 o text-center en lugar de CSS a medida. Tailwind CSS es el framework utility-first más popular — compones las utilidades directamente en el HTML/JSX.
¿Qué son las capas CSS?
@layer te da control explícito de la cascada: los estilos de capas posteriores sobreescriben los anteriores sin importar la especificidad. Declara @layer base, components, utilities y las utilidades siempre superan a los estilos de componente y base.
¿Debería usar CSS modules o Tailwind?
Ambos. Tailwind para layout y espaciado (rápido y consistente); CSS modules para estilos de componente complejos con hover, focus y transiciones de estado (con ámbito y mantenibles).
¿Qué son los design tokens?
Custom properties CSS que guardan tus decisiones de diseño — colores, fuentes, espaciado, sombras y radios. Centralízalas y un cambio se propaga por todo el sistema. Por ejemplo: --color-primary: var(--color-brand-500).
¿Cómo gestiono el modo oscuro?
Sobreescribe los design tokens dentro de un bloque @media (prefers-color-scheme: dark) — cambia --color-bg, --color-text y demás, y los componentes recogen los valores nuevos por sí solos. Sin tocar su CSS.
Recursos Relacionados
Patrón Container-Presenter
Cómo separar data-fetching logic del rendering en React usando el container-presenter pattern. Cubre hooks migration, testing benefits, y trade-offs.
PatternPatrón Islands Architecture: Hidratación Parcial
Renderiza la página como HTML estático en el servidor e hidrata solo las islas interactivas. Cubre las directivas client: de Astro, hidratación parcial, estado compartido y cuándo las islas superan a una SPA.
PatternPatrón Progressive Enhancement
Construye una base HTML funcional y añade capas de CSS y JavaScript encima. Cubre detección de características, degradación elegante y fallbacks sin JS.
PatternComposicion de hooks personalizados
Cómo componer lógica reusable con custom React hooks. Cubre hook composition patterns, dependency arrays, context integration, y testing strategies.
DocPlantilla de Documentación de Component API
Una plantilla para documentar APIs de componentes UI: props, events, slots, methods, accessibility y usage examples con TypeScript types.