Chapter 10 — CSS in Component Frameworks
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: prose from './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 function Card({ 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.
/* ── Styled-components (runtime CSS-in-JS) ──────────────────── */
import styled from 'styled-components';
const Button = styled.button`
padding: 0.6rem 1.2rem;
border-radius: 6px;
font-weight: 700;
background: ${props => props.variant === 'primary'
? 'oklch(0.55 0.22 248)'
: 'oklch(0.20 0.02 248)'};
color: white;
border: 1px solid ${props => props.variant === 'primary'
? 'transparent'
: 'oklch(0.30 0.05 248)'};
&:hover {
filter: brightness(1.1);
}
`;
/* Usage */
<Button variant="primary">Save</Button>
<Button>Cancel</Button>
/* styled-components injects a <style> tag at runtime with a hashed class: */
/* .sc-abc123 { padding: 0.6rem 1.2rem; background: oklch(0.55 0.22 248); } */
/* ── 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 {
@apply inline-flex items-center gap-2 px-4 py-2 rounded-md font-medium;
@apply bg-accent-500 text-white transition-colors;
}
.btn:hover {
@apply bg-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.