Component-based frameworks — React, Vue, Svelte, and others — changed how
we think about styling. Instead of one global stylesheet ruling the whole page,
each component owns its styles. Four main approaches emerged: plain CSS with
naming conventions (BEM), CSS Modules, CSS-in-JS, and utility-first CSS.
Each solves the scoping and co-location problem differently, with different
trade-offs for performance, DX, and long-term maintainability.
Global CSS + BEM
✓ No build step. Full CSS feature set. Easy to inspect.
✗ Manual scoping discipline. Global leaks possible. Naming overhead.
CSS Modules
✓ Automatic scoping. Real CSS. Works with any bundler.
✗ Co-location requires separate file. Dynamic styles need inline or data attributes.
CSS-in-JS
✓ JS-driven dynamic styles. Typed props. Zero dead CSS.
✗ Runtime cost. Larger bundle. Server rendering complexity.
Utility-first
✓ Tiny CSS bundle. No naming. Fast iteration. Design system built-in.
✗ Verbose markup. Hard to extract semantic meaning. Custom CSS still needed.
1. CSS Modules
CSS Modules processes a .module.css file at build time and generates
unique class names scoped to that file. The component imports the generated name map
as a JavaScript object and applies names via styles.className. The
generated output is plain CSS — no runtime overhead.
/* ── Card.module.css ────────────────────────────────────────── */.card{background:var(--color-surface);border:1px solid var(--color-border);border-radius:8px;padding:1.25rem;}.title{font-size:1.1rem;font-weight:700;color:var(--color-text);margin:0 0 0.5rem;}/* composes — inherit styles from another class (same file or another module) */.cardHighlighted{composes:card;/* same file */border-color:var(--color-accent);}.body{composes:prosefrom'./typography.module.css';/* another module */color:var(--color-text-sub);}/* :global — escape scoping for third-party overrides */.card :global(.react-tooltip){font-size:0.8rem;}/* :local — explicitly scoped (the default, rarely needed) */:local(.icon){width:1.25rem;}
/* ── Card.jsx — React component using CSS Modules ──────────── */import styles from'./Card.module.css';export functionCard({ title, body, highlighted }) {return(
<div className={highlighted ? styles.cardHighlighted : styles.card}>
<h2 className={styles.title}>{title}</h2>
<p className={styles.body}>{body}</p>
</div>
);}/* Build output — generated class names (hashes vary per bundler): *//* .card → ._card_x7k2p_1 *//* .title → ._title_x7k2p_12 *//* No collision with any other component's .card or .title class *//* ── Composing classes conditionally (clsx / classnames helper) ── */import clsx from'clsx';const className =clsx(
styles.card,
highlighted && styles.highlighted,
size === 'lg'&& styles.large,);/* ── Dynamic values: CSS custom properties are the clean solution ─ */.progress{width:var(--progress-width);/* set from JS via inline style */}/* In JSX — inline style sets the custom property, module CSS reads it */<div className={styles.progress} style={{ '--progress-width':`${pct}%`}} />
CSS Modules + custom properties is a powerful combination.
The module handles scoping and static styles; custom properties bridge the gap
for values that change based on JavaScript state. No runtime style injection needed.
2. CSS-in-JS
CSS-in-JS libraries write CSS directly inside JavaScript files, often as tagged
template literals or style objects. They generate and inject styles at runtime
(or at build time in newer zero-runtime approaches). The main appeal is that
component props can drive styles with full JavaScript logic — no custom properties
needed as an intermediary.
/* ── Emotion — similar API, popular in design systems ───────── */import{ css, cx }from'@emotion/css';const cardStyle = css`
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 1.25rem;
`;const highlightedStyle = css`
border-color: var(--color-accent);
`;/* cx merges class names, deduplicating where possible */<div className={cx(cardStyle, highlighted && highlightedStyle)}>/* ── Zero-runtime CSS-in-JS (the modern shift) ─────────────── *//* Libraries: vanilla-extract, Linaria, Panda CSS, StyleX (Meta) *//* These extract CSS at build time — no <style> injection at runtime *//* Result: plain .css files, zero JS overhead, full SSR compatibility *//* ── vanilla-extract example ─────────────────────────────────── *//* card.css.ts — TypeScript file, processed at build time */import{ style, styleVariants }from'@vanilla-extract/css';export const card =style({
background:'var(--color-surface)',
borderRadius:8,
padding:'1.25rem',});export const variants =styleVariants({
default:{ border:'1px solid var(--color-border)'},
highlighted:{ border:'1px solid var(--color-accent)'},});/* Build output: Card.css (static file) + hashed class names *//* TypeScript knows the exact class names — autocomplete works */
3. Utility-First CSS
Utility-first CSS (popularised by Tailwind CSS) replaces custom class names
with small, single-purpose classes. Instead of writing a .card class
that sets background, padding, and border-radius, you apply
bg-surface p-5 rounded-lg directly in the markup.
The CSS bundle stays tiny because only classes actually used in the HTML are
generated — unused utilities are tree-shaken away.
/* ── How Tailwind generates its CSS ─────────────────────────── *//* tailwind.config.js — scans all template files for class names */export default{
content: ['./src/**/*.{html,js,jsx,ts,tsx,vue,svelte}'],
theme: {
extend: {
colors: {
accent:{500:'oklch(0.60 0.18 248)',400:'oklch(0.70 0.18 248)',},},},},};/* ── HTML / JSX using Tailwind classes ───────────────────────── *//* A card with title and body — no .css file written at all */<div className="bg-surface border border-border rounded-lg p-5"><h2 className="text-lg font-bold text-text mb-2">Title</h2><p className="text-sm text-text-sub">Body text</p></div>/* ── Extracting a reusable component class with @apply ─────── *//* Use sparingly — @apply is useful for prose and form elements *//* Not recommended for general component styling (defeats the point) */.btn{@applyinline-flex items-center gap-2 px-4 py-2 rounded-md font-medium;@applybg-accent-500 text-white transition-colors;}.btn:hover{@applybg-accent-400;}/* ── Arbitrary values — escape hatch for one-off values ──────── *//* Wrap in square brackets for values not in the design system */<div className="w-[342px] top-[17px] bg-[oklch(0.55_0.22_248)]"></div>/* ── Dynamic classes — the key Tailwind gotcha ───────────────── *//* ❌ This does NOT work — Tailwind cannot see dynamically constructed strings */const cls =`bg-${color}-500`;/* 'bg-blue-500' — not scanned, not generated *//* ✓ Use full class names in a lookup object */const colorMap = {
blue:'bg-blue-500',
green:'bg-green-500',
red:'bg-red-500',};const cls = colorMap[color];/* full strings — Tailwind sees all three at scan time */
4. Scoped Styles in Vue and Svelte
/* ── Vue Single File Component (SFC) ─────────────────────────── */<!-- Card.vue --><template><div :class="['card', highlighted &&'card--highlighted']"><h2 class="card__title">{{ title }}</h2></div></template><style scoped>/* scoped adds a unique data attribute to all selectors *//* .card → .card[data-v-a3f2b1] — no global leak */.card{background:var(--color-surface);border-radius:8px;padding:1.25rem;}.card--highlighted{border:1px solid var(--color-accent);}/* Penetrate scoping to reach child component's DOM elements */.card :deep(.icon){color:var(--color-accent);}/* Inject global rules from inside the SFC */:global(.some-third-party){font-size:0.9rem;}</style>/* ── Vue CSS v-bind — JS values directly in scoped CSS ─────── */<script setup>import{ ref }from'vue';const accentColor =ref('oklch(0.55 0.22 248)');</script><style scoped>.card{border-color:v-bind(accentColor);/* reactive JS ref → CSS value *//* Vue sets this as an inline custom property at runtime */}</style>
/* ── Svelte scoped styles ─────────────────────────────────────── */<!-- Card.svelte --><script>export let highlighted =false;</script><div class="card" class:highlighted><slot /></div><style>/* All styles in .svelte are automatically scoped — no keyword needed *//* Svelte adds svelte-HASH to selectors at compile time */.card{background:var(--color-surface);border-radius:8px;padding:1.25rem;}/* class: directive adds 'highlighted' class when the prop is truthy */.highlighted{border:1px solid var(--color-accent);}/* :global — escape Svelte scoping */:global(body){font-family:system-ui, sans-serif;}</style>
5. How Bundlers Handle CSS
/* ── Vite CSS handling (the modern default) ──────────────────── *//* *.module.css → CSS Modules (automatic) *//* *.css → global CSS injected via <style> tag in dev, extracted in build *//* *.module.css with ?inline → returns CSS string, no injection *//* vite.config.js — CSS options */export default{
css: {
modules: {
localsConvention:'camelCase',/* .my-class → styles.myClass */
generateScopedName:'[name]__[local]__[hash:5]',},
preprocessorOptions: {
scss: { additionalData:'@use "./src/tokens" as *;'},},},};/* ── Code splitting — CSS follows JS chunks ──────────────────── *//* When a route is lazy-loaded, its CSS is also in a separate chunk *//* The browser fetches the CSS chunk only when the route is visited *//* This keeps initial CSS payload small for large apps *//* ── Critical CSS extraction ─────────────────────────────────── *//* Tools: critters (used by Angular), penthouse, critical *//* Extract above-fold CSS → inline in <head> → defer rest *//* Result: page renders without waiting for the full CSS bundle *//* Manual critical CSS pattern */<!-- In <head> --><style> /* critical above-fold CSS here */ </style><link rel="preload" href="/styles.css" as="style" onload="this.rel='stylesheet'"><noscript><link rel="stylesheet" href="/styles.css"></noscript>/* ── CSS tree-shaking with PurgeCSS / Tailwind ───────────────── *//* Tailwind's JIT engine generates only the classes actually used in HTML *//* PurgeCSS does the same post-build for any CSS framework */import purgecss from'@fullhuman/postcss-purgecss';export default{
plugins: [purgecss({
content: ['./src/**/*.html','./src/**/*.jsx'],
safelist: [/^js-/],/* preserve classes added dynamically by JS */}),],};
6. Choosing an Approach
Approach
Runtime cost
Dynamic styles
Scoping
DX
Best for
Global CSS + BEM
Zero
Custom props
Manual
Familiar
Small sites, no framework
CSS Modules
Zero
Custom props
Automatic
Good
React apps, design systems
styled-components / Emotion
Runtime
Full JS
Automatic
Excellent
Highly dynamic UIs
vanilla-extract / Linaria
Zero
Custom props
Automatic
Typed
Large-scale typed codebases
Tailwind CSS
Zero
Lookup maps
No leaks
Fast iterations
Rapid prototyping, startups
Vue scoped / Svelte
Zero
v-bind / stores
Automatic
Native
Vue/Svelte projects
The modern consensus: runtime CSS-in-JS (styled-components,
Emotion) peaked around 2020 and is declining in new projects due to server
rendering complexity and performance cost. Zero-runtime approaches (vanilla-extract,
CSS Modules + custom properties, Tailwind) are the current direction for
performance-sensitive applications.
Chapter Summary
Concept
Key point
CSS Modules
Build-time scoping via hashed class names. Import as a JS object (styles.card). Use composes: for inheritance. Bridge dynamic values with CSS custom properties set as inline styles. Zero runtime cost. Works with any bundler that supports CSS Modules.
CSS-in-JS (runtime)
styled-components and Emotion inject <style> tags at runtime driven by JS props. Full JavaScript logic in styles. Cost: runtime overhead, larger bundle, SSR complexity. Declining for new projects.
Zero-runtime CSS-in-JS
vanilla-extract, Linaria, StyleX extract CSS at build time. TypeScript types for class names. Same scoping as CSS Modules but with typed style objects. No runtime cost. The direction for typed codebases.
Utility-first (Tailwind)
Atomic utility classes in markup. Only used classes are generated — tiny bundles. Dynamic class names must be full strings in source (no string interpolation). Use @apply sparingly for semantic extraction. Arbitrary values via bracket syntax.
Vue scoped styles
Add scoped to the <style> block — Vue appends a data attribute to all selectors. Use :deep() to reach child component elements. v-bind() in CSS reads reactive JS values and injects them as inline custom properties.
Svelte styles
All <style> content is automatically scoped at compile time (no keyword needed). class: directive adds/removes a class based on a boolean prop. Use :global() to escape scoping.
Bundler CSS features
Vite handles CSS Modules, PostCSS, and preprocessors out of the box. CSS follows JS code-splitting — lazy routes get their own CSS chunk. Critical CSS extraction inlines above-fold CSS in <head> and defers the rest. PurgeCSS / Tailwind JIT remove unused CSS at build time.
Exercises
CSS Modules card system: Create a Card.module.css with a base .card class. Add a .cardFeatured class that uses composes: card and adds an accent border. Add a .cardCompact variant with reduced padding. In a React component, accept a variant prop ('default' | 'featured' | 'compact') and map it to the correct module class. Use clsx to handle the mapping cleanly. Bridge the card's background colour to a JS prop using an inline style attribute setting --card-bg as a custom property and reading it in the CSS module.
Dynamic Tailwind class lookup: Build a badge component that accepts a status prop: 'success' | 'warning' | 'error' | 'info'. Create a lookup object that maps each status to a complete set of Tailwind classes (background, text colour, border). Verify that each full class string appears in the source file so Tailwind's scanner can find it. Add a fifth status dynamically from a user input and observe that classes generated by string interpolation are absent from the build output.
Vue CSS v-bind: In a Vue SFC, create a colour-picker that sets a reactive themeColor ref. Use v-bind(themeColor) in the scoped styles to apply it as a border, background tint (color-mix(in oklch, v-bind(themeColor) 15%, transparent)), and text colour on a card component. Observe in DevTools how Vue sets the value as an inline CSS custom property on the element and updates it reactively.
vanilla-extract typed tokens: Create a tokens.css.ts file using createGlobalTheme from vanilla-extract to define colour, spacing, and typography tokens. Create a button.css.ts that imports those tokens and uses styleVariants to define primary, secondary, and ghost button styles. Inspect the build output — confirm a static .css file was generated and that the TypeScript types catch a typo in a token name at compile time.
Critical CSS audit: Take an existing multi-section page. Using Chrome DevTools → Coverage panel, record which CSS rules are used on first load vs after scrolling. Identify the above-fold critical CSS. Manually extract it into a <style> block in <head> and convert the main stylesheet link to use the preload / onload pattern. Measure the difference in Largest Contentful Paint (LCP) in the Performance panel before and after.
Next: Chapter 11 — Building a Design System / Token-Based Theming.
Primitive, semantic, and component token tiers; CSS custom property architecture;
multi-brand theming with a single token layer swap; dark mode with
light-dark() and color-scheme; generating tokens from
Figma; documenting and versioning a token system; and integrating tokens
across CSS Modules, Tailwind, and vanilla-extract.