Artifact theming
The single most common visual bug in published pages, and it comes from a wrong mental model: there are three theme states, not two.
| State | What the root element carries | Detected by |
|---|---|---|
| Explicit dark | data-theme="dark" |
attribute |
| Explicit light | data-theme="light" |
attribute |
| System default | nothing | prefers-color-scheme only |
The default setting stamps no attribute. A page that only handles [data-theme="dark"] is unstyled
for everyone on system default — which is most people.
The pattern that covers all three
/* 1. Complete light palette on bare :root. EVERY token defined here. */
:root {
--surface: #ffffff;
--ink: #14161a;
--border: #e3e6ea;
--grid: #eceff3;
/* … the full set … */
}
/* 2. System dark - guarded so an explicit light choice still wins */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--surface: #101215;
--ink: #e8eaed;
--border: #262b31;
--grid: #1c2126;
}
}
/* 3. Explicit dark - so the toggle wins in both directions */
:root[data-theme="dark"] {
--surface: #101215;
--ink: #e8eaed;
--border: #262b31;
--grid: #1c2126;
}
Three rules, and each prevents a specific bug:
- Every token gets its definition on bare
:root. A colour whose only definition lives inside a media query or a[data-theme]block is undefined in the third state. - Guard the media block with
:not([data-theme="light"]). Without it, a viewer who explicitly chose light gets dark anyway when their OS is dark. - Redefine explicit dark separately. The media query does not fire for a viewer whose OS is light but who chose dark in the app.
Blocks 2 and 3 carry identical values. That duplication is the price of covering all three states; do not try to collapse it.
Give the body an explicit background
body { background: var(--surface); color: var(--ink); }
A transparent body borrows the host's background. The symptom: dark text on a dark ground, or a page that looks fine for you and unreadable for someone on the other theme. Always paint it.
Charts under theme change
Chart colours are the part people forget, because the chart looks fine in whichever theme they built in.
- Grid and axis lines must be tokens (
--grid,--axis), not hardcoded#eee. A light grid on a dark surface disappears; a dark grid on a light surface shouts. - Series colours need adjusting, not inverting. A hue that reads well on white is often too dark on near-black. Lighten and slightly desaturate series colours in dark mode rather than flipping them — flipping breaks the identity mapping between a segment and its colour.
- Text inside SVG uses
fill, notcolor.fill: var(--ink)— a common miss, because the element inheritscolorand silently stays black. - Semantic colours (positive/negative) need their own dark values; the light-mode green is usually too dark to read on a dark surface.
.chart-text { fill: var(--ink); }
.grid { stroke: var(--grid); }
.axis { stroke: var(--axis); }
Print and greyscale
Board packs get printed, and a chart that only works in colour becomes unreadable. This is not a nicety — it is where a colour-only encoding finally fails visibly.
@media print {
:root { /* force the light palette regardless of theme */
--surface: #fff; --ink: #000; --ink-muted: #444;
--border: #999; --grid: #ddd;
}
body { background: #fff; color: #000; }
.no-print { display: none; } /* controls, filters, toggles */
.chart { break-inside: avoid; }
table { break-inside: auto; }
thead { display: table-header-group; } /* repeat headers across pages */
a[href]::after { content: " (" attr(href) ")"; font-size: 10px; }
}
Then check greyscale separation: every series must be distinguishable without colour. If two
series converge to the same grey, add a second encoding — dash pattern for lines, hatch or texture
for areas, direct labels for bars. See artifact-accessibility.
An optional toggle
If you add one, it must set the attribute and be reachable by keyboard:
const root = document.documentElement;
btn.addEventListener('click', () => {
const explicit = root.getAttribute('data-theme');
const systemDark = matchMedia('(prefers-color-scheme: dark)').matches;
const currentlyDark = explicit ? explicit === 'dark' : systemDark;
root.setAttribute('data-theme', currentlyDark ? 'light' : 'dark');
});
Note it reads the effective state, not just the attribute — otherwise the first click on a system-dark page appears to do nothing.
Verification — all three states, every time
Do not trust one look. Per artifact-testing:
- System default with OS light — no attribute set
- System default with OS dark — no attribute set; this is the state that catches missing base definitions
- Explicit light on a dark OS — catches a missing
:not()guard - Explicit dark on a light OS — catches a missing
[data-theme="dark"]block - Print preview
- Greyscale — a browser filter is enough
Check specifically: body background, SVG text fill, grid lines, borders, muted text contrast, and placeholder/disabled states. Those are where the misses concentrate.
Deliberately single-theme
A design may commit to one look. That is legitimate — but then paint background and colours explicitly and skip the dark blocks entirely. What is not legitimate is accidentally single-theme: a page that works in the theme you built in and breaks in the other.
Related skills
design-tokens— the role-named tokens this switchesartifact-accessibility— contrast in both themes, greyscale separationartifact-testing— the verification passsvg-charting— why SVG text needsfill