CSS Architecture: Tailwind vs CSS Modules vs CSS-in-JS
A practical guide to CSS architecture patterns — when to use Tailwind for rapid development, CSS Modules for component scoping, or CSS-in-JS for dynamic styling.
A practical guide to Vanilla Extract — type-safe styles at build time, theming with CSS variables, recipes and sprinkles, and when to pick it over Tailwind or CSS Modules.
By codefunc
Styled Components and Emotion solved co-location and theming, but they ship a runtime CSS engine to the browser. CSS Modules scope class names without runtime cost, but theming and variants get awkward fast. Vanilla Extract sits in the middle: you write styles in TypeScript, get type checking and autocomplete, and the build step emits plain static CSS — nothing executes in the browser.
Bottom line: Use Vanilla Extract when you want CSS Modules–style output with TypeScript safety, first-class theming, and zero runtime overhead. It fits design-system work, component libraries, and apps where bundle size and Server Component compatibility matter. Skip it for quick prototypes (Tailwind is faster to start) or when your team does not use TypeScript.
Vanilla Extract evaluates .css.ts files at build time. The TypeScript in those files never ships to users. You export class names, theme tokens, and variables; the bundler plugin turns them into scoped CSS files.
// button.css.ts
import { style } from '@vanilla-extract/css';
export const button = style({
padding: '0.5rem 1rem',
borderRadius: '0.5rem',
fontWeight: 500,
border: 'none',
cursor: 'pointer',
});
// Button.tsx
import { button } from './button.css.ts';
export function Button({ children }: { children: React.ReactNode }) {
return <button className={button}>{children}</button>;
}
Generated CSS (simplified):
.button_button__x7f3k {
padding: 0.5rem 1rem;
border-radius: 0.5rem;
font-weight: 500;
border: none;
cursor: pointer;
}
The mental model: TypeScript is your preprocessor, not your runtime. Think Sass with types, not styled-components without the engine.
| Feature | Vanilla Extract | CSS Modules | Styled Components | Tailwind |
|---|---|---|---|---|
| Runtime cost | None | None | Yes (~10–15KB) | None |
| Type safety | Yes (CSSType) | No | Partial | With plugins |
| Theming | First-class | Manual (CSS vars) | Built-in | Config tokens |
| Dynamic styles | Limited (recipes, vars) | No | Yes | Limited |
| Server Components | Yes | Yes | Partial | Yes |
| Learning curve | Medium | Low | Medium | Medium |
| Build tooling required | Yes | Minimal | Minimal | PostCSS |
Install the core package and a bundler integration:
npm install @vanilla-extract/css
npm install -D @vanilla-extract/vite-plugin
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin';
export default defineConfig({
plugins: [react(), vanillaExtractPlugin()],
});
npm install -D @vanilla-extract/next-plugin
// next.config.ts
import { createVanillaExtractPlugin } from '@vanilla-extract/next-plugin';
const withVanillaExtract = createVanillaExtractPlugin();
export default withVanillaExtract({
// your Next.js config
});
npm install -D @vanilla-extract/webpack-plugin
// webpack.config.js
const { VanillaExtractPlugin } = require('@vanilla-extract/webpack-plugin');
module.exports = {
plugins: [new VanillaExtractPlugin()],
};
Official integrations also exist for esbuild, Parcel, Rollup, and Gatsby. Pick the one that matches your bundler — the API in your .css.ts files stays the same.
style — single classThe workhorse. Accepts any valid CSS property with TypeScript autocomplete via CSSType.
import { style } from '@vanilla-extract/css';
export const card = style({
padding: '1rem',
borderRadius: '0.5rem',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
backgroundColor: 'white',
':hover': {
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
},
'@media': {
'(min-width: 768px)': {
padding: '1.5rem',
},
},
});
Pseudo-classes, media queries, container queries, and nested selectors all work — it is still CSS under the hood.
styleVariants — named variantsAvoid string-concatenation for button sizes, alert types, or any prop-driven class lookup.
import { styleVariants } from '@vanilla-extract/css';
const base = {
padding: '0.5rem 1rem',
borderRadius: '0.5rem',
fontWeight: 500,
};
export const button = styleVariants({
primary: {
...base,
backgroundColor: '#2563eb',
color: 'white',
},
secondary: {
...base,
backgroundColor: '#e5e7eb',
color: '#111827',
},
danger: {
...base,
backgroundColor: '#dc2626',
color: 'white',
},
});
<button className={button.primary}>Save</button>
<button className={button.danger}>Delete</button>
TypeScript knows the keys — typos fail at compile time, not in production.
globalStyle — unscoped rulesFor resets, typography defaults, or third-party overrides:
import { globalStyle } from '@vanilla-extract/css';
globalStyle('body', {
margin: 0,
fontFamily: 'system-ui, sans-serif',
lineHeight: 1.5,
});
globalStyle('*, *::before, *::after', {
boxSizing: 'border-box',
});
keyframes and fontFaceScoped animation names and font declarations, exported like any other style:
import { keyframes, style } from '@vanilla-extract/css';
const fadeIn = keyframes({
'0%': { opacity: 0, transform: 'translateY(8px)' },
'100%': { opacity: 1, transform: 'translateY(0)' },
});
export const animated = style({
animation: `${fadeIn} 300ms ease-out`,
});
createThemeThis is where Vanilla Extract pulls ahead of plain CSS Modules. createTheme generates a scoped class and a matching set of CSS custom properties with a type-safe contract.
// theme.css.ts
import { createTheme } from '@vanilla-extract/css';
export const [themeClass, vars] = createTheme({
color: {
background: '#ffffff',
foreground: '#111827',
primary: '#2563eb',
primaryForeground: '#ffffff',
},
space: {
sm: '0.5rem',
md: '1rem',
lg: '1.5rem',
},
radius: {
md: '0.5rem',
},
});
// button.css.ts
import { style } from '@vanilla-extract/css';
import { vars } from './theme.css.ts';
export const button = style({
padding: `${vars.space.sm} ${vars.space.md}`,
borderRadius: vars.radius.md,
backgroundColor: vars.color.primary,
color: vars.color.primaryForeground,
});
// App.tsx
import { themeClass } from './theme.css.ts';
export function App({ children }) {
return <div className={themeClass}>{children}</div>;
}
Swap themes by applying a different theme class — no runtime style injection:
import { createTheme, createThemeContract } from '@vanilla-extract/css';
export const vars = createThemeContract({
color: {
background: null,
foreground: null,
primary: null,
},
});
export const lightTheme = createTheme(vars, {
color: {
background: '#ffffff',
foreground: '#111827',
primary: '#2563eb',
},
});
export const darkTheme = createTheme(vars, {
color: {
background: '#111827',
foreground: '#f9fafb',
primary: '#3b82f6',
},
});
<div className={isDark ? darkTheme : lightTheme}>
<Content />
</div>
Components reference vars.color.primary — the value resolves from whichever theme class is active on an ancestor.
Vanilla Extract ships higher-level packages for common patterns.
When a component has several independent variant axes (size × color × outline):
import { recipe } from '@vanilla-extract/recipes';
export const button = recipe({
base: {
borderRadius: '0.5rem',
fontWeight: 500,
cursor: 'pointer',
},
variants: {
size: {
sm: { padding: '0.25rem 0.75rem', fontSize: '0.875rem' },
md: { padding: '0.5rem 1rem', fontSize: '1rem' },
lg: { padding: '0.75rem 1.5rem', fontSize: '1.125rem' },
},
intent: {
primary: { backgroundColor: '#2563eb', color: 'white' },
ghost: { backgroundColor: 'transparent', color: '#2563eb' },
},
},
defaultVariants: {
size: 'md',
intent: 'primary',
},
});
<button className={button({ size: 'lg', intent: 'ghost' })}>Cancel</button>
Sprinkles is Vanilla Extract's answer to Tailwind: responsive, conditional utility classes generated at build time with zero runtime.
import { defineProperties, createSprinkles } from '@vanilla-extract/sprinkles';
const responsiveProperties = defineProperties({
properties: {
display: ['none', 'flex', 'block'],
paddingTop: ['0', '4px', '8px', '16px'],
gap: ['0', '4px', '8px', '16px'],
},
shorthands: {
paddingY: ['paddingTop', 'paddingBottom'],
},
});
export const sprinkles = createSprinkles(responsiveProperties);
Use Sprinkles for layout primitives; use style and recipe for component-specific styles. Many teams combine both.
Vanilla Extract is build-time first. You cannot do style({ width: props.width }) with arbitrary runtime values the way Styled Components allows.
Works at build time:
styleVariants and recipe for known variant setsassignInlineVars for scoped variable overrides on a single elementimport { assignInlineVars } from '@vanilla-extract/dynamic';
import { vars } from './theme.css.ts';
// In component:
<div
className={progressBar}
style={assignInlineVars({
[vars.color.primary]: customColor,
})}
/>
Does not work:
For those cases, inline styles or a small runtime layer (@vanilla-extract/dynamic) are the escape hatches. Plan your token contracts upfront.
CSS Modules give you scoping. Vanilla Extract gives you scoping plus typed themes, variants, and keyframes — without maintaining parallel .module.css files. The tradeoff is build plugin setup and a .css.ts convention your team must adopt.
Runtime CSS-in-JS libraries excel at arbitrary dynamic styles. Vanilla Extract wins on bundle size, SSR/RSC compatibility, and performance — there is no style recalculation on re-render. If your theming is token-based rather than prop-per-style, Vanilla Extract is usually the better fit in 2026.
Tailwind optimizes for speed of iteration in markup. Vanilla Extract optimizes for design-system rigor in TypeScript. Tailwind's class strings can grow long; Vanilla Extract keeps JSX clean with semantic class exports. Some teams use Sprinkles for utilities and recipes for components — a hybrid that stays zero-runtime.
A typical layout:
src/
styles/
theme.css.ts # tokens and theme classes
reset.css.ts # globalStyle rules
sprinkles.css.ts # utility definitions
components/
Button/
Button.tsx
button.css.ts # component styles
Card/
Card.tsx
card.css.ts
Rules that keep things maintainable:
.css.ts file per component (or feature folder)theme.css.ts — never hardcode colors in component stylesrecipe when you have more than two variant dimensions.css.ts files free of React imports — styles are a separate layerBuild errors usually mean invalid CSS values — TypeScript catches most of these, but shorthand mistakes can slip through.
Finding generated class names: Inspect the element in DevTools. Scoped names look like button_primary__abc123.
Dev mode: Vanilla Extract supports a runtime dev version for faster feedback in tests. Production builds always emit static CSS.
For inspecting output CSS before deploy, run your production build and check the emitted stylesheets — or paste chunks into the CSS Formatter to read them more easily.
Need arbitrary runtime styles from props?
├── Yes → Styled Components / Emotion, or inline styles
└── No → Want utility-first markup speed?
├── Yes → Tailwind (or Sprinkles on top of Vanilla Extract)
└── No → Need typed themes and zero runtime?
├── Yes → Vanilla Extract
└── No → CSS Modules
Use these codefunc tools alongside this guide:
Vanilla Extract is the strongest option when you want type-safe, token-driven styles without shipping a CSS runtime. Set up the bundler plugin once, define your theme contract, and build components with style, styleVariants, or recipe. Use CSS variables and theme classes for dark mode and brand switching. Reach for Sprinkles when you miss utility classes. Skip it if your team is not on TypeScript or you need fully dynamic per-prop styling.
A practical guide to CSS architecture patterns — when to use Tailwind for rapid development, CSS Modules for component scoping, or CSS-in-JS for dynamic styling.
Choose transitions vs @keyframes, pick units that scale, and build reusable motion snippets — with performance and accessibility rules that keep UI snappy.
Build practical gradient and shadow design tokens, pick easing curves that feel right, and avoid paint-heavy effects that tank scroll performance.
Find a developer tool