Deriving a Dark Theme
A dark theme is derived from a finished light theme by re-binding roles to steps, never by mirroring the pixels. The ink/canvas pair does flip — the near-white step becomes the near-black one — but elevation does not: a raised surface is lighter than its page in both themes, so a mechanical inversion yields black cards floating on grey, and is the most common way a dark mode ends up wrong without anyone naming why. The default posture is that dark mode is optional: Benji Taylor's site carries exactly two prefers-color-scheme rules and both sit inside embedded demos, so the site itself has none. Commit or build it properly; half-built is the worst of the three. This skill maps an existing set into the second theme — color picks the values and owns contrast, and this skill never picks a brand hue. surfaces owns the elevation ladder within a theme (shadow recipes, nested radii, hairlines); this skill owns expressing that same ladder as surface lightness once shadows stop reading.
Find the theme switch before writing a single token. Read whether the project flips a [data-theme] attribute, a .dark class, prefers-color-scheme, or a provider such as next-themes, and put every dark value behind that exact mechanism. If a numbered variable scale already flips at one root selector, add to it. Never introduce a second switching mechanism, and never answer a dark-mode bug with per-component dark: overrides on top of a variable system built to make them unnecessary.
Quick Reference
| Open this |
When |
| derivation-table.md |
You are producing the actual dark values and need the role-by-role mapping — canvas, raised surface, border, ink, accent, status, shadow, image. |
The Derivation Order
Each step depends on the one above it.
- Surfaces first. Darkest canvas, then each elevation level lighter than the one below.
- Borders and rings second, against the surfaces you just fixed.
- Ink third, then measure every pair per
color.
- Accent and status fourth, moving chroma and lightness together.
- Images, illustrations, and shadows last — they sit on top of everything above.
Core Principles
Point elevation toward lighter in both themes. In light mode the page is grey and the card is white; in dark mode the page is near-black and the card is a lighter grey. The topmost surface is always the brightest in its theme. Exception: a modal scrim, which goes darker than the canvas because its job is to remove depth rather than add it.
Never bottom out at #000. Pure black leaves nothing below the base surface, so the elevation ladder loses its floor, and on OLED the bottom step becomes an unlit void the next step visibly detaches from. Use #171717 as the darkest canvas and #1f1f1f as the first level above it. Exception: video letterboxing and full-screen media chrome, where true black is the right frame for the content.
Never top out at #fff either. Maximum-white ink on a near-black surface halates, thickening the letterforms and tiring the eye over a long session. The lightest ink step is #fafafa. Exception: one small element that must read as the brightest thing on screen — a live indicator, a focused caret.
Replace shadows with lightness steps plus one ring. A stacked depth shadow is invisible against a dark surface, so collapse each shadow token to a 1px white ring at 0 0 0 1px rgb(255 255 255 / 0.09), hover 0.14, and let surface lightness carry the depth. Exception: an overlay floating above a photo or video, where a real shadow still reads because the backdrop is not a theme surface.
Flip elevation rings to translucent white, but keep separation borders solid. A compositing ring (white at 0.09) reads as light catching an edge; a separation border built the same way glows. Dividers, gridlines, and input edges go solid in the #2a2a2a range instead of rgba(255,255,255,0.1). Exception: a border over a translucent or blurred material, where solid would reveal the panel's true colour.
Pull brand chroma back 20–30%, and raise L rather than lowering it. A hue carried across at full chroma vibrates against a dark ground; reducing chroma settles it. But chroma ceilings collapse toward the poles, so a merely-darkened accent loses vividness twice over — move it up the ramp and desaturate. Exception: destructive red, tuned against contrast rather than comfort because it must stay loud enough to stop someone.
Declare color-scheme: dark on the root. Without it, native form controls, scrollbars, the caret, and the autofill background stay in their light rendering and give the theme away instantly — and no amount of custom-property work fixes any of them. Exception: none. It is one declaration.
Dim assets until their own ground joins the surface ladder. A screenshot or illustration authored on white out-glows every surface around it. The check is measurable: the asset's largest flat area must land below L 0.6, the same cutoff separating a light surface from a dark one. Re-export against the dark canvas where you can; dim in CSS only where you cannot. Exception: photography and user uploads, which are the subject and are never dimmed — give them the 1px white outline at 10% instead.
Never reach for filter: invert(). It flips photographs into negatives, turns logos into their own complements, and drags every hue 180° around the wheel. No asset class survives it. Exception: none.
Suspend transitions across the theme swap. Every component carries its own duration, so a live switch cascades raggedly for as long as the slowest one runs. Kill transitions for the frame of the swap — with next-themes that is disableTransitionOnChange on the provider. Exception: a deliberately choreographed theme transition built as one animation, not thirty.
Re-measure every pair; do not trust the mirror. Perceptually even L steps make the ink/canvas flip safe, but muted text, disabled states, and placeholder greys routinely fail in exactly one theme. Run color's thresholds again on the dark set. Exception: none — half of all dark-mode contrast failures are greys that passed in light.
Ship light-only rather than half-built. A dark theme is a second complete surface set with its own contrast pass, not dark: classes over the 60% of components someone reached. If the product is not used at night, beside a dark editor, or in media contexts, that budget buys more elsewhere. Exception: once you claim support, honouring prefers-color-scheme is not optional — a toggle with no system default is a half-build too.
Smells and Fixes
| Smell |
Fix |
| Cards vanish in dark mode |
The light shadow stack is still active; collapse to the ring and raise the surface step |
| Cards darker than the page behind them |
Elevation was mirrored; point it toward lighter |
#000 canvas |
Raise to #171717 so the ladder has a floor |
| Text feels smeared or too heavy |
#fff ink; drop to #fafafa |
| Dividers glow |
Translucent white on a separation border; go solid #2a2a2a |
| The accent buzzes against the surface |
Cut chroma 20–30% and move it up the ramp |
| Scrollbars, caret, or autofill still light |
Missing color-scheme: dark on the root |
| A screenshot blazes white in the middle of the page |
Its flat ground is above L 0.6; re-export or dim it |
| Photos look like negatives |
filter: invert() — remove it entirely |
| The page ripples for half a second on toggle |
Transitions not suspended during the swap |
dark: overrides sprinkled through components |
Flip the variable scale at the theme root instead |
Reporting a Derivation
Report the dark theme as a table, one row per role: the light value, the derived dark value, and the rule that produced it — mirrored, elevation-inverted, chroma-reduced, ring-replaced, re-measured. Then list separately every pair that failed in dark and passed in light, with its repaired value; that set is where a reviewer's attention pays most.
Checklist
1---2name: dark-mode3description: Use when deriving or fixing a dark theme: lightness mapping, elevation by surface lightness, shadow replacement, image dimming, and pure-black bans.4---567# Deriving a Dark Theme89A dark theme is derived from a finished light theme by re-binding roles to steps, never by mirroring the pixels. The ink/canvas pair does flip — the near-white step becomes the near-black one — but elevation does not: a raised surface is lighter than its page in *both* themes, so a mechanical inversion yields black cards floating on grey, and is the most common way a dark mode ends up wrong without anyone naming why. The default posture is that dark mode is optional: Benji Taylor's site carries exactly two `prefers-color-scheme` rules and both sit inside embedded demos, so the site itself has none. Commit or build it properly; half-built is the worst of the three. This skill maps an existing set into the second theme — `color` picks the values and owns contrast, and this skill never picks a brand hue. `surfaces` owns the elevation ladder within a theme (shadow recipes, nested radii, hairlines); this skill owns expressing that same ladder as surface lightness once shadows stop reading.1011**Find the theme switch before writing a single token.** Read whether the project flips a `[data-theme]` attribute, a `.dark` class, `prefers-color-scheme`, or a provider such as `next-themes`, and put every dark value behind that exact mechanism. If a numbered variable scale already flips at one root selector, add to it. Never introduce a second switching mechanism, and never answer a dark-mode bug with per-component `dark:` overrides on top of a variable system built to make them unnecessary.1213## Quick Reference1415| Open this | When |16| --- | --- |17| [derivation-table.md](references/derivation-table.md) | You are producing the actual dark values and need the role-by-role mapping — canvas, raised surface, border, ink, accent, status, shadow, image. |1819## The Derivation Order2021Each step depends on the one above it.22231. **Surfaces first.** Darkest canvas, then each elevation level *lighter* than the one below.242. **Borders and rings second**, against the surfaces you just fixed.253. **Ink third**, then measure every pair per `color`.264. **Accent and status fourth**, moving chroma and lightness together.275. **Images, illustrations, and shadows last** — they sit on top of everything above.2829## Core Principles30311. **Point elevation toward lighter in both themes.** In light mode the page is grey and the card is white; in dark mode the page is near-black and the card is a lighter grey. The topmost surface is always the brightest in its theme. *Exception:* a modal scrim, which goes darker than the canvas because its job is to remove depth rather than add it.32332. **Never bottom out at `#000`.** Pure black leaves nothing below the base surface, so the elevation ladder loses its floor, and on OLED the bottom step becomes an unlit void the next step visibly detaches from. Use `#171717` as the darkest canvas and `#1f1f1f` as the first level above it. *Exception:* video letterboxing and full-screen media chrome, where true black is the right frame for the content.34353. **Never top out at `#fff` either.** Maximum-white ink on a near-black surface halates, thickening the letterforms and tiring the eye over a long session. The lightest ink step is `#fafafa`. *Exception:* one small element that must read as the brightest thing on screen — a live indicator, a focused caret.36374. **Replace shadows with lightness steps plus one ring.** A stacked depth shadow is invisible against a dark surface, so collapse each shadow token to a `1px` white ring at `0 0 0 1px rgb(255 255 255 / 0.09)`, hover `0.14`, and let surface lightness carry the depth. *Exception:* an overlay floating above a photo or video, where a real shadow still reads because the backdrop is not a theme surface.38395. **Flip elevation rings to translucent white, but keep separation borders solid.** A compositing ring (white at `0.09`) reads as light catching an edge; a separation border built the same way glows. Dividers, gridlines, and input edges go solid in the `#2a2a2a` range instead of `rgba(255,255,255,0.1)`. *Exception:* a border over a translucent or blurred material, where solid would reveal the panel's true colour.40416. **Pull brand chroma back `20–30%`, and raise `L` rather than lowering it.** A hue carried across at full chroma vibrates against a dark ground; reducing chroma settles it. But chroma ceilings collapse toward the poles, so a merely-darkened accent loses vividness twice over — move it *up* the ramp and desaturate. *Exception:* destructive red, tuned against contrast rather than comfort because it must stay loud enough to stop someone.42437. **Declare `color-scheme: dark` on the root.** Without it, native form controls, scrollbars, the caret, and the autofill background stay in their light rendering and give the theme away instantly — and no amount of custom-property work fixes any of them. *Exception:* none. It is one declaration.44458. **Dim assets until their own ground joins the surface ladder.** A screenshot or illustration authored on white out-glows every surface around it. The check is measurable: the asset's largest flat area must land below `L 0.6`, the same cutoff separating a light surface from a dark one. Re-export against the dark canvas where you can; dim in CSS only where you cannot. *Exception:* photography and user uploads, which are the subject and are never dimmed — give them the `1px` white outline at `10%` instead.46479. **Never reach for `filter: invert()`.** It flips photographs into negatives, turns logos into their own complements, and drags every hue `180°` around the wheel. No asset class survives it. *Exception:* none.484910. **Suspend transitions across the theme swap.** Every component carries its own duration, so a live switch cascades raggedly for as long as the slowest one runs. Kill transitions for the frame of the swap — with `next-themes` that is `disableTransitionOnChange` on the provider. *Exception:* a deliberately choreographed theme transition built as one animation, not thirty.505111. **Re-measure every pair; do not trust the mirror.** Perceptually even `L` steps make the *ink/canvas* flip safe, but muted text, disabled states, and placeholder greys routinely fail in exactly one theme. Run `color`'s thresholds again on the dark set. *Exception:* none — half of all dark-mode contrast failures are greys that passed in light.525312. **Ship light-only rather than half-built.** A dark theme is a second complete surface set with its own contrast pass, not `dark:` classes over the 60% of components someone reached. If the product is not used at night, beside a dark editor, or in media contexts, that budget buys more elsewhere. *Exception:* once you claim support, honouring `prefers-color-scheme` is not optional — a toggle with no system default is a half-build too.5455## Smells and Fixes5657| Smell | Fix |58| --- | --- |59| Cards vanish in dark mode | The light shadow stack is still active; collapse to the ring and raise the surface step |60| Cards darker than the page behind them | Elevation was mirrored; point it toward lighter |61| `#000` canvas | Raise to `#171717` so the ladder has a floor |62| Text feels smeared or too heavy | `#fff` ink; drop to `#fafafa` |63| Dividers glow | Translucent white on a separation border; go solid `#2a2a2a` |64| The accent buzzes against the surface | Cut chroma `20–30%` and move it up the ramp |65| Scrollbars, caret, or autofill still light | Missing `color-scheme: dark` on the root |66| A screenshot blazes white in the middle of the page | Its flat ground is above `L 0.6`; re-export or dim it |67| Photos look like negatives | `filter: invert()` — remove it entirely |68| The page ripples for half a second on toggle | Transitions not suspended during the swap |69| `dark:` overrides sprinkled through components | Flip the variable scale at the theme root instead |7071## Reporting a Derivation7273Report the dark theme as a table, one row per role: the light value, the derived dark value, and the rule that produced it — mirrored, elevation-inverted, chroma-reduced, ring-replaced, re-measured. Then list separately every pair that failed in dark and passed in light, with its repaired value; that set is where a reviewer's attention pays most.7475## Checklist7677- [ ] Elevation points toward lighter in both themes78- [ ] Darkest canvas is `#171717`, not `#000`; lightest ink is `#fafafa`, not `#fff`79- [ ] Shadow stacks collapsed to `0 0 0 1px rgb(255 255 255 / 0.09)`80- [ ] Elevation rings translucent white; separation borders solid `#2a2a2a`81- [ ] Accents desaturated `20–30%` and moved up the ramp, not down82- [ ] `color-scheme: dark` declared on the root83- [ ] Every asset's flat ground sits below `L 0.6`; no `filter: invert()` anywhere84- [ ] Transitions suspended for the theme swap85- [ ] Every pair re-measured against `color`'s thresholds in the dark set86- [ ] The theme is complete, or the project ships light-only on purpose