Typography, colour and dark mode
Set type with size and line-height together, build a colour system you can theme, and switch dark mode from a class or the operating system.
Type scale and line height
<h1 class="text-balance text-4xl font-bold tracking-tight sm:text-5xl">
Ship interfaces your team can maintain
</h1>
<p class="text-pretty text-lg/8 text-slate-600">
A longer paragraph. The size and the line height are set in one utility,
so a change to the reading rhythm is a single edit.
</p>
<p class="max-w-prose text-base/7 text-slate-600">...</p>
<!-- truncation and clamping -->
<p class="truncate">One line, with an ellipsis</p>
<p class="line-clamp-3">Three lines, then an ellipsis</p>
<p class="break-words">AnUnbrokenIdentifierThatWouldOtherwiseOverflow</p>| Utility | Sets |
|---|---|
text-lg | Font size from the scale |
text-lg/8 | Font size and a fixed line height |
leading-8 | Line height on its own |
tracking-tight | Letter spacing, for large headings |
font-sans / font-mono | Font family from the theme |
text-balance | Balanced line lengths in a heading |
text-pretty | Avoids a single orphaned word in a paragraph |
max-w-prose | A comfortable reading measure, about 65 characters |
💡
Set the line height with the size (
text-lg/8) rather than separately. The default line heights in the scale are tuned per size, and overriding one without the other is how headings end up looking cramped while the body text looks loose.A colour system
<!-- one palette, several roles -->
<button class="bg-sky-600 text-white hover:bg-sky-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-600">
Primary
</button>
<button class="border border-slate-300 bg-white text-slate-900 hover:bg-slate-50">
Secondary
</button>
<p class="rounded bg-amber-50 p-3 text-amber-900 ring-1 ring-amber-200">Warning</p>
<!-- opacity modifiers, instead of a separate opacity utility -->
<div class="bg-slate-900/75 text-white">Overlay</div>
<div class="border-slate-200/60">Hairline</div>
<!-- a token indirection, so a brand change is one line -->
<div class="bg-surface text-on-surface border-border">Card</div>@import "tailwindcss";
@theme {
--color-surface: oklch(99% 0.002 250);
--color-on-surface: oklch(21% 0.02 260);
--color-border: oklch(92% 0.01 260);
--color-brand-500: oklch(62% 0.19 250);
--color-brand-600: oklch(55% 0.19 250);
}- Naming a token by its role (
surface,border,danger) rather than its colour is what makes a theme change possible without touching markup. bg-red-500/50replaces the oldbg-opacity-50utility, which no longer exists.- oklch keeps lightness perceptually even, so a 500 shade in one hue looks as bright as a 500 in another.
- Use one accent colour and a neutral ramp. A palette with six accents is a palette where nothing is emphasised.
Dark mode
@import "tailwindcss";
/* default: follow the operating system, no configuration needed
dark:bg-slate-900 -> @media (prefers-color-scheme: dark) */
/* class strategy: switch with a class on the html element */
@custom-variant dark (&:where(.dark, .dark *));<html class="dark">
<body class="bg-white text-slate-900 dark:bg-slate-950 dark:text-slate-100">
<div class="rounded-lg border border-slate-200 bg-white p-6
dark:border-slate-800 dark:bg-slate-900">
<h2 class="text-slate-900 dark:text-slate-50">Title</h2>
<p class="text-slate-600 dark:text-slate-400">Body copy</p>
</div>
</body>
</html>// toggle and persist the class strategy
const root = document.documentElement;
function applyTheme(theme) {
const dark =
theme === "dark" ||
(theme === "system" && matchMedia("(prefers-color-scheme: dark)").matches);
root.classList.toggle("dark", dark);
}
applyTheme(localStorage.theme ?? "system");
document.querySelector("#theme-toggle").addEventListener("click", () => {
const next = root.classList.contains("dark") ? "light" : "dark";
localStorage.theme = next;
applyTheme(next);
});- Prefer the class strategy when the user must be able to override, or when the choice must survive a page load on the server.
- Define the dark variant once, at the top of your CSS, and use
dark:freely in markup afterwards. - Set the theme before first paint - an inline script in the head that reads storage and sets the class - or the page flashes the wrong theme.
- Theme the tokens, not every utility.
bg-surfacethat resolves to a different colour in dark mode is one rule instead of fortydark:prefixes.
/* tokens that flip with the theme, so markup needs no dark: at all */
@theme {
--color-surface: oklch(99% 0.002 250);
--color-on-surface: oklch(21% 0.02 260);
}
@layer theme {
.dark {
--color-surface: oklch(18% 0.02 260);
--color-on-surface: oklch(97% 0.005 250);
}
}FAQ
Should I use the media strategy or the class strategy for dark mode?
The media strategy is zero configuration and respects the operating system, which is right for a documentation site. The class strategy is right when the user must be able to override it, or when you need the choice to be consistent between server render and browser - as long as you apply the class before first paint.
Why does my dark mode look muddy?
Usually the same colours inverted rather than a palette designed for the dark background. Pure white on pure black is harsh, and saturated mid-tones glare. Use a near-black background, a slightly dimmed text colour, and reduce saturation on the accent.
Related
Responsive and state variants Production performance and the honest limits
Last refreshed 2026-09-18.