Frontend Architecture
Part 7 of 11CSS Architecture at Scale: The Decision Nobody Takes Seriously Until It Hurts
CSS Architecture at Scale: The Decision Nobody Takes Seriously Until It Hurts
Tailwind vs CSS Modules vs CSS-in-JS vs vanilla — not a syntax debate but a maintainability, team size, and design system integration argument.
The Pain Arrives Late
CSS architecture is one of those decisions that feels inconsequential early on. Everything works when you have three components and one developer. The pain surfaces later:
- New hires take weeks to understand the styling patterns
- Design changes require touching 47 files
- Bundle size creeps up and nobody knows why
- The design system exists in Figma but not in code
- "Just add !important" becomes the default fix
By then, migration is expensive. The codebase has 200 components, each styled differently based on who wrote them and what year it was.
This post isn't about which CSS approach has nicer syntax. It's about what happens at 50 components, 10 developers, and 3 years of maintenance.
The Four Approaches
┌─────────────────────────────────────────────────────────────────┐
│ CSS ARCHITECTURE SPECTRUM │
├─────────────────────────────────────────────────────────────────┤
│ │
│ SEMANTIC ◄──────────────────────────────────────────► UTILITY │
│ │
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
│ │ Vanilla │ │ CSS │ │ CSS-in- │ │ Tailwind │ │
│ │ CSS │ │ Modules │ │ JS │ │ │ │
│ └───────────┘ └───────────┘ └───────────┘ └───────────┘ │
│ │
│ Global Scoped Scoped Utility-first │
│ BEM/SMACSS Local names JS runtime Atomic classes │
│ Separation Composition Co-location Composition │
│ │
└─────────────────────────────────────────────────────────────────┘
Let's examine each through the lens of scale, not syntax.
Vanilla CSS (Global Stylesheets)
The approach: Global CSS files with naming conventions (BEM, SMACSS, ITCSS) to manage scope manually.
/* styles/components/button.css */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 0.375rem;
font-weight: 500;
transition: background-color 0.2s;
}
.btn--primary {
background-color: var(--color-primary);
color: white;
}
.btn--primary:hover {
background-color: var(--color-primary-dark);
}
.btn--secondary {
background-color: transparent;
border: 1px solid var(--color-border);
color: var(--color-text);
}
.btn--sm { padding: 0.5rem 1rem; font-size: 0.875rem; }
.btn--md { padding: 0.75rem 1.5rem; font-size: 1rem; }
.btn--lg { padding: 1rem 2rem; font-size: 1.125rem; }
.btn--loading {
position: relative;
pointer-events: none;
}
.btn--loading::after {
content: '';
position: absolute;
/* spinner styles */
}
// components/Button.tsx
import './button.css';
export function Button({ variant = 'primary', size = 'md', loading, children }) {
const className = [
'btn',
`btn--${variant}`,
`btn--${size}`,
loading && 'btn--loading',
].filter(Boolean).join(' ');
return <button className={className}>{children}</button>;
}
How It Scales
TEAM SIZE: 1-3 developers
──────────────────────────────────────────────────────────────────
✓ Everyone knows CSS
✓ No build tooling complexity
✓ Fast initial development
✓ Easy to inspect in DevTools
TEAM SIZE: 5-10 developers
──────────────────────────────────────────────────────────────────
⚠ Naming collisions start happening
⚠ "Where does this style come from?" becomes common
⚠ Dead CSS accumulates (nobody dares delete)
⚠ Specificity wars begin
⚠ !important count increases
TEAM SIZE: 10+ developers
──────────────────────────────────────────────────────────────────
✗ Global namespace is a war zone
✗ Changes have unpredictable side effects
✗ Onboarding requires learning "our conventions"
✗ Design system drift is constant
✗ CSS file size grows unbounded (no tree shaking)
The Real Problem: Fear of Deletion
/* Who knows if anything uses this? */
.legacy-card-wrapper {
padding: 1rem;
}
/* Added in 2021, might be important? */
.card-special-case {
margin-top: -2px; /* fixes alignment somewhere */
}
/* TODO: remove after launch (dated 2019) */
.temporary-fix {
display: none !important;
}
Global CSS accumulates because deletion is risky. You can grep for class names, but dynamic class construction, SSR, and third-party code make it unreliable.
When Vanilla CSS Works
✓ Marketing sites with limited interactivity
✓ Small teams with strong CSS discipline
✓ Projects with stable, well-defined scope
✓ When you genuinely need global styles (reset, typography)
CSS Modules
The approach: Locally scoped CSS by default. Class names are transformed at build time to be unique.
/* Button.module.css */
.button {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 0.375rem;
font-weight: 500;
transition: background-color 0.2s;
}
.primary {
background-color: var(--color-primary);
color: white;
}
.primary:hover {
background-color: var(--color-primary-dark);
}
.secondary {
background-color: transparent;
border: 1px solid var(--color-border);
}
.small { padding: 0.5rem 1rem; font-size: 0.875rem; }
.medium { padding: 0.75rem 1.5rem; font-size: 1rem; }
.large { padding: 1rem 2rem; font-size: 1.125rem; }
.loading {
position: relative;
pointer-events: none;
}
// Button.tsx
import styles from './Button.module.css';
import clsx from 'clsx';
interface ButtonProps {
variant?: 'primary' | 'secondary';
size?: 'small' | 'medium' | 'large';
loading?: boolean;
children: React.ReactNode;
}
export function Button({
variant = 'primary',
size = 'medium',
loading,
children,
}: ButtonProps) {
return (
<button
className={clsx(
styles.button,
styles[variant],
styles[size],
loading && styles.loading
)}
>
{children}
</button>
);
}
// Compiled output:
// <button class="Button_button_x7f2s Button_primary_k3j2d Button_medium_p9s3k">
How It Scales
TEAM SIZE: 1-3 developers
──────────────────────────────────────────────────────────────────
✓ Scoping prevents accidental collisions
✓ Still just CSS - familiar to everyone
✓ Co-location with components
✓ Delete component = delete styles (safer)
TEAM SIZE: 5-10 developers
──────────────────────────────────────────────────────────────────
✓ Teams can work independently without conflicts
✓ Clear ownership - styles live with components
⚠ Sharing styles requires explicit composition
⚠ Design tokens need a separate strategy
⚠ Theming requires CSS variables or context
TEAM SIZE: 10+ developers
──────────────────────────────────────────────────────────────────
✓ Scales well - scoping is automatic
✓ Dead code elimination possible (with unused modules)
⚠ Consistency depends on discipline or design system
⚠ No runtime theming without CSS variables
⚠ Composition can lead to duplicate styles
The Composition Question
/* How do you share styles across components? */
/* Option 1: composes (CSS Modules feature) */
.cardButton {
composes: button from './Button.module.css';
width: 100%;
}
/* Option 2: Shared utility module */
/* shared/spacing.module.css */
.mt4 { margin-top: 1rem; }
.mb4 { margin-bottom: 1rem; }
/* Option 3: Global design tokens */
/* tokens.css (imported globally) */
:root {
--space-4: 1rem;
--color-primary: #3b82f6;
}
.button {
padding: var(--space-4);
background: var(--color-primary);
}
The Token Integration Pattern
/* tokens/index.css - imported once globally */
:root {
/* Colors */
--color-primary-50: #eff6ff;
--color-primary-500: #3b82f6;
--color-primary-600: #2563eb;
--color-primary-700: #1d4ed8;
/* Spacing */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
/* Typography */
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
--font-size-lg: 1.125rem;
/* Shadows */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
}
/* Button.module.css - uses tokens */
.button {
padding: var(--space-3) var(--space-4);
font-size: var(--font-size-base);
background: var(--color-primary-500);
box-shadow: var(--shadow-sm);
}
.button:hover {
background: var(--color-primary-600);
box-shadow: var(--shadow-md);
}
When CSS Modules Work
✓ Teams that know and like CSS
✓ Projects needing scoped styles without runtime overhead
✓ When design tokens are managed via CSS variables
✓ Incremental adoption in existing CSS codebases
✓ Server-rendered apps where CSS-in-JS overhead matters
CSS-in-JS (styled-components, Emotion, etc.)
The approach: Write CSS in JavaScript. Styles are co-located, scoped, and can use runtime values.
// Button.tsx (styled-components)
import styled, { css } from 'styled-components';
const sizes = {
small: css`
padding: 0.5rem 1rem;
font-size: 0.875rem;
`,
medium: css`
padding: 0.75rem 1.5rem;
font-size: 1rem;
`,
large: css`
padding: 1rem 2rem;
font-size: 1.125rem;
`,
};
const variants = {
primary: css`
background-color: ${({ theme }) => theme.colors.primary};
color: white;
&:hover {
background-color: ${({ theme }) => theme.colors.primaryDark};
}
`,
secondary: css`
background-color: transparent;
border: 1px solid ${({ theme }) => theme.colors.border};
color: ${({ theme }) => theme.colors.text};
`,
};
interface ButtonProps {
variant?: keyof typeof variants;
size?: keyof typeof sizes;
$loading?: boolean;
}
const StyledButton = styled.button<ButtonProps>`
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 0.375rem;
font-weight: 500;
transition: background-color 0.2s;
border: none;
cursor: pointer;
${({ size = 'medium' }) => sizes[size]}
${({ variant = 'primary' }) => variants[variant]}
${({ $loading }) =>
$loading &&
css`
position: relative;
pointer-events: none;
opacity: 0.7;
`}
`;
export function Button({ variant, size, loading, children }: ButtonProps) {
return (
<StyledButton variant={variant} size={size} $loading={loading}>
{children}
</StyledButton>
);
}
The Runtime Cost Reality
┌─────────────────────────────────────────────────────────────────┐
│ CSS-IN-JS RUNTIME COST │
├─────────────────────────────────────────────────────────────────┤
│ │
│ What happens on render: │
│ │
│ 1. Parse template literal or style object │
│ 2. Resolve dynamic values (props, theme) │
│ 3. Generate unique class name │
│ 4. Check if style already exists │
│ 5. If new, serialize to CSS string │
│ 6. Inject <style> tag into document │
│ 7. Apply class name to element │
│ │
│ This happens for EVERY styled component on EVERY render │
│ (optimizations exist but overhead is non-zero) │
│ │
└─────────────────────────────────────────────────────────────────┘
Bundle size impact:
- styled-components: ~12KB gzipped
- Emotion: ~7KB gzipped
- Your app's style definitions: varies
Runtime impact:
- Initial render: style generation + injection
- Re-renders: prop comparison + possible re-generation
- Memory: style cache grows with unique prop combinations
How It Scales
TEAM SIZE: 1-3 developers
──────────────────────────────────────────────────────────────────
✓ Full power of JavaScript for styling
✓ TypeScript integration for props
✓ Theming is first-class
✓ Dynamic styles are trivial
✓ Delete component = delete styles
TEAM SIZE: 5-10 developers
──────────────────────────────────────────────────────────────────
✓ Strong typing catches style errors at build time
✓ Theme changes propagate automatically
⚠ Performance debugging becomes necessary
⚠ Server rendering complexity (style extraction)
⚠ "Should this be a prop or a variant?" debates
TEAM SIZE: 10+ developers
──────────────────────────────────────────────────────────────────
⚠ Runtime overhead accumulates
⚠ Bundle size grows with styled components
⚠ SSR style extraction is another build step
⚠ Debugging generated class names is harder
✗ React Server Components compatibility issues
The Server Component Problem
// This doesn't work with React Server Components:
// ServerComponent.tsx (Server Component)
import styled from 'styled-components';
// ❌ Error: styled-components requires client-side JavaScript
const Container = styled.div`
padding: 1rem;
`;
export function ServerComponent() {
return <Container>Content</Container>;
}
// You must either:
// 1. Add 'use client' (defeats RSC benefits)
// 2. Use a different styling approach
// 3. Use a zero-runtime CSS-in-JS library
Zero-Runtime Alternatives
// Vanilla Extract - CSS-in-JS at build time
// Button.css.ts
import { style, styleVariants } from '@vanilla-extract/css';
import { vars } from './theme.css';
export const button = style({
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '0.375rem',
fontWeight: 500,
transition: 'background-color 0.2s',
});
export const variants = styleVariants({
primary: {
backgroundColor: vars.colors.primary,
color: 'white',
':hover': {
backgroundColor: vars.colors.primaryDark,
},
},
secondary: {
backgroundColor: 'transparent',
border: `1px solid ${vars.colors.border}`,
},
});
// Button.tsx - works in Server Components!
import * as styles from './Button.css';
export function Button({ variant = 'primary', children }) {
return (
<button className={`${styles.button} ${styles.variants[variant]}`}>
{children}
</button>
);
}
When CSS-in-JS Works
✓ Apps with complex theming requirements
✓ Teams that prefer JavaScript over CSS
✓ When dynamic styles based on props are common
✓ Design systems with typed, enforced APIs
✓ Client-rendered applications (less SSR concerns)
Consider zero-runtime (Vanilla Extract, Linaria) when:
✓ Using React Server Components
✓ Performance is critical
✓ You want CSS-in-JS DX without runtime cost
Tailwind CSS
The approach: Utility-first CSS. Style by composing small, single-purpose classes directly in markup.
// Button.tsx
interface ButtonProps {
variant?: 'primary' | 'secondary';
size?: 'sm' | 'md' | 'lg';
loading?: boolean;
children: React.ReactNode;
}
const baseStyles = 'inline-flex items-center justify-center rounded-md font-medium transition-colors';
const variants = {
primary: 'bg-blue-500 text-white hover:bg-blue-600 active:bg-blue-700',
secondary: 'bg-transparent border border-gray-300 text-gray-700 hover:bg-gray-50',
};
const sizes = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg',
};
export function Button({
variant = 'primary',
size = 'md',
loading,
children,
}: ButtonProps) {
return (
<button
className={`
${baseStyles}
${variants[variant]}
${sizes[size]}
${loading ? 'opacity-70 pointer-events-none' : ''}
`}
>
{loading && <Spinner className="mr-2 h-4 w-4 animate-spin" />}
{children}
</button>
);
}
The "Ugly HTML" Argument
// The complaint:
<div className="flex items-center justify-between p-4 bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 border border-gray-200">
<div className="flex items-center space-x-3">
<img className="w-10 h-10 rounded-full object-cover" src={avatar} alt="" />
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-900">{name}</span>
<span className="text-xs text-gray-500">{role}</span>
</div>
</div>
<button className="px-3 py-1 text-sm font-medium text-blue-600 hover:text-blue-700 hover:bg-blue-50 rounded-md transition-colors">
View
</button>
</div>
// The response: Extract components, not CSS classes
<UserCard user={user} onView={handleView} />
// UserCard is the abstraction, not .user-card CSS class
How It Scales
TEAM SIZE: 1-3 developers
──────────────────────────────────────────────────────────────────
✓ Extremely fast prototyping
✓ No context switching between files
✓ Consistent spacing/colors from day one
✓ Small learning curve (it's just classes)
✓ No unused CSS (PurgeCSS)
TEAM SIZE: 5-10 developers
──────────────────────────────────────────────────────────────────
✓ Consistency enforced by limited options
✓ Component extraction handles complexity
✓ Design system is the config file
⚠ Custom styles need @apply or config extension
⚠ Complex selectors require plugins or escape hatches
TEAM SIZE: 10+ developers
──────────────────────────────────────────────────────────────────
✓ New developers productive quickly (just learn utilities)
✓ No CSS architecture debates (it's decided)
✓ Design system changes = config changes
⚠ Requires component discipline (extract, don't copy-paste)
⚠ Complex animations/interactions need additional approach
⚠ Designers must think in Tailwind constraints
The Design System Integration
// tailwind.config.js IS your design system
module.exports = {
theme: {
// Override defaults completely
colors: {
transparent: 'transparent',
current: 'currentColor',
white: '#ffffff',
black: '#000000',
// Brand colors
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
},
// Semantic colors
success: '#10b981',
warning: '#f59e0b',
error: '#ef4444',
},
// Spacing scale
spacing: {
px: '1px',
0: '0',
0.5: '0.125rem',
1: '0.25rem',
2: '0.5rem',
3: '0.75rem',
4: '1rem',
5: '1.25rem',
6: '1.5rem',
8: '2rem',
10: '2.5rem',
12: '3rem',
16: '4rem',
20: '5rem',
24: '6rem',
},
// Typography
fontSize: {
xs: ['0.75rem', { lineHeight: '1rem' }],
sm: ['0.875rem', { lineHeight: '1.25rem' }],
base: ['1rem', { lineHeight: '1.5rem' }],
lg: ['1.125rem', { lineHeight: '1.75rem' }],
xl: ['1.25rem', { lineHeight: '1.75rem' }],
'2xl': ['1.5rem', { lineHeight: '2rem' }],
},
// Extend (add to defaults)
extend: {
animation: {
'fade-in': 'fadeIn 0.2s ease-out',
'slide-up': 'slideUp 0.3s ease-out',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { transform: 'translateY(10px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
},
},
},
plugins: [
require('@tailwindcss/forms'),
require('@tailwindcss/typography'),
],
};
CVA: The Best of Both Worlds
Class Variance Authority (CVA) brings type safety to Tailwind variants:
// components/Button.tsx
import { cva, type VariantProps } from 'class-variance-authority';
const buttonVariants = cva(
// Base styles
'inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
primary: 'bg-blue-500 text-white hover:bg-blue-600 focus-visible:ring-blue-500',
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200 focus-visible:ring-gray-500',
ghost: 'hover:bg-gray-100 text-gray-700',
destructive: 'bg-red-500 text-white hover:bg-red-600 focus-visible:ring-red-500',
},
size: {
sm: 'h-8 px-3 text-sm',
md: 'h-10 px-4 text-sm',
lg: 'h-12 px-6 text-base',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
},
}
);
interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
loading?: boolean;
}
export function Button({
className,
variant,
size,
loading,
children,
...props
}: ButtonProps) {
return (
<button
className={buttonVariants({ variant, size, className })}
disabled={loading}
{...props}
>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{children}
</button>
);
}
// TypeScript knows the valid variants:
<Button variant="primary" size="lg">Click me</Button> // ✓
<Button variant="invalid">Click me</Button> // ✗ Type error
When Tailwind Works
✓ Teams that value speed and consistency over customization
✓ Projects where design system IS the Tailwind config
✓ When designers can work within utility constraints
✓ Component-based architectures (React, Vue, Svelte)
✓ Projects using shadcn/ui or similar component libraries
The Comparison Matrix
┌──────────────────────────────────────────────────────────────────────────────┐
│ SCALING CHARACTERISTICS │
├───────────────────┬──────────────┬────────────┬─────────────┬────────────────┤
│ │ Vanilla │ CSS │ CSS-in-JS │ Tailwind │
│ │ CSS │ Modules │ (Runtime) │ │
├───────────────────┼──────────────┼────────────┼─────────────┼────────────────┤
│ Scoping │ Manual │ Auto │ Auto │ N/A (util) │
│ Bundle size │ Grows │ Moderate │ Larger │ Small │
│ Runtime cost │ None │ None │ Yes │ None │
│ Type safety │ None │ Partial │ Full │ With CVA │
│ Deletion safety │ Risky │ Safe │ Safe │ Safe │
│ Theming │ Variables │ Variables │ Runtime │ Config │
│ Design tokens │ Manual │ Manual │ Built-in │ Built-in │
│ Server Components │ Yes │ Yes │ No* │ Yes │
│ Learning curve │ Low │ Low │ Medium │ Medium │
│ Onboarding (new) │ Variable │ Easy │ Moderate │ Fast │
├───────────────────┴──────────────┴────────────┴─────────────┴────────────────┤
│ * Zero-runtime CSS-in-JS (Vanilla Extract) works with Server Components │
└──────────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────────┐
│ TEAM SIZE RECOMMENDATIONS │
├───────────────────┬──────────────┬────────────┬─────────────┬────────────────┤
│ │ Vanilla │ CSS │ CSS-in-JS │ Tailwind │
│ │ CSS │ Modules │ │ │
├───────────────────┼──────────────┼────────────┼─────────────┼────────────────┤
│ Solo / 1-2 devs │ ✓ │ ✓ │ ✓ │ ✓ │
│ Small (3-5) │ ⚠ │ ✓ │ ✓ │ ✓ │
│ Medium (5-15) │ ✗ │ ✓ │ ✓ │ ✓ │
│ Large (15+) │ ✗ │ ✓ │ ⚠ │ ✓ │
├───────────────────┴──────────────┴────────────┴─────────────┴────────────────┤
│ ✓ = Recommended ⚠ = Possible with discipline ✗ = Not recommended │
└──────────────────────────────────────────────────────────────────────────────┘
The Real Questions to Ask
Before choosing, answer these:
1. What's Your Team's CSS Proficiency?
Strong CSS team:
→ CSS Modules or Vanilla CSS with conventions
→ They'll appreciate the control, won't need guardrails
Varied CSS proficiency:
→ Tailwind with CVA
→ Constraints prevent bad patterns, faster onboarding
JavaScript-first team:
→ CSS-in-JS or Tailwind
→ Co-location with components feels natural
2. How Important Is Runtime Performance?
Performance critical (e-commerce, content sites):
→ CSS Modules, Tailwind, or zero-runtime CSS-in-JS
→ No runtime style generation overhead
Performance flexible (dashboards, internal tools):
→ CSS-in-JS is fine
→ DX benefits outweigh runtime cost
3. Are You Using Server Components?
Yes, heavily:
→ CSS Modules, Tailwind, or Vanilla Extract
→ Runtime CSS-in-JS won't work
No, or client-heavy:
→ Any approach works
→ Choose based on other factors
4. How Does Your Design System Work?
Design tokens in Figma, need code sync:
→ CSS variables (works with any approach)
→ Style Dictionary can generate for any format
Designers work in code:
→ Tailwind (config is the source of truth)
→ Storybook-driven development
Designers hand off, devs interpret:
→ CSS-in-JS with typed theme
→ Type errors catch design drift
5. What's Your Migration Tolerance?
Greenfield project:
→ Any approach works
→ Choose based on team preferences
Existing codebase:
→ CSS Modules easiest to adopt incrementally
→ Tailwind requires buy-in but is also incremental
→ CSS-in-JS requires wrapper components
Integration Patterns
Design Tokens Across Approaches
// tokens.ts - Source of truth
export const tokens = {
colors: {
primary: {
50: '#eff6ff',
500: '#3b82f6',
600: '#2563eb',
},
gray: {
50: '#f9fafb',
500: '#6b7280',
900: '#111827',
},
},
spacing: {
1: '0.25rem',
2: '0.5rem',
4: '1rem',
8: '2rem',
},
radii: {
sm: '0.25rem',
md: '0.375rem',
lg: '0.5rem',
},
} as const;
// Generate for CSS Modules / Vanilla CSS
export function generateCSSVariables() {
let css = ':root {\n';
// ... traverse tokens and generate --color-primary-50: #eff6ff;
css += '}\n';
return css;
}
// Generate for Tailwind
export function generateTailwindConfig() {
return {
theme: {
colors: tokens.colors,
spacing: tokens.spacing,
borderRadius: tokens.radii,
},
};
}
// Generate for CSS-in-JS
export function generateTheme() {
return {
colors: tokens.colors,
space: tokens.spacing,
radii: tokens.radii,
};
}
Hybrid Approaches
Sometimes you need multiple approaches:
// Global reset and typography: Vanilla CSS
import './globals.css';
// Component styles: Tailwind
export function Card({ children }) {
return (
<div className="bg-white rounded-lg shadow-md p-4">
{children}
</div>
);
}
// Complex animations: CSS Modules
import styles from './AnimatedList.module.css';
export function AnimatedList({ items }) {
return (
<ul className={styles.list}>
{items.map(item => (
<li key={item.id} className={styles.item}>
{item.name}
</li>
))}
</ul>
);
}
// Third-party component customization: CSS-in-JS
import { styled } from 'styled-components';
import DatePicker from 'react-datepicker';
const StyledDatePicker = styled(DatePicker)`
.react-datepicker__header {
background: ${({ theme }) => theme.colors.primary};
}
`;
Common Failure Modes
Failure 1: No Approach (The Worst)
// Developer A prefers inline styles
<div style={{ padding: 16, marginTop: 8 }}>
// Developer B uses BEM
<div className="card__header card__header--highlighted">
// Developer C loves Tailwind
<div className="p-4 mt-2">
// Developer D brought CSS-in-JS
<StyledCard highlighted>
// Result: 4 different systems, no consistency, nightmare to maintain
Failure 2: Tailwind Without Components
// Copy-pasted utilities everywhere
// page1.tsx
<button className="inline-flex items-center px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600">
// page2.tsx (slightly different)
<button className="inline-flex items-center px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600">
// page3.tsx (forgot hover state)
<button className="inline-flex items-center px-4 py-2 bg-blue-500 text-white rounded-md">
// Fix: Extract Button component, use once everywhere
Failure 3: CSS-in-JS Prop Explosion
// Every visual property is a prop
<Box
display="flex"
alignItems="center"
justifyContent="space-between"
padding={4}
marginTop={2}
backgroundColor="gray.50"
borderRadius="md"
boxShadow="sm"
_hover={{ boxShadow: 'md' }}
>
// You've recreated Tailwind but with runtime cost and more verbosity
// Either use Tailwind, or design intentional component variants
Failure 4: CSS Modules Without Tokens
/* button.module.css */
.button {
background: #3b82f6; /* Magic number */
padding: 8px 16px; /* Magic number */
border-radius: 6px; /* Magic number */
}
/* card.module.css */
.card {
background: #3c82f6; /* Typo - slightly different blue */
padding: 12px; /* Different padding scale */
border-radius: 8px; /* Different radius */
}
/* Fix: Use CSS variables for all design tokens */
The Decision Framework
┌─────────────────────────────────────────────────────────────────┐
│ CSS ARCHITECTURE DECISION TREE │
└─────────────────────────────────────────────────────────────────┘
Using React Server Components heavily?
├── Yes → Runtime CSS-in-JS is out
│ ├── Team prefers utilities? → Tailwind
│ ├── Team prefers CSS? → CSS Modules
│ └── Want CSS-in-JS DX? → Vanilla Extract
│
└── No → All options available
│
├── Need runtime theming (user-selectable themes)?
│ ├── Yes → CSS-in-JS or Tailwind + CSS variables
│ └── No → Any approach works
│
├── Team size > 10?
│ ├── Yes → Strong conventions required
│ │ ├── Want enforced constraints? → Tailwind
│ │ └── Want flexibility? → CSS Modules + strict tokens
│ └── No → Team preference matters more
│
└── Existing codebase?
├── Global CSS chaos → CSS Modules (incremental adoption)
├── Component library exists → Match its approach
└── Greenfield → Best DX for your team
Quick Reference
Choose Vanilla CSS When
- Small project, stable scope
- Team has strong CSS discipline
- Global styles are actually needed (marketing site)
- Progressive enhancement is critical
Choose CSS Modules When
- Want scoped CSS without runtime
- Team knows CSS, wants familiar syntax
- Incremental migration from global CSS
- Server components are primary
Choose CSS-in-JS When
- Complex runtime theming requirements
- Team prefers JavaScript over CSS
- Type safety for styles is valuable
- Client-rendered application
Choose Tailwind When
- Speed of development is priority
- Want design system as configuration
- Component-based architecture
- Team varies in CSS experience
The Universal Rules
1. Pick ONE primary approach and document it
2. Design tokens must exist regardless of approach
3. Extract components, not just CSS classes
4. Delete unused styles (automated or by scoping)
5. Onboarding docs should explain "how we style"
Closing Thoughts
CSS architecture is a team scaling problem disguised as a technical decision. The "best" approach is the one your team will actually follow consistently.
The failure mode isn't picking the wrong framework — it's picking no framework, or picking one and not enforcing it. A mediocre CSS architecture followed consistently beats a perfect architecture followed inconsistently.
Whatever you choose:
- Document it
- Enforce it (linting, code review)
- Provide escape hatches (they'll be needed)
- Revisit when the team grows or pain emerges
The decision matters. Take it seriously before it hurts.
What did you think?