Skip to content
StackPractices
intermediate Por Mathias Paulenko

Patrón CSS Architecture

Cómo organizar CSS con utility-first classes y component-scoped layers. Cubre Tailwind CSS, CSS layers, BEM, CSS modules, y design tokens.

Temas: frontend

Nota para desarrolladores hispanohablantes: Esta guía incluye ejemplos y convenciones de nomenclatura adaptadas a equipos que trabajan en español. Cuando existen diferencias significativas en terminología técnica entre el inglés y el español, se indican explícitamente para facilitar la comunicación en equipos multiculturales.

Overview

CSS architecture es sobre organizar styles para que escalen sin convertirse en un maintenance nightmare. El utility-first con component-scoped layers pattern combina Tailwind CSS (o similar utility frameworks) para rapid layout y spacing con component-scoped CSS para complex, reusable components. CSS @layer provee cascade control — las utilities pueden overridear component styles, y component styles pueden overridear base styles, todo sin !important. Este approach te da la speed de utility classes para common patterns y la maintainability de scoped styles para complex components.

When to Use

  • Aplicaciones con 50+ components que necesitan consistent styling
  • Teams donde múltiples developers escriben CSS simultáneamente
  • Design systems que necesitan escalar across múltiples projects
  • Projects usando Tailwind CSS, UnoCSS, o similar utility-first frameworks
  • Cuando necesitás cascade control — base styles < component styles < utility overrides

When NOT to Use

  • Simple landing pages con menos de 10 components
  • Projects donde CSS ya está well-organized con BEM u otra methodology
  • Legacy projects donde introducir un new CSS architecture sería costly
  • Static sites sin reusable components

Solution

CSS layers para cascade control

/* 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%; }
}

Tailwind CSS v4 configuration

/* 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 component-scoped styles

/* 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;

BEM methodology para complex components

/* 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 CSS custom properties

/* 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 component classes

// 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>
  );
}

Responsive utility patterns

/* 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); }
  }
}

Variants

CUBE CSS methodology

/* 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>

Best Practices

  • For a deeper guide, see Container-Presenter: Separate Data Logic from Rendering.

  • Usá @layer para cascade control — declará @layer base, components, utilities para que utilities siempre ganen

  • Mantené design tokens en :root — colors, spacing, fonts, shadows como CSS custom properties

  • Usá @apply con moderación — es útil para component classes pero no buildes apps enteras con eso

  • Preferí utility classes para layout — flex, grid, padding, margin son más fast como utilities

  • Usá component classes para complex patterns — buttons, cards, inputs que tienen hover/focus states

  • Scopéa component styles con CSS modules — previene class name collisions en apps grandes

  • Usá semantic token names — --color-primary no --color-blue-500, para que podás rethemear sin cambiar usages

  • Supportá dark mode con token overrides — cambiá token values en un media query, no components individuales

Common Mistakes

  • No cascade layers: utilities y component styles pelean por specificity. @layer lo resuelve declarativamente.
  • Hardcodear values: color: #3b82f6 en vez de color: var(--color-brand-500). Los changes require find-and-replace.
  • Over-usar @apply: convertir cada utility combination en un component class. Esto defeat el purpose de utilities.
  • No usar CSS modules para components: global class names como .button colisionan. Usá .module.css o scoped styles.
  • Ignorar dark mode: buildar light mode only y boltar on dark mode después. Design tokens desde el start lo hacen trivial.

FAQ

¿Qué es el utility-first CSS approach?

Escribir styles usando classes chicas y single-purpose como flex, p-4, text-center en vez de custom CSS. Tailwind CSS es el utility-first framework más popular. Componés utilities directamente en HTML/JSX.

¿Qué son CSS layers?

CSS @layer provee explicit cascade control. Los styles en layers posteriores overridean los anteriores regardless de specificity. Declará @layer base, components, utilities para que utilities siempre overrideen component y base styles.

¿Debería usar CSS modules o Tailwind?

Ambos. Usá Tailwind utilities para layout y spacing (fast, consistent). Usá CSS modules para complex component styles con hover, focus, y state transitions (scoped, maintainable).

¿Qué son design tokens?

CSS custom properties que storean design decisions: colors, fonts, spacing, shadows, radii. Centralizan el design system para que los changes propaguen everywhere. Ejemplo: --color-primary: var(--color-brand-500).

¿Cómo manejo dark mode?

Overrideá design tokens en un @media (prefers-color-scheme: dark) block. Cambiá --color-bg, --color-text, etc. Los components automáticamente pick up los new values sin changes al component CSS.