Acknowledgement: Shared by Peter Bamuhigire, techguypeter.com.
-
Treat dark as a remap, never an inversion. Do not flip lightness (L → 1−L) or invert
the hex values. Inversion produces over-bright, over-saturated, vibrating colour and breaks
semantic meaning (your "danger" red can land on a near-white that no longer reads as danger).
Build the dark column role by role from the brand, exactly as color-system-and-palette
step 8 directs — this skill is that step, expanded. State up front that you are remapping.
-
Anchor dark surfaces in a brand-tinted dark grey, not #000. Pure black surfaces with
light text over-contrast and smear (halation) — text appears to bleed, especially at small
sizes and for readers with astigmatism. Use a near-dark grey carrying a trace of the brand
hue: in OKLCH, base surface around L 0.14–0.20, very low chroma (C ≈ 0.01–0.03) on
the brand hue. This keeps the dark theme feeling authored in the brand, not a default void
(Mission §0, doctrine/design-doctrine.md). See references/dark-mode-semantic-roles.md.
-
Lower chroma across the whole dark palette. Saturated colours that read as confident in
light mode glow and buzz against dark surfaces (simultaneous contrast / chromatic aberration
at the eye). Pull chroma down for surfaces, borders, and especially large filled areas. The
accent and status colours keep more of their chroma than surfaces but still less than their
light-mode values — see step 4. This is the single biggest tell of a real dark mode versus a
naive one.
-
Lighten and slightly desaturate accents and status colours so they hold meaning at depth.
A mid/dark accent that passed 4.5:1 on a light surface will fail badly on a dark one. Raise
each accent's and status colour's lightness (and trim its chroma a little) until it clears the
gate against the dark surface it actually sits on — typically the light-mode L moves up by
~0.15–0.30 in OKLCH. The hue is preserved so error still reads red, success still green;
only L and C move. This is where "not just inversion" becomes concrete.
-
Express elevation with surface lightness, not drop shadows. In light mode, raised
surfaces use shadow; on dark surfaces shadows are nearly invisible, so the convention flips:
higher elevation = lighter surface. Build an elevation ramp where base/sunken is darkest
and each raised layer (card → menu → dialog → popover/toast) steps L up by ~0.02–0.04.
Keep chroma low and constant so the steps read as depth, not as a different colour. Optionally
add a faint lighter top border (a 1px highlight) instead of a shadow to imply a raised edge.
Material's "elevation overlay" is the same idea; implement it as explicit surface tokens.
-
Hold contrast parity across modes — match the experience, not the numbers. Body text in
dark mode should feel as legible as in light, which usually means not maxing it out: pure
white body text (L 1.0) on dark over-contrasts and shimmers exactly as #000-on-white does.
Target a near-white primary text around L 0.92–0.96 and a muted text that still clears
4.5:1 (or 3:1 where it is genuinely large/secondary). Parity means each role does the same job
with comparable comfort in both modes — not that the ratios are identical.
-
Re-gate every dark pair on WCAG independently — passing in light proves nothing. This is a
hard gate, not advice. Body and small text ≥ 4.5:1; large text (≥24px, or ≥18.66px bold)
and meaningful UI components / icons / focus rings / borders that convey state ≥ 3:1
(WCAG 2.2 §1.4.3, §1.4.11 — doctrine/references/wcag-2.2-criteria.md). Check the accent and
each status colour against every elevation surface it can appear on, not just base. Design
with APCA for real legibility, certify with the WCAG ratio (same ref, "Contrast method note").
Any failing pair sends you back to step 4 to retune L/C.
-
Map the status set deliberately for dark. success / warning / error / info must each stay
distinguishable from one another and hold ≥3:1 (≥4.5:1 if they carry text) against dark
surfaces. Warning (yellow/amber) is the usual casualty — it goes muddy or illegible; lighten
it well and verify. Never rely on hue alone to carry status (colour-blind safety) — keep the
icon/label that accessible-color-and-contrast mandates.
-
Define theming as a token swap, not duplicated components. Components reference semantic
roles only; switching theme swaps the values behind the roles (e.g. [data-theme="dark"]
or an @media (prefers-color-scheme: dark) block re-binding the CSS custom properties).
Support a manual toggle that overrides the system preference, and persist the choice. Honour
prefers-color-scheme as the default. Hand the final role→value map to
09-design-systems-tokens-and-theming/design-tokens-and-naming for architecture/export.
-
State the dual-mode system and hand off. Output both columns of the role map (light +
dark, in OKLCH with hex), the elevation ramp, the recorded contrast results for both modes,
and the switch mechanism. See examples/light-dark-token-pair.md.
Read and rendered theme inspection are required. Editing needs implementation authority; preference persistence and deployment remain with engineering. Do not destructively alter master brand assets.
If required evidence or tooling is unavailable, use the scoped fallback below and mark the result unverified.
Without rendering, deliver a conditional role map and state matrix, marking adaptation unverified. Recover with the light theme when dark assets or accessible pairs cannot be established.