Tutorials Ultimate Web Development Series › Chapter 4

CSS Design Systems — The Pattern Every Real Site Uses

WebChapter 4 of the Ultimate Web Development Series28 minApril 20, 2026Beginner

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:

Loading diagram…

Figure 1 — The three layers of a design system. Tokens are the atoms, primitives are molecules, compositions are the page-level organisms.

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:

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.

Loading diagram…

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:

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.

Loading diagram…

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):

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:

  1. Move every hex color and px value into the tokens.css above. Put tokens.css in <head> before styles.css.
  2. Replace every raw value with a var(--…) reference. color: #0071e3 becomes color: var(--color-accent). padding: 24px 40px becomes padding: var(--space-6) var(--space-12) (which is 24px 48px — close enough; snap to the scale).
  3. 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:

  1. Keep tokens.css as a snippet. Copy it into every new HTML-only project you start. It'll save you an hour every time.
  2. 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.
Ch 3: CSS FundamentalsCh 5: JavaScript Essentials

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
5 free articles remainingSubscribe for unlimited access