Templates
Dark Mode Spec One-Pager
By Flora May dela Cruz
The single-page spec a designer hands engineering when adding dark mode to a product that wasn't designed for it. Names the three layers it has to touch, the contrast traps, and the seams to leave documented.
What this is
A drop-in spec template for adding dark mode to a product that started as light-only. It’s deliberately one page — enough to make every implementation decision visible, short enough that engineering will actually read it. The format mirrors how dark mode is actually built in component-library-backed apps: a token swap handles most of it, a CSS layer handles what the tokens can’t reach, and a small set of runtime branches handle what neither can.
Fill it in, hand it over, and the engineer should be able to start without a follow-up meeting.
When to use it
- A product is adding dark mode for the first time
- A product has dark mode but it was implemented per-component and now drifts
- A new third-party component or chart library has been introduced and its dark behavior needs to be specified
- A design review or audit is reviewing dark-mode coverage and needs a single artifact
The template
# Dark Mode Spec — <product name>
Author: <name> · Last reviewed: <date> · Status: <Draft | Active>
## 1. Position
- Light is the product baseline.
- Dark is additive — every dark rule is scoped so that light mode is
byte-identical to the pre-dark codebase.
- Default mode on first load: <light | dark | follow OS>.
- Persistence: <localStorage key | cookie | user account setting>.
- Toggle location: <topbar | settings | both>.
## 2. The three layers
Dark mode is implemented across exactly three layers. Every dark style
must live in one of them, named.
### Layer A — Token swap
The component library's theme provider is swapped at the root between a
light theme object and a dark theme object. This handles ~80% of
component chrome (button surfaces, text colors, borders, badges).
| Concern | Light | Dark |
|---|---|---|
| Surface (cards) | <#fff> | <#141D2F> |
| Canvas (page bg) | <#F5F7FF> | <#0D1320> |
| Primary brand foreground | <hex> | <hex — usually lifted for contrast on dark> |
| Stroke / divider | <hex / rgba> | <rgba on white, e.g. 0.12 alpha> |
| Elevation shadow | Tinted soft | Black-heavy (>0.30 alpha) |
### Layer B — Scoped CSS
For everything the token system can't reach. Each rule lives under a
single selector at the document root:
:root[data-theme="dark"] { ... }
Document each rule with one line of "what it works around and why a
token wasn't enough."
Typical contents:
- Body / canvas background outside the React tree
- Third-party components with hardcoded colors
- Decorative chrome that doesn't exist in light mode at all
(gradient waves, halos, custom borders)
- Overlay backdrops / native dialog backdrops
- Chart libraries that don't consume the component library's theme
### Layer C — Runtime branches
For layout deltas that depend on the theme — sizes, line heights,
palette swaps in data visualization. Branch in component code via a
single hook: `const isDark = useThemeMode().mode === 'dark'`. Keep
the light path untouched.
Typical contents:
- Display-scale hero typography (larger only in dark, per design)
- Sankey / chart palette swaps where dark reads as near-black
- Opaque card variants where decorative chrome would bleed through
## 3. Contrast traps
These are the four places dark mode quietly fails contrast and where
you must verify against WCAG AA before shipping.
| Trap | Why it fails | Required check |
|---|---|---|
| Filled badge text | Library defaults to white on a bright fill (green / red / amber) — fails at ~2:1 | Override the badge text color to near-black on filled badges in dark mode |
| Lifted brand foreground | The light-mode brand color is too dark to read on the dark canvas | Brand foreground must be a lifted variant; verify ≥ 4.5:1 against canvas |
| Status palette | Default library status colors often deepen in dark and lose contrast | Specify the dark status palette explicitly; verify against the surface color used |
| Decorative chrome bleeding through translucent cards | Card backgrounds are translucent white over canvas; a decorative wave under them shows through | Mark cards adjacent to decorative chrome as opaque, not translucent |
## 4. Coverage matrix
One row per surface. The matrix doubles as a checklist.
| Surface | Dark coverage | Notes |
|---|---|---|
| Shell (top bar, app rail, section nav) | ✓ | Layer A + Layer B chrome |
| Page X | ✓ | <one-line note on anything page-specific> |
| Page Y | ⚠ Partial | <gap + owner + target date> |
| Charts | ✓ | <chart library name> consumes Layer A; <other chart library> uses Layer B fallback |
| Modals & drawers | ✓ | Backdrop deepened in Layer B; built-in dialogs inherit Layer A |
## 5. Seams (the things future-you will trip over)
List the implementation seams openly. The point is so the next
designer doesn't rediscover them the hard way.
- <Any pinned third-party class names or hashes — these must be
re-verified after every library upgrade>
- <Any third-party component library that ignores the theme provider
and needs a Layer B fallback>
- <Any !important uses, and why they are load-bearing>
- <Any hardcoded hex values still in feature components, and the
planned migration to tokens>
- <Decision on prefers-color-scheme auto-follow: yes / no / planned>
## 6. Adding dark coverage to a new surface — checklist
- [ ] All colors come from component-library tokens or design tokens
(no inline hex)
- [ ] Cards inherit the Layer B card rule, or opt out with a
documented data attribute
- [ ] Decorative chrome (gradients, halos, custom borders) is scoped
under `:root[data-theme="dark"]`
- [ ] Charts using legacy libraries are wrapped in a marker attribute
paired with a Layer B fallback rule
- [ ] Any layout delta is a runtime branch keyed on `isDark`, with
the light path untouched
- [ ] The same page renders identically in light mode after the
change (regression check)
## 7. Testing
- Manual: toggle on every route; verify no light-mode regressions.
- Contrast: filled badges, brand-on-canvas, status pills, focus rings.
- Reduced motion: dark-mode decorative animations honor
`prefers-reduced-motion`.
- Forced colors / high contrast OS mode: dark mode should not break
the high-contrast pass.
End of template. Fill the bracketed values, list your surfaces in section 4, list your seams openly in section 5. The seam list is the most important section of the entire document — that’s the institutional memory.
Why this fits on one page
Dark-mode work tends to grow indefinitely because there’s always one more component to verify. The one-page constraint forces the decisions that actually matter to the front: position (additive, not separate codebase), the three layers, the contrast traps, the coverage status, and the named seams. Anything that doesn’t fit isn’t part of the spec — it’s implementation detail and lives in code comments.
Companion artifacts
- The Accessibility Compliance Baseline Playbook for the contrast requirements this spec must verify against
- The Subtraction Playbook — useful if your team is debating whether to ship dark mode as a separate codebase vs. additive (it should almost always be additive; document the refusal)
Public-safe review (verified before publish)
- No employer or client product names or codenames
- No customer data
- No real component-library class hashes or pinned versions
- No real package names
- No real brand hex values — example colors illustrative only
- A peer outside any employer could read this and learn nothing proprietary
Take this playbook with you
Drop your email to copy the markdown or download the file. One email unlocks every playbook in the Toybox.
No spam. Occasional notes on new playbooks. Unsubscribe in one click.