Typography & Color
Font Loading
Fonts affect both how your site looks and how fast it loads. There are three approaches, each with different trade-offs.
System Font Stack
The fastest option -- zero network requests. Uses the operating system's default font.
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont,
'Segoe UI', Roboto, 'Helvetica Neue', Arial,
sans-serif;
}
Modern shorthand:
body {
font-family: system-ui, sans-serif;
}
System fonts are instantly available, match the platform's look and feel, and handle international characters well.
Google Fonts
The most common way to use custom fonts. Google hosts the files and optimizes delivery.
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap"
rel="stylesheet">
body {
font-family: 'Inter', system-ui, sans-serif;
}
Key details:
display=swapaddsfont-display: swap-- shows fallback text immediately, swaps when the font loadspreconnecthints reduce DNS/connection latency- Only request the weights you actually use (
wght@400;600;700)
@font-face (Self-Hosted)
Maximum control. Host the font files yourself.
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-bold.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
Use WOFF2 format -- it has the best compression and is supported by all modern browsers. The font-display: swap value shows fallback text immediately and swaps when the font loads. Always use it -- users should never stare at invisible text.
Font Sizing
rem vs em vs px
/* px: fixed size, does not respect user's browser font size setting */
.bad { font-size: 16px; }
/* rem: relative to root element font size (usually 16px) */
.good { font-size: 1rem; } /* 16px at default settings */
.larger { font-size: 1.25rem; } /* 20px at default settings */
/* em: relative to parent element's font size */
.em-example { font-size: 1.5em; } /* 1.5x the parent's font size */
Use rem for font sizes. It respects the user's browser settings (critical for accessibility) and provides a consistent scale.
Use em for component-internal spacing that should scale with the component's font size (e.g., padding: 0.5em 1em on a button).
Fluid Typography
Use clamp() so font sizes scale smoothly with the viewport:
h1 {
font-size: clamp(2rem, 5vw, 3.5rem);
/* Minimum 2rem, preferred 5vw, maximum 3.5rem */
}
body {
font-size: clamp(1rem, 0.9rem + 0.5vw, 1.25rem);
}
No media queries needed. The font scales fluidly between the minimum and maximum.
Line Height & Letter Spacing
line-height
body {
line-height: 1.6; /* Unitless value (recommended) */
}
h1 {
line-height: 1.2; /* Tighter for headings */
}
Always use unitless line-height. A unitless value is a multiplier of the element's font size. A value with units (line-height: 24px) does not scale with font size changes.
/* Bad: fixed line-height doesn't scale */
.text { font-size: 1rem; line-height: 24px; }
/* Good: scales with font size */
.text { font-size: 1rem; line-height: 1.5; }
Comfortable reading line-height for body text: 1.5 to 1.7. Headings: 1.1 to 1.3.
letter-spacing
.uppercase-label {
text-transform: uppercase;
letter-spacing: 0.05em; /* Uppercase text needs more tracking */
font-size: var(--text-sm);
}
h1 {
letter-spacing: -0.02em; /* Large text often looks better tighter */
}
Color Systems
CSS supports multiple color formats. Each has strengths.
Hex
.element {
color: #1a1a2e; /* 6-digit hex */
color: #1a1a2e80; /* 8-digit hex (with alpha) */
color: #333; /* 3-digit shorthand */
}
Compact and widely used, but hard to read and impossible to adjust intuitively.
rgb / rgba
.element {
color: rgb(26, 26, 46);
color: rgb(26, 26, 46 / 0.5); /* Modern syntax with alpha */
}
hsl
.element {
color: hsl(240, 28%, 14%);
color: hsl(240, 28%, 14% / 0.5);
}
HSL is human-readable: Hue (0-360 on the color wheel), Saturation (0-100%), Lightness (0-100%). Want a lighter version? Increase lightness. Want it muted? Decrease saturation.
oklch (The Modern Choice)
.element {
color: oklch(50% 0.15 240);
/* Lightness (0-100%), Chroma (0-0.4+), Hue (0-360) */
}
OKLCH is perceptually uniform -- equal steps in lightness look equal to human eyes. HSL is not: hsl(60, 100%, 50%) (yellow) looks much brighter than hsl(240, 100%, 50%) (blue) despite the same lightness value. OKLCH fixes this.
:root {
/* Perceptually consistent palette */
--blue-light: oklch(80% 0.1 240);
--blue-medium: oklch(55% 0.2 240);
--blue-dark: oklch(30% 0.1 240);
}
OKLCH is the best format for building color systems. It is supported in all modern browsers.
Dark Mode
Use prefers-color-scheme to detect the user's system preference:
:root {
--color-bg: #ffffff;
--color-surface: #f8f9fa;
--color-text: #1a1a2e;
--color-text-muted: #6b7280;
--color-border: #e5e7eb;
}
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #0f0f1a;
--color-surface: #1a1a2e;
--color-text: #e5e7eb;
--color-text-muted: #9ca3af;
--color-border: #2d2d44;
}
}
Because the component CSS uses custom properties, every element adapts automatically. No component-level dark mode overrides needed.
For a user-controlled toggle, apply dark theme variables via a [data-theme="dark"] attribute on <html>, toggled with JavaScript and persisted to localStorage.
Contrast & Readability
WCAG AA requires a contrast ratio of at least 4.5:1 for normal text and 3:1 for large text. This is not negotiable.
/* Good contrast: dark text on light background */
.readable {
color: #1a1a2e; /* Very dark */
background: #ffffff; /* White */
/* Contrast ratio: ~16:1 */
}
/* Bad contrast: light gray on white */
.unreadable {
color: #c0c0c0; /* Light gray */
background: #ffffff; /* White */
/* Contrast ratio: ~2:1 -- fails WCAG */
}
Tips for readable text:
- Body text:
line-heightof 1.5 to 1.7 - Maximum line width: 60-75 characters (
max-width: 65ch) - Sufficient paragraph spacing (
margin-bottom: 1emor more) - Do not justify text on the web (uneven spacing harms readability)
.article-body {
max-width: 65ch;
line-height: 1.7;
font-size: clamp(1rem, 0.9rem + 0.4vw, 1.125rem);
}
.article-body p + p {
margin-top: 1.25em;
}
Common Pitfalls
- Loading too many font weights: Each weight is a separate file download. Load only the weights you use (typically 400 and 700, maybe 600).
- Using px for font sizes: This overrides the user's browser font size preference, harming accessibility. Use
rem. - Setting line-height with units:
line-height: 24pxdoes not scale with font size. Use unitless values likeline-height: 1.6. - Not testing dark mode: Building a light theme and then "just inverting" for dark mode produces poor contrast and muddy colors. Design both themes intentionally.
- Low contrast "aesthetic" text: Light gray text on white backgrounds fails accessibility requirements regardless of how modern it looks.
- Using HSL for palette generation: HSL is not perceptually uniform. Colors at the same lightness value look different to human eyes. Use OKLCH for consistent palettes.
- Forgetting
font-display: swap: Without it, users see invisible text (FOIT) while custom fonts load. Always includefont-display: swap. - Not preconnecting to font CDNs: The
preconnectlink hint saves significant time when loading fonts from external servers.
Key Takeaways
- Use system fonts for maximum performance or
@font-facewith WOFF2 for custom fonts - Always set
font-display: swapto prevent invisible text during loading - Use
remfor font sizes, unitless values forline-height clamp()enables fluid typography that scales smoothly without media queries- OKLCH is the best color format for building consistent color systems
- Define colors as custom properties for easy theming and dark mode support
prefers-color-schemedetects the user's system theme preference- Maintain WCAG AA contrast ratios: 4.5:1 for normal text, 3:1 for large text
- Readable body text needs: good contrast, 1.5-1.7 line height, and 60-75 character line width