Designing a dark theme
intermediate·7 min·Markdown for agents
What this lesson teaches
Build a dark theme from the same color scale as the light one, with near-black surfaces, softened text, lighter layers for depth and calmer accents.
This course is light only, so here is a dark theme in a box. It starts the way most generated dark modes start. Flip the four switches, one at a time.
Light
Quarterly report
Edited 2 hours ago by Sam
ShareDark · inverted
Quarterly report
Edited 2 hours ago by Sam
ShareThe first version is the light theme run through an inverter: pure black, pure white, shadows that vanish into nothing, and a blue that buzzes. Each switch is one of the skill's rules.
Never pure black
Pure #000000 under pure #ffffff is harsh. Surfaces feel cheap, and on OLED phones scrolling text smears. Use a near-black with a slight hue, like #18181b.
--bg-base: #18181b; /* the page */
--bg-elevated: #1f1f23; /* +1: cards */
--bg-overlay: #2a2a2f; /* +2: menus, dialogs */Keep #000 for photo backdrops, video players and OLED themes that opt in on purpose.
Up means lighter
In light mode, elevation is a shadow. On a dark page there is nothing dark enough for a shadow to fall on, so in dark mode, higher surfaces are lighter, and the topmost layer is the lightest.
A raised card swaps its shadow stack for a faint white ring:
.dark { --elevation-raised: 0 0 0 1px rgb(255 255 255 / .09); }Rings are for elevation only. Dividers stay solid dark grays, around #2a2a2a on #18181b: table rules, list separators, input edges. A see-through white line glows.
Soften the white
Full white text on near-black is still glaring. White at 90–93% reads softer:
--text-primary: rgba(255, 255, 255, 0.92);
--text-secondary: rgba(255, 255, 255, 0.62);There's a catch. Text with alpha is only safe on one known surface. Over a lighter card, an image or a tinted panel, the alpha mixes with whatever is underneath and the contrast changes. Everywhere else, use solid tokens checked against their real background.
Remap, don't invert
The deeper idea: dark mode is the same color ramp with the roles pointed at mirrored steps. The page used step 50 in light; it uses 950 in dark. Text used 900; it uses 50. Borders swap 200 for 800.
✕ Inverted
Every color flipped by a filter or by hand-picking a second palette. Yellows turn muddy, the accent vibrates, and the two themes drift apart.
✓ Remapped
One scale. Semantic tokens point at different steps in a .dark block. Every pair is then re-checked for contrast.
Because OKLCH lightness is measured, mirrored steps stay just as distinct after the flip. Then two adjustments:
- Desaturate the accent 20–30%. A brand blue that looks crisp on white buzzes on black.
- Re-measure every pair. The swatch looking right isn't the same as the screen reading right.
This is the payoff of the token tiers from the palette lesson: components never change, only the semantic layer does. Per-component dark: overrides scatter the theme across the codebase and rot on the first palette change.
The details that give it away
- Icons and logos with a hard-coded
fill="#000"vanish in dark. Usefill="currentColor"so they follow the text color. - Native controls: set
color-scheme: darkin the dark theme, or scrollbars and<select>menus stay light. - Images get a white 10% inset outline instead of the black one.
- The phone's status bar: one
theme-colorper scheme, matched to the top of the page.
Half done is broken
A dark mode with one white modal, one light dropdown or one black logo on black makes the whole product look broken. The skill treats it as a blocker, not a nit. If the theme isn't finished, don't ship the toggle and don't declare color-scheme: dark. That is also why this course has no dark mode: one well-made theme beats two half-made ones.
Add a dark theme by remapping our existing scale, not inverting it: - Surfaces: near-black (e.g. #18181b), getting lighter as they rise (card, menu, dialog). No pure #000. - Elevation in dark is a lighter surface plus a ring (0 0 0 1px rgb(255 255 255 / .09)), not shadows. Dividers stay solid grays. - Text: 92% white primary, 62% secondary, only on one known surface; elsewhere solid tokens. - Repoint semantic tokens in a .dark block; no per-component dark: overrides. - Desaturate the accent 20–30%. SVGs use currentColor. Set color-scheme: dark and a dark theme-color. - Re-check every text pair for contrast and list the results.
Quick check
In a dark theme, how should a dropdown menu show it floats above a card?
Connected lessons
What this lesson builds on and leads to, across every course.