CSS in Component Frameworks

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

ConceptKey point
CSS ModulesBuild-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-JSvanilla-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 stylesAdd 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 stylesAll <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 featuresVite 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
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.