In Chapter 3 you wrote ~60 lines of CSS to restyle a page. That works for one page. But look at any product site you admire — Apple, Stripe, Linear, Notion — and you'll notice that across hundreds of pages, the fonts, colors, spacings, and corners all feel consistent. That's not discipline, it's a design system.
This chapter is the moment your CSS stops being "rules I added to make things look okay" and starts being a system: a small set of reusable tokens (colors, font sizes, spacing units, radii, shadows) that everything else is built from. It's the same technique Apple's HIG, Stripe's Sail, and Tailwind CSS use under the hood — and you can build your own in an afternoon.
By the end you'll have: a working token system using CSS custom properties, a typographic scale, a spacing rhythm, a dark/light theme toggle, and a set of reusable components you can drop into any page.
What a Design System Actually Is
A design system has three layers, stacked on top of each other:
Figure 1 — The three layers of a design system. Tokens are the atoms, primitives are molecules, compositions are the page-level organisms.
- Tokens — named values.
--color-accent: #3399ff,--space-4: 16px,--radius-md: 12px. The single source of truth for every design decision. - Primitives — the buttons, inputs, cards, chips. Built from tokens, never from raw values.
- Compositions — a pricing page, a dashboard, a settings screen. Built from primitives, never from raw values.
When a designer says "let's bump the accent color to purple," you change one token and the whole site updates. Without this system, you'd be hunting through every stylesheet for hex codes.
Tokens in CSS — The -- Custom Properties
CSS custom properties (informally "CSS variables") are the native-browser way to define tokens. You declare them on an element, then reference them anywhere below it in the tree.
:root {
/* --- Colors --- */
--color-bg: #ffffff;
--color-surface: #f5f5f7;
--color-text: #1d1d1f;
--color-muted: #6e6e73;
--color-accent: #3399ff;
--color-border: #d2d2d7;
/* --- Spacing --- */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-6: 24px;
--space-8: 32px;
--space-12: 48px;
--space-16: 64px;
/* --- Radii --- */
--radius-sm: 6px;
--radius-md: 12px;
--radius-lg: 20px;
--radius-full: 9999px;
/* --- Shadows --- */
--shadow-sm: 0 1px 3px rgba(0,0,0,0.06);
--shadow-md: 0 4px 12px rgba(0,0,0,0.08);
--shadow-lg: 0 12px 32px rgba(0,0,0,0.12);
}
:root is the <html> element — declaring tokens there makes them available everywhere on the page. Use them with var():
.card {
background: var(--color-surface);
color: var(--color-text);
padding: var(--space-6);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
}
That's the whole technique. Once you've done this, you've built a design system — you just have to resist the temptation to write raw values anywhere except in :root.
The Typographic Scale — Why 18px Isn't Random
Great typography uses a scale — a small set of font sizes in a harmonic progression, not arbitrary numbers like 14px, 15px, 16px, 17px, 18px, 19px.
The most popular scale is 1.250 (major third): each step is 1.25× the previous. Start from a base of 16px (1rem) and multiply/divide:
| Size | px | rem | Use for |
|---|---|---|---|
| --text-xs | 12px | 0.75rem | Fine print, captions, badges |
| --text-sm | 14px | 0.875rem | Secondary text, metadata |
| --text-base | 16px | 1rem | Body copy (default) |
| --text-lg | 20px | 1.25rem | Lead paragraphs, card titles |
| --text-xl | 24px | 1.5rem | H3 |
| --text-2xl | 32px | 2rem | H2 |
| --text-3xl | 40px | 2.5rem | H1 |
| --text-4xl | 56px | 3.5rem | Hero headline |
Add those as tokens:
:root {
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.25rem;
--text-xl: 1.5rem;
--text-2xl: 2rem;
--text-3xl: 2.5rem;
--text-4xl: 3.5rem;
--leading-tight: 1.2;
--leading-normal: 1.6;
--leading-loose: 1.8;
--weight-regular: 400;
--weight-medium: 500;
--weight-semibold: 600;
--weight-bold: 700;
}
Now every font size in your app snaps to one of 8 values. Your site instantly looks more polished, because inconsistent sizing is one of the fastest tells of amateur design.
Don't forget line-height
A font size without a line-height is incomplete. Rule of thumb:
- Headlines (h1, h2, h3) — tight line-height (
--leading-tight: 1.2). Big text doesn't need much gap. - Body text — normal (
--leading-normal: 1.6). This is the sweet spot for readability. - Long-form prose (articles, docs) — loose (
--leading-loose: 1.8). Easier on the eye for extended reading.
The Spacing Scale — The Rhythm of the Page
Spacing is the invisible grid everything sits on. Great sites use a 4px or 8px base unit and scale in multiples. You already saw the spacing tokens above (--space-1 through --space-16). That's a 4px base, doubling roughly every two steps.
Figure 2 — The spacing scale. Eight values, covering everything from icon nudges (4px) to section breaks (64px). If the value you need isn't on this list, the design is asking for inconsistency.
Why this works: when every padding, margin, and gap in your site is one of these eight values, horizontal and vertical rhythms line up automatically. A card's 16px padding stacks neatly with a 32px section gap. No manual pixel-pushing.
Practical use: almost all your padding and gap values will be 4, 8, 12, 16, or 24. Save 48 and 64 for section breaks. You'll rarely need 1 (4px) except for icon nudges.
Color Systems — Going Beyond --color-accent
The basic color tokens above (bg, surface, text, muted, accent, border) work for a small site. A real product wants more nuance:
- Semantic tokens for specific meanings (success, warn, danger, info)
- Shades for hover/active states and layered surfaces
- Content-on-color tokens (what color text goes on the accent background?)
A tiered color system looks like this:
:root {
/* Neutral shades (for surfaces + text) */
--neutral-50: #fafafa;
--neutral-100: #f5f5f7;
--neutral-200: #e5e5e7;
--neutral-300: #d2d2d7;
--neutral-500: #86868b;
--neutral-700: #424245;
--neutral-900: #1d1d1f;
/* Accent shades */
--accent-100: #eaf4ff;
--accent-500: #3399ff;
--accent-600: #2080e0;
--accent-700: #1060b8;
/* Semantic */
--success-500: #34a853;
--warn-500: #fbbc05;
--danger-500: #ea4335;
/* --- Aliased tokens (what your components actually use) --- */
--color-bg: var(--neutral-50);
--color-surface: var(--neutral-100);
--color-border: var(--neutral-200);
--color-text: var(--neutral-900);
--color-muted: var(--neutral-500);
--color-accent: var(--accent-500);
--color-accent-hover: var(--accent-600);
--color-on-accent: #ffffff;
--color-success: var(--success-500);
--color-warn: var(--warn-500);
--color-danger: var(--danger-500);
}
Two layers: scale tokens (--accent-500) that rarely change, and aliased tokens (--color-accent) that components use. When you want to redesign, you rewire the aliases — the scale stays intact.
Dark Mode — One Extra Selector, Zero Extra Work
Once your tokens are in place, dark mode is almost free. Re-declare the aliased tokens under a different selector:
:root {
/* Light theme (default) */
--color-bg: var(--neutral-50);
--color-surface: var(--neutral-100);
--color-text: var(--neutral-900);
--color-muted: var(--neutral-500);
--color-border: var(--neutral-200);
}
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #0a0a0a;
--color-surface: #1a1a1a;
--color-text: #e5e5e7;
--color-muted: #86868b;
--color-border: #2a2a2a;
}
}
/* Or, user-toggleable via a class on <html> */
html[data-theme="dark"] {
--color-bg: #0a0a0a;
--color-surface: #1a1a1a;
--color-text: #e5e5e7;
--color-muted: #86868b;
--color-border: #2a2a2a;
}
Your components don't change — they still reference var(--color-bg) and friends. Only the token values swap. This is the entire dark-mode implementation for this tutorial site, and it's what Apple, GitHub, and Stripe use under the hood.
Figure 3 — How a single attribute on <html> cascades through aliased tokens to restyle every component. This is why tokens matter — zero code changes to add dark mode.
Building Primitives — The Reusable Button
Armed with tokens, you can now build primitives: small reusable CSS classes that stand in for "button," "card," "input." Here's a canonical button:
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-6);
border-radius: var(--radius-full);
border: 1px solid transparent;
font-size: var(--text-sm);
font-weight: var(--weight-semibold);
line-height: 1;
cursor: pointer;
transition: background 0.15s, transform 0.1s;
}
.btn:active { transform: translateY(1px); }
/* Variants */
.btn--primary {
background: var(--color-accent);
color: var(--color-on-accent);
}
.btn--primary:hover { background: var(--color-accent-hover); }
.btn--ghost {
background: transparent;
color: var(--color-text);
border-color: var(--color-border);
}
.btn--ghost:hover { background: var(--color-surface); }
.btn--danger {
background: var(--color-danger);
color: white;
}
Usage is just HTML:
<button class="btn btn--primary">Save</button>
<button class="btn btn--ghost">Cancel</button>
<button class="btn btn--danger">Delete account</button>
Notice the BEM-ish naming: block__element--modifier. You don't have to use BEM strictly, but some naming convention prevents class-name collisions as your system grows. "btn" is the block; --primary, --ghost, --danger are modifiers.
The Component Sheet
Collect your primitives in one file (components.css or similar):
.btn+ variants.input+ states (:focus,:disabled,.input--error).card+ variants.chip/.badge/.tag.dialog/.modal.stack(vertical flex with--space-*gap).row(horizontal flex).grid-responsive(therepeat(auto-fit, minmax(…, 1fr))pattern from Ch 3)
Each one a handful of lines of CSS. The whole sheet rarely exceeds 500 lines. But once you have it, every new page is mostly HTML — you're gluing primitives together, not writing fresh CSS.
Where Tailwind Fits
You may have heard of Tailwind CSS and wondered how it relates to all of this.
Tailwind is a design system shipped as a ready-made set of utility classes. Instead of you writing:
.card {
padding: var(--space-6);
border-radius: var(--radius-md);
background: var(--color-surface);
}
…you use Tailwind's pre-built tokens directly in the HTML:
<div class="p-6 rounded-xl bg-neutral-100">…</div>
It's the same three-layer idea — tokens → primitives → compositions — just expressed as class names in HTML rather than named components in CSS. Tailwind's underlying token scale (p-4 = 16px, rounded-md = 12px) is nearly identical to what we just built. Many teams use both: Tailwind utilities for composition, custom .btn / .card primitives for repeatable shapes.
We'll cover Tailwind properly in Chapter 19. For now, know that the thinking you're doing right now is the prerequisite — without a mental model of tokens and primitives, Tailwind feels like a sea of cryptic class names. With it, Tailwind feels like a shortcut for the system you already know how to build.
Putting It Together — A Full Token File
Here's a complete, shippable tokens.css with every token category we've covered. Save this and the rest of your site can draw from it.
:root {
/* --- Color: neutral scale --- */
--neutral-50: #fafafa;
--neutral-100: #f5f5f7;
--neutral-200: #e5e5e7;
--neutral-300: #d2d2d7;
--neutral-500: #86868b;
--neutral-700: #424245;
--neutral-900: #1d1d1f;
/* --- Color: accent scale --- */
--accent-100: #eaf4ff;
--accent-500: #3399ff;
--accent-600: #2080e0;
--accent-700: #1060b8;
/* --- Color: semantic --- */
--success-500: #34a853;
--warn-500: #fbbc05;
--danger-500: #ea4335;
/* --- Color: aliases (what components use) --- */
--color-bg: var(--neutral-50);
--color-surface: var(--neutral-100);
--color-border: var(--neutral-200);
--color-text: var(--neutral-900);
--color-muted: var(--neutral-500);
--color-accent: var(--accent-500);
--color-accent-hover: var(--accent-600);
--color-on-accent: #ffffff;
/* --- Typography --- */
--font-sans: -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
--font-mono: 'SF Mono', 'Fira Code', ui-monospace, monospace;
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.25rem;
--text-xl: 1.5rem;
--text-2xl: 2rem;
--text-3xl: 2.5rem;
--text-4xl: 3.5rem;
--leading-tight: 1.2;
--leading-normal: 1.6;
--leading-loose: 1.8;
--weight-regular: 400;
--weight-medium: 500;
--weight-semibold: 600;
--weight-bold: 700;
/* --- Spacing --- */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-6: 24px;
--space-8: 32px;
--space-12: 48px;
--space-16: 64px;
/* --- Radii --- */
--radius-sm: 6px;
--radius-md: 12px;
--radius-lg: 20px;
--radius-full: 9999px;
/* --- Shadows --- */
--shadow-sm: 0 1px 3px rgba(0,0,0,0.06);
--shadow-md: 0 4px 12px rgba(0,0,0,0.08);
--shadow-lg: 0 12px 32px rgba(0,0,0,0.12);
/* --- Motion --- */
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--dur-fast: 0.1s;
--dur-base: 0.2s;
--dur-slow: 0.4s;
/* --- Layout --- */
--max-width-prose: 760px;
--max-width-content: 1100px;
}
html[data-theme="dark"] {
--color-bg: #0a0a0a;
--color-surface: #1a1a1a;
--color-border: #2a2a2a;
--color-text: #e5e5e7;
--color-muted: #86868b;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.4);
--shadow-md: 0 4px 12px rgba(0,0,0,0.5);
--shadow-lg: 0 12px 32px rgba(0,0,0,0.6);
}
That's roughly 80 lines, and it's a complete design system foundation. Every page, every component, every new feature pulls from this. When design needs change, you edit this file — not twenty different CSS files across the project.
Exercise — Retrofit the Ch 3 Stylesheet
Take your styles.css from Chapter 3 and do three things:
- Move every hex color and px value into the
tokens.cssabove. Puttokens.cssin<head>beforestyles.css. - Replace every raw value with a
var(--…)reference.color: #0071e3becomescolor: var(--color-accent).padding: 24px 40pxbecomespadding: var(--space-6) var(--space-12)(which is 24px 48px — close enough; snap to the scale). - Add a theme toggle. Drop this into your page footer:
<button class="btn btn--ghost" onclick="
const root = document.documentElement;
root.dataset.theme = root.dataset.theme === 'dark' ? 'light' : 'dark';
">Toggle theme</button>
Click it. Watch every color on the page flip between light and dark in one repaint. No JavaScript frameworks, no animation libraries — just a token system doing its job.
Next Steps
You've just built a design system. Not a toy one — the real thing, with tiered color, a type scale, a spacing rhythm, semantic variants, dark mode, and reusable primitives. This is what every design team you've admired has been doing all along.
Next:
- Keep
tokens.cssas a snippet. Copy it into every new HTML-only project you start. It'll save you an hour every time. - Next chapter — Chapter 5: JavaScript Essentials. We've given your HTML structure and CSS looks. Now we give it interactivity: reading the DOM, handling clicks, fetching from a server. That's the third leg of the browser tripod, and the last thing you need before Part 1 closes with the simpleappshipper.com project study.
Ship your apps faster
When you're ready to publish your Swift app to the App Store, Simple App Shipper handles metadata, screenshots, TestFlight, and submissions — all in one place.
Try Simple App Shipper