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():
: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.
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.
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:
- It doesn't remember the choice. Without saving it somewhere (typically
localStorage) and re-reading that value on the next page load, the theme silently resets to light every time the user comes back. - It ignores the OS-level preference. Browsers expose the user's system-wide choice
through a media query,
prefers-color-scheme: dark. A theme picker should default to that preference before the user has made an explicit choice of their own, not hardcode light as the starting point.
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);
}
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.
<style scoped>
.title {
font-weight: 600;
color: var(--fg-1);
}
</style>
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.
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.