HTML & CSS · Lesson 8 of 13

Typography, Colors and CSS Variables

Style readable text with web fonts and fluid type, choose accessible colors with hsl() and oklch(), and theme a site with CSS custom properties.

  • Intermediate
  • 16 min read
  • 4 objectives

Before this lessonLesson 7: The Box Model, Sizing and Units

What you will learn

  • Load web fonts efficiently and set a readable type scale
  • Use modern colour functions and keep contrast accessible
  • Build design tokens with CSS custom properties
  • Add a dark theme by swapping variables

Your Progress

0 of 13 lessons 0%

  • Lessons0 / 13
  • Completed0
  • Est. time left~ 4 hours

Create a free account to keep your progress on every device.

Tip: pressing Next marks this lesson complete automatically.

Most of a web page is text, so typography does more for how professional a site feels than almost anything else. Colour sets the mood and guides attention. And CSS custom properties (variables) tie it all together, so a brand colour or font lives in one place instead of fifty. CSS Basics showed a two-line variable example; this lesson turns that into a small design system.

Font stacks and web fonts

A font stack lists fonts in order of preference. The browser uses the first one it has. The system stack is fast (no download) and looks native everywhere:

body {
  font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
code {
  font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
}

For a custom font, self-host a WOFF2 file (the most compressed format) and declare it with @font-face. A single variable font file can contain every weight from thin to black, which is usually smaller than loading three separate weights.

@font-face {
  font-family: "Inter";
  src: url("/fonts/inter-var.woff2") format("woff2");
  font-weight: 100 900;      /* variable font: any weight in this range */
  font-display: swap;        /* show fallback text immediately, swap when loaded */
}

body { font-family: "Inter", system-ui, sans-serif; }

Readable text

Four properties do most of the work: size, line height, line length and weight.

body {
  font-size: 1.0625rem;        /* 17px: slightly larger reads better on screens */
  line-height: 1.6;            /* unitless, so it scales with each element's size */
  color: #1e293b;
  -webkit-font-smoothing: antialiased;
}

h1, h2, h3 {
  line-height: 1.15;           /* big text needs tighter leading */
  letter-spacing: -0.02em;
  text-wrap: balance;          /* evens out lines in headings */
}

p {
  max-width: 65ch;
  text-wrap: pretty;           /* avoids a single orphan word on the last line */
}

.caption { font-size: 0.875rem; color: #64748b; }
.eyebrow { text-transform: uppercase; letter-spacing: 0.08em; font-weight: 600; }

Always give line-height without a unit. line-height: 24px is inherited as a fixed 24px, so a 40px heading would have lines overlapping.

A type scale

Rather than picking random sizes, multiply by a ratio. A 1.25 ratio from a 1rem base gives 1, 1.25, 1.563, 1.953, 2.441rem. Combined with clamp(), headings shrink on phones and grow on desktops without media queries.

:root {
  --step-0: 1rem;
  --step-1: clamp(1.125rem, 1rem + 0.5vw, 1.25rem);
  --step-2: clamp(1.375rem, 1.1rem + 1vw, 1.563rem);
  --step-3: clamp(1.625rem, 1.2rem + 1.8vw, 1.953rem);
  --step-4: clamp(2rem, 1.3rem + 3vw, 2.441rem);
}
h1 { font-size: var(--step-4); }
h2 { font-size: var(--step-3); }
h3 { font-size: var(--step-2); }

Colours: hex, rgb, hsl and oklch

.a { color: #2563eb; }                  /* hex */
.b { color: rgb(37 99 235); }           /* modern space-separated syntax */
.c { color: rgb(37 99 235 / 50%); }     /* with 50% opacity */
.d { color: hsl(221 83% 53%); }         /* hue, saturation, lightness */
.e { color: oklch(55% 0.2 262); }       /* lightness, chroma, hue */

/* Mix colours in the browser */
.f { background: color-mix(in oklch, #2563eb 20%, white); }

HSL is easy to reason about: keep the hue and change lightness to get lighter and darker shades. oklch(), supported in all modern browsers, goes further: its lightness is perceptually even, so a yellow and a blue at 60% lightness really look equally bright. That makes generating accessible palettes much more reliable. color-mix() creates tints and shades without a preprocessor.

Whatever the format, check contrast: at least 4.5:1 for body text. Light grey text on white is the most common failure on the web.

Custom properties as design tokens

Custom properties start with two dashes and are read with var(). Unlike preprocessor variables, they are live: they inherit, can be changed per element, and can be updated by JavaScript or a media query at runtime.

:root {
  /* Raw palette */
  --blue-600: oklch(55% 0.2 262);
  --slate-900: oklch(21% 0.04 265);
  --slate-50: oklch(98% 0.005 250);

  /* Semantic tokens: what the colour is FOR */
  --color-bg: var(--slate-50);
  --color-text: var(--slate-900);
  --color-accent: var(--blue-600);
  --color-muted: oklch(55% 0.02 260);

  --radius: 0.5rem;
  --space-2: 0.5rem;
  --space-4: 1rem;
  --font-body: "Inter", system-ui, sans-serif;
}

body {
  background: var(--color-bg);
  color: var(--color-text);
  font-family: var(--font-body);
}

.button {
  background: var(--btn-bg, var(--color-accent));   /* fallback value */
  color: white;
  padding: var(--space-2) var(--space-4);
  border-radius: var(--radius);
}

/* A variant just overrides one variable */
.button.danger { --btn-bg: oklch(58% 0.21 27); }

The two-layer approach (raw palette, then semantic tokens) is what design systems use. Components only reference semantic names like --color-accent, so changing the theme never means hunting through component CSS.

Dark mode by swapping tokens

Because components use semantic tokens, a dark theme is just a new set of values. Respect the operating system setting, and optionally allow a manual override with a data-theme attribute.

:root { color-scheme: light dark; }   /* dark scrollbars and form controls too */

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --color-bg: oklch(18% 0.02 265);
    --color-text: oklch(94% 0.01 250);
    --color-accent: oklch(72% 0.15 262);
  }
}

:root[data-theme="dark"] {
  --color-bg: oklch(18% 0.02 265);
  --color-text: oklch(94% 0.01 250);
  --color-accent: oklch(72% 0.15 262);
}

Recap

  • Use a system font stack or a self-hosted WOFF2 variable font with font-display: swap.
  • Readable text: about 1rem or more, unitless line-height around 1.5-1.7, lines around 65ch, text-wrap: balance for headings.
  • Build a type scale with a ratio and clamp() for fluid headings.
  • Prefer oklch() or hsl() for palettes, color-mix() for tints, and always check 4.5:1 contrast.
  • Define raw and semantic tokens as custom properties; dark mode becomes a token swap.
// Write your solution here

Finished reading? Mark this lesson complete to track your progress.

Up next · Lesson 9Positioning, z-index and OverflowLearn CSS position relative, absolute, fixed and sticky, how z-index and stacking contexts work, and how to control overflow and scrolling.