● David R. Longnecker
Chapter 14 · Architecture & Practice

Design Systems & CSS

A design system is what keeps a hundred components looking like they were built by one person on one afternoon, instead of forty people over three years. The mechanism that makes this practical in CSS is deceptively simple: variables. This chapter builds up from raw color values to a full token system with dark mode, and draws the line between styles that are safe to share and styles that must stay local.

CSS custom properties #

A CSS custom property (see MDN's reference) is a variable, declared with a double-dash prefix and read with var():

a custom property, declared and used
:root {
  --accent: #c1502e;
}

.button-primary {
  background: var(--accent);
}

The payoff isn't the syntax, it's where the value lives. Change --accent once, in one place, and every rule that reads var(--accent) updates. Compare that to a codebase where #c1502e is typed literally into forty different stylesheets — changing the brand color becomes a find-and-replace across the whole project, and you'll inevitably miss one.

Primitive tokens vs. semantic tokens #

A mature design token system has two layers, not one. The bottom layer is primitives: a raw scale of values with no opinion about what they're for — just "here are the oranges we use," numbered from light to dark. The layer above is semantic tokens: names that describe a role — "the color of a border," "the color of primary text" — and each one points at a primitive.

primitives: raw values, no opinions
:root {
  --orange-40: #f3ddd0;
  --orange-60: #e8865b;
  --orange-70: #c1502e;
  --orange-80: #a3401f;

  --neutral-10: #faf7f2;
  --neutral-90: #241f1a;
}
semantic tokens: roles that point at primitives
:root {
  --bg-base: var(--neutral-10);
  --fg-1: var(--neutral-90);
  --accent: var(--orange-70);
  --accent-hover: var(--orange-80);
  --accent-soft: var(--orange-40);
}

Components should only ever reference semantic tokens, never primitives directly. A button reads var(--accent), not var(--orange-70). That indirection is what makes theming possible: if "accent" is redefined to point at a different primitive, or reassigned entirely under a different selector, every component that consumes --accent updates without a single line of component code changing.

A token pyramid: primitives at the base, semantic tokens in the middle, components consuming semantic tokens at the top Components .btn, .card, .badge Semantic tokens --accent, --fg-1, --border-default Primitive tokens --orange-70, --neutral-90, --neutral-10 raw color/size scales, no meaning attached
Values flow upward: primitives feed semantic tokens, semantic tokens feed components. Components never skip the middle layer.

Dark mode: reassigning tokens, not rewriting styles #

Because components only ever read semantic tokens, dark mode becomes a matter of reassigning those tokens under a different selector — not writing a second stylesheet, and not touching a single component:

dark mode reassigns the same semantic names
[data-theme="dark"] {
  --bg-base: #16130f;
  --fg-1: #f4ede1;
  --accent: #e8865b;
  --border-default: #3d3325;
}

Toggling dark mode is then just setting an attribute on the root element:

document.documentElement.setAttribute("data-theme", "dark");

Every component that was already written against var(--bg-base) and var(--fg-1) repaints instantly. No component needed to know dark mode existed.

Why this matters

This is the same "program against the interface, not the implementation" idea from Chapter 13, applied to color. Components depend on the semantic name, never the concrete value, so the value can change underneath them.

That single line is enough to demonstrate the token mechanism, but it's not enough for a real toggle. Two things are missing:

a theme choice that persists and respects the OS default
const saved = localStorage.getItem("theme");
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
const theme = saved ?? (prefersDark ? "dark" : "light");

document.documentElement.setAttribute("data-theme", theme);

function setTheme(next) {
  document.documentElement.setAttribute("data-theme", next);
  localStorage.setItem("theme", next);
}
Where you run that script matters

If the snippet above runs after the page has already started rendering — inside a mounted component, for instance — a user with dark mode saved sees a flash of the light theme before JavaScript catches up and switches it, sometimes called a "flash of wrong theme." Since this course's whole premise is plain HTML with no build step, the fix is straightforward here: put an inline, synchronous <script> containing this logic directly in <head>, before any stylesheet or visible content, so the attribute is set before the browser paints anything. In a framework app, the equivalent is running this logic as early as possible, before the app mounts.

Spacing and radius scales #

The same token approach applies to anything that benefits from consistency: spacing, corner radius, type size. Instead of every component inventing its own padding value, they all pick from a shared, small scale:

a spacing and radius scale
:root {
  --space-1: 4px;
  --space-2: 8px;
  --space-3: 12px;
  --space-4: 16px;
  --space-6: 24px;

  --radius-sm: 6px;
  --radius-md: 10px;
  --radius-lg: 16px;
}

.card {
  padding: var(--space-4);
  border-radius: var(--radius-md);
}
.modal {
  border-radius: var(--radius-lg);
}

A small, deliberate scale (four to eight steps) is a feature, not a limitation. It forces every component to align to the same rhythm instead of accumulating one-off values like 13px and 15px that only ever existed because someone eyeballed it once.

Shared stylesheet vs. scoped styles #

A design system needs exactly two kinds of CSS, and confusing them is the most common way a frontend's styles rot over time.

Global stylesheet

Tokens, resets, and genuinely reusable classes — .btn, .card, .badge — that appear across many components. Anyone can use them; nobody should casually add a new global class without checking whether one already covers the case.

Scoped styles

Rules that only apply to one component, usually written in that component's own file. They can't leak out and affect anything else, and nothing else can accidentally override them by accident. This is where layout details specific to one screen belong.

In a component framework, scoped styles are typically enforced by the build tool: it rewrites your selectors with a unique, generated attribute so .title in NoteCard.vue can never collide with .title in SettingsPanel.vue, even though both files use the exact same class name.

scoped styles, isolated to one component
<style scoped>
.title {
  font-weight: 600;
  color: var(--fg-1);
}
</style>
The escape hatch

Scoping is deliberately strict, which occasionally gets in your way: a parent component sometimes needs to style markup that actually lives inside a child component (a third-party chart library's generated DOM, for instance). Vue's scoped styles support a :deep() selector for exactly this — .chart-wrap :deep(.legend) { color: var(--fg-2); } reaches through the boundary on purpose. Reach for it rarely; if you find yourself using it constantly, the styles probably belong in the child component instead.

Try it

Take three literal color values from a mockup you like and turn them into a two-layer token system: name the primitives by their position on a scale (--blue-40, --blue-70), then name semantic tokens that point at them (--info, --info-soft). Then write a [data-theme="dark"] block that reassigns just the semantic layer.