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#171717as the darkest canvas and#1f1f1fas 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
#fffeither. 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
1pxwhite ring at0 0 0 1px rgb(255 255 255 / 0.09), hover0.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#2a2a2arange instead ofrgba(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 raiseLrather 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: darkon 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 the1pxwhite outline at10%instead. -
Never reach for
filter: invert(). It flips photographs into negatives, turns logos into their own complements, and drags every hue180°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-themesthat isdisableTransitionOnChangeon 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
Lsteps make the ink/canvas flip safe, but muted text, disabled states, and placeholder greys routinely fail in exactly one theme. Runcolor'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, honouringprefers-color-schemeis 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
- Elevation points toward lighter in both themes
- Darkest canvas is
#171717, not#000; lightest ink is#fafafa, not#fff - Shadow stacks collapsed to
0 0 0 1px rgb(255 255 255 / 0.09) - Elevation rings translucent white; separation borders solid
#2a2a2a - Accents desaturated
20–30%and moved up the ramp, not down -
color-scheme: darkdeclared on the root - Every asset's flat ground sits below
L 0.6; nofilter: invert()anywhere - Transitions suspended for the theme swap
- Every pair re-measured against
color's thresholds in the dark set - The theme is complete, or the project ships light-only on purpose