StackPractices
intermediate By Mathias Paulenko

CSS Architecture: Utility-First with Component-Scoped Layers

How to organize CSS with utility-first classes and component-scoped layers. Covers Tailwind CSS, CSS layers, BEM, CSS modules, and design tokens.

Topics: frontend

Overview

Good CSS architecture keeps styles organized as the app grows — without it, every new component makes the codebase harder to touch. The utility-first with component-scoped layers pattern pairs Tailwind CSS (or a similar utility framework) for fast layout and spacing with scoped CSS for complex, reusable components. @layer keeps the cascade under control — utilities beat component styles, component styles beat base styles, and you never reach for !important. In practice you get the speed of utilities for everyday layout and the maintainability of scoped styles where components get hairy.

Cascade layer order: base styles are overridden by component styles, which are overridden by utility classes, without needing important flags.

When to Use

  • Applications with 50+ components that need consistent styling
  • Teams where several developers write CSS at the same time
  • Design systems that need to scale across projects
  • Projects using Tailwind CSS, UnoCSS, or a similar utility-first framework
  • When you need cascade control — base styles < component styles < utility overrides

When NOT to Use

  • Simple landing pages with fewer than 10 components
  • Projects where CSS is already well-organized with BEM or another methodology
  • Legacy projects where introducing a new CSS architecture would be costly
  • Static sites with no reusable components — see the progressive enhancement pattern instead
/* 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 using @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 for 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 — using 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 for 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 as 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;
  }
}

Combining utility-first with component classes

// ProductCard.jsx — utility classes for layout, component class for 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 with 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

If your app mixes server-rendered islands with interactive widgets, the islands architecture pattern pairs well with this layering approach.

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 with 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

  • Declare @layer base, components, utilities once — from then on utilities always win without specificity fights
  • Keep design tokens in :root — colors, spacing, fonts, shadows as CSS custom properties
  • Keep @apply for true component classes — building an entire app out of it just recreates the problem utilities solved
  • Prefer utility classes for layout — flex, grid, padding, margin are faster as utilities
  • Use component classes for complex patterns — buttons, cards, inputs that have hover/focus states
  • Scope component styles with CSS modules — prevents class name collisions in large apps
  • Use semantic token names — --color-primary not --color-blue-500, so you can retheme without changing usages
  • Get dark mode for free via token overrides — flip token values in a media query and components follow

Drop-in starter files (design tokens, cascade layers, a scoped button) live in the companion repository.

Common Mistakes

  • No cascade layers: utilities and component styles fight for specificity. @layer solves this declaratively.
  • Hardcoding values: color: #3b82f6 instead of color: var(--color-brand-500). Now a rebrand means find-and-replace across the codebase.
  • Over-using @apply: turning every utility combination into a component class. This defeats the purpose of utilities.
  • Skipping CSS modules for components: global names like .button eventually collide. .module.css or scoped styles prevent it.
  • Ignoring dark mode: building light-only and bolting dark mode on later. With tokens in place from day one, it costs almost nothing.

Frequently Asked Questions

What is the utility-first CSS approach?

Writing styles using small, single-purpose classes like flex, p-4, text-center instead of custom CSS. Tailwind CSS is the most popular utility-first framework. You compose utilities directly in HTML/JSX.

What are CSS layers?

CSS @layer gives you explicit cascade control. Styles in later layers override earlier ones regardless of specificity — declare @layer base, components, utilities and utilities always beat component and base styles.

Should I use CSS modules or Tailwind?

Both. Use Tailwind utilities for layout and spacing (fast, consistent). Use CSS modules for complex component styles with hover, focus, and state transitions (scoped, maintainable).

What are design tokens?

CSS custom properties holding your design decisions — colors, fonts, spacing, shadows, radii. Centralize them and one change propagates through the whole system. Example: --color-primary: var(--color-brand-500).

How do I handle dark mode?

Override design tokens inside a @media (prefers-color-scheme: dark) block — change --color-bg, --color-text, and so on, and components pick up the new values on their own. No component CSS touched.