What Tailwind doesn't cover: cascade layers for specificity control, CSS Modules for complex components, vanilla-extract for type-safe theming, and the architectural patterns that make them all work together in production.
Tyler McDaniel
AI Engineer & IBM Business Partner
Utility-first CSS — Tailwind, specifically — solved the naming problem and the dead-code problem. I use it on every project, including [this site](https://tostupidtooquit.com/blog/nextjs-16-tailwind-v4-migration-guide). But utility classes have gaps. They don't handle cascade control, they make complex animations verbose, and they can't enforce architectural boundaries between component styles. This guide covers the CSS architecture patterns that fill those gaps: cascade layers, CSS Modules, vanilla-extract, and the new @layer system that changes how we think about specificity.
Tailwind works by applying single-purpose classes directly in markup: flex items-center gap-4 p-6 bg-white rounded-lg shadow-md. This eliminates the naming problem (no more .card-wrapper-container-outer) and the purging is trivial (unused classes don't ship). For 80% of styling, this is enough.
The other 20%:
:has(), :is(), nested selectors with conditions that require more than group-hover: or peer-checked:.color: var(--color-primary) is the only way to set brand colors, not bg-blue-500 or color: #3b82f6. Tokens need architectural constraints, not just conventions.animate-* utilities cover simple cases; complex choreography needs real CSS.BEM (Block Element Modifier) was the first CSS architecture most of us learned. .card__title--highlighted is self-documenting: you know the block, the element, and the modifier. For a 20-page marketing site, it's fine.
For a 200-component application, BEM falls apart:
/ This happens in every BEM codebase at scale /
.course-card__header__title--truncated { ... }
.course-card__header__title--truncated.course-card__header__title--highlighted { ... }
.dashboard__sidebar__nav__item--active .dashboard__sidebar__nav__item__icon { ... }
The naming convention that was supposed to prevent specificity wars becomes a specificity war itself. Teams start adding !important to override deeply nested BEM selectors. The flat-specificity promise breaks as soon as someone nests blocks inside blocks.
Real problems I've hit with BEM on production codebases:
--small, --large, --primary, --secondary, --disabled, --loading, --active, --highlighted — every visual variant is a new modifier. Five states times four variants is twenty modifiers per block..card .title next to .card__title. After six months, both patterns coexist and specificity becomes unpredictable..card__title--${variant}) defeats static analysis. Your CSS bundle grows monotonically.BEM solved the global namespace problem with discipline. CSS Modules solve it with tooling. Cascade layers solve the specificity problem with the language itself.
Before covering the zero-runtime alternatives, it's worth understanding why the industry moved away from runtime CSS-in-JS solutions like styled-components, Emotion, and Stitches.
// styled-components — runtime CSS generation
import styled from "styled-components";
const Card = styled.div<{ $elevated: boolean }>
background: ${(props) => props.theme.colors.bg};
box-shadow: ${(props) =>
props.$elevated ? props.theme.shadows.lg : "none"};
padding: ${(props) => props.theme.space.lg};
border-radius: ${(props) => props.theme.radius.md};
;
This looks clean. But at runtime, styled-components:
tag into the document headThe cost per component is small. The cost across 200 components rendering simultaneously during a page transition is not. [Emotion's own benchmarks](https://emotion.sh/docs/benchmarking) show 2-5x slower initial render compared to static CSS, and the gap widens on low-powered mobile devices.
The React team noticed. React Server Components can't use runtime CSS-in-JS because there's no DOM on the server to inject tags into. The React 18+ concurrent rendering model conflicts with synchronous style injection. This isn't theoretical — the styled-components v6 migration has been painful for teams adopting the App Router pattern I described in the [React Server Components guide](https://tostupidtooquit.com/blog/react-server-components-mental-model).
Zero-runtime alternatives:
| Library | Approach | Runtime cost | RSC compatible |
|---------|----------|-------------|----------------|
| styled-components | Runtime injection | 2-5x slower render | No |
| Emotion | Runtime injection | 2-4x slower render | No |
| CSS Modules | Build-time class gen | Zero | Yes |
| vanilla-extract | Build-time CSS gen | Zero | Yes |
| Tailwind | Build-time purge | Zero | Yes |
| Panda CSS | Build-time extraction | Zero | Yes |
If you're starting a new project in 2025, skip runtime CSS-in-JS entirely. The DX it provides (co-located styles, dynamic theming) is available in zero-runtime alternatives without the performance tax.
CSS Cascade Layers (@layer) are the most significant CSS feature in years. They let you control which styles win when specificity is equal — without adding selectors or !important.
/ Define layer order — first declared = lowest priority /
@layer reset, base, tokens, components, utilities, overrides;
/ Reset layer: lowest priority /
@layer reset {
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
}
/ Base layer: element defaults /
@layer base {
body {
font-family: var(--font-sans);
color: var(--color-text);
background: var(--color-bg);
line-height: 1.6;
}
a {
color: var(--color-link);
text-decoration-thickness: 1px;
text-underline-offset: 2px;
}
}
/ Token layer: design system constraints /
@layer tokens {
:root {
--color-primary: oklch(0.65 0.24 265);
--color-text: oklch(0.2 0 0);
--color-bg: oklch(0.99 0 0);
--color-link: var(--color-primary);
--font-sans: "Inter", system-ui, sans-serif;
--radius-md: 0.5rem;
--shadow-md: 0 4px 6px -1px oklch(0 0 0 / 0.1);
}
}
/ Components: scoped styles /
@layer components {
.card {
background: var(--color-bg);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
padding: 1.5rem;
}
.prose h2 {
margin-top: 2em;
margin-bottom: 0.5em;
font-weight: 700;
}
}
/ Utilities: highest standard priority /
@layer utilities {
/ Tailwind v4 automatically places its utilities here /
}
/ Overrides: escape hatch /
@layer overrides {
.force-dark {
color-scheme: dark;
--color-text: oklch(0.9 0 0);
--color-bg: oklch(0.15 0 0);
}
}
The layer order defines priority. Styles in utilities always beat styles in components, regardless of selector specificity. A .card selector in the components layer loses to a single .mt-4 in the utilities layer — no !important needed.
Tailwind v4 natively uses cascade layers. When you write:
@import "tailwindcss";
Tailwind creates its own @layer base, @layer components, and @layer utilities layers. You can wrap your custom styles in matching layers to integrate cleanly:
@import "tailwindcss";@layer base {
/ Your base styles merge with Tailwind's base layer /
html {
scroll-behavior: smooth;
-webkit-font-smoothing: antialiased;
}
}
@layer components {
/ Your component styles — lower priority than utilities /
.lti-embed-container {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
min-height: 400px;
}
.calendly-widget iframe {
border: none;
width: 100%;
height: 700px;
}
}
This is how I handle third-party embed styles on tostupidtooquit.com. The embed container gets a .lti-embed-container class in the components layer. If I need to override spacing on a specific page, a utility class like p-0 in the utilities layer wins automatically. No specificity hacks.
For the full Tailwind v4 migration including @theme inline and OKLCH, see the [Next.js 16 + Tailwind v4 migration guide](https://tostupidtooquit.com/blog/nextjs-16-tailwind-v4-migration-guide).
CSS Modules generate unique class names at build time, preventing style leaks between components. They're built into Next.js, require zero configuration, and work alongside Tailwind.
/ components/course-card.module.css /
.card {
display: grid;
grid-template-rows: auto 1fr auto;
gap: 1rem;
container-type: inline-size;
}
.card:hover .thumbnail {
scale: 1.02;
transition: scale 0.2s ease-out;
}
/ Container query — responsive to the card's width, not viewport /
@container (min-width: 400px) {
.card {
grid-template-columns: 200px 1fr;
grid-template-rows: auto;
}
}
.title {
font-weight: 700;
line-height: 1.2;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.meta {
display: flex;
gap: 0.75rem;
color: var(--color-text-secondary);
font-size: 0.875rem;
}
import styles from "./course-card.module.css";export function CourseCard({ title, instructor, thumbnail }: CourseCardProps) {
return (
<div className={styles.card}>
<img className={styles.thumbnail} src={thumbnail} alt="" />
<h3 className={styles.title}>{title}</h3>
<div className={styles.meta}>
<span>{instructor}</span>
</div>
</div>
);
}
At build time, .card becomes something like .course-card_card__x7Kn2. No global namespace collision. The styles are co-located with the component, imported explicitly, and tree-shaken if the component isn't used.
When to use CSS Modules over Tailwind utilities:
@container syntax is cleaner in a stylesheet)@keyframesThe two aren't mutually exclusive. I use Tailwind for layout, spacing, and typography. CSS Modules for complex component internals. The cascade layer system ensures they play nicely together.
[vanilla-extract](https://vanilla-extract.style/) generates static CSS at build time from TypeScript. No runtime overhead, full type safety, and theming via typed token contracts.
// styles/theme.css.ts
import { createThemeContract, createTheme } from "@vanilla-extract/css";
export const vars = createThemeContract({
color: {
primary: null,
text: null,
bg: null,
border: null,
},
space: {
sm: null,
md: null,
lg: null,
},
radius: {
sm: null,
md: null,
lg: null,
},
});
export const lightTheme = createTheme(vars, {
color: {
primary: "oklch(0.65 0.24 265)",
text: "oklch(0.2 0 0)",
bg: "oklch(0.99 0 0)",
border: "oklch(0.8 0 0)",
},
space: { sm: "0.5rem", md: "1rem", lg: "2rem" },
radius: { sm: "0.25rem", md: "0.5rem", lg: "1rem" },
});
export const darkTheme = createTheme(vars, {
color: {
primary: "oklch(0.75 0.2 265)",
text: "oklch(0.9 0 0)",
bg: "oklch(0.15 0 0)",
border: "oklch(0.3 0 0)",
},
space: { sm: "0.5rem", md: "1rem", lg: "2rem" },
radius: { sm: "0.25rem", md: "0.5rem", lg: "1rem" },
});
// components/card.css.ts
import { style } from "@vanilla-extract/css";
import { vars } from "../styles/theme.css";
export const card = style({
background: vars.color.bg,
border: 1px solid ${vars.color.border},
borderRadius: vars.radius.md,
padding: vars.space.lg,
// TypeScript catches typos: vars.color.primry → compile error
});
export const cardTitle = style({
color: vars.color.text,
fontWeight: 700,
marginBottom: vars.space.sm,
});
The createThemeContract ensures every theme implements every token. If you add color.accent to the contract, TypeScript forces you to define it in both lightTheme and darkTheme. This is the type-safe equivalent of the [design token governance](https://tostupidtooquit.com/blog/building-design-token-system) I described in the token system guide — but enforced by the compiler instead of code review.
When to use vanilla-extract over CSS Modules:
| Feature | Tailwind | CSS Modules | vanilla-extract | CSS @layer | BEM |
|---------|----------|-------------|-----------------|-------------|-----|
| Scoping | Utility classes | Generated class names | Generated class names | Manual (layer name) | Convention |
| Type safety | No | No | Yes (TypeScript) | No | No |
| Runtime cost | Zero | Zero | Zero | Zero | Zero |
| Build tooling | PostCSS/Tailwind CLI | Built into bundlers | Vite/webpack plugin | Native CSS | None |
| Theming | CSS variables | CSS variables | Typed theme contracts | CSS variables | CSS variables |
| Specificity control | Layer-aware (v4) | None | None (flat specificity) | Full control | BEM convention |
| Learning curve | Low | Low | Moderate | Low | Low |
| Co-location | Inline in markup | File per component | File per component (.css.ts) | Global or scoped | Global |
| Dead code elimination | Automatic (purge) | Tree-shaking | Tree-shaking | Manual | Manual |
CSS custom properties aren't new. Using them as an architectural enforcement mechanism is. The pattern: define tokens in a cascade layer, consume them everywhere, and make raw values a code review violation.
@layer tokens {
:root {
/ Primitive scale — never reference directly in components /
--blue-50: oklch(0.97 0.02 265);
--blue-500: oklch(0.65 0.24 265);
--blue-900: oklch(0.25 0.12 265);
/ Semantic tokens — the only tokens components should use /
--color-primary: var(--blue-500);
--color-primary-hover: var(--blue-900);
--color-surface: oklch(0.99 0 0);
--color-surface-elevated: oklch(1 0 0);
--color-text: oklch(0.2 0 0);
--color-text-muted: oklch(0.45 0 0);
}
/ Dark mode — swap semantic tokens, primitives stay the same /
[data-theme="dark"] {
--color-primary: oklch(0.75 0.2 265);
--color-primary-hover: var(--blue-50);
--color-surface: oklch(0.15 0 0);
--color-surface-elevated: oklch(0.2 0 0);
--color-text: oklch(0.9 0 0);
--color-text-muted: oklch(0.6 0 0);
}
}
The two-tier structure (primitive → semantic) means dark mode is a token swap, not a stylesheet rewrite. Components reference --color-surface, never --blue-50. When a designer changes the primary blue from oklch(0.65 0.24 265) to oklch(0.60 0.26 270), you change one line. I covered this three-tier token architecture (primitive → semantic → component) in detail in the [design token system guide](https://tostupidtooquit.com/blog/building-design-token-system).
The cascade layer placement matters. Tokens in @layer tokens have lower priority than @layer components, which means a component can override a token for its own scope without specificity gymnastics:
@layer components {
.destructive-button {
--color-primary: oklch(0.6 0.2 25); / red for this component only /
background: var(--color-primary);
color: white;
}
}
For production Next.js projects, I use this stack:
@layer to control the cascade: reset → base → tokens → components → utilities → overrides. Tailwind v4 handles the utilities layer natively.tokens layer and consumed everywhere. See the [design token system guide](https://tostupidtooquit.com/blog/building-design-token-system) for the full three-tier architecture.I skip vanilla-extract because Tailwind + CSS Modules + custom properties covers my needs without adding a build plugin. But if I were building a design system consumed by multiple teams with strict token enforcement, vanilla-extract's typed theme contracts would be hard to beat.
The key insight: CSS architecture isn't about picking one tool. It's about layering tools so each handles what it's best at, with cascade layers ensuring they don't conflict. Stop fighting specificity. Declare the order, let the cascade work for you.
If you're migrating from a BEM or runtime CSS-in-JS codebase, start with cascade layers. Wrap your existing styles in @layer legacy, declare your new layer order with legacy at the bottom, and migrate component by component. You don't need to rewrite everything at once. The layer system is designed for incremental adoption — which is exactly what makes it the right foundation for modern CSS architecture.
---
A real migration story from Next.js 15.1 with Tailwind v3.4 to Next.js 16 with Tailwind v4. Covers the CSS-first config system, OKLCH color conversion, Turbopack landmines, and every class name that changed.
A three-tier token architecture (primitive, semantic, component) with CSS custom properties, Style Dictionary transforms, Tailwind v4 integration, and dark mode that's a token swap instead of a stylesheet rewrite.
Production TypeScript patterns: const type parameters, satisfies operator, template literal types for API routes, branded types for domain safety, discriminated unions, and the builder pattern with full type inference.