Guide 04

Light & dark variations

A dark theme is not an inverted light theme. Inversion breaks brand colours, destroys shadow-based hierarchy and produces the halation effect that makes pure-white text on pure-black feel like it is vibrating.

Never use pure black or pure white

Two rules cover most of the damage. First, the darkest background should not be #000000. A very dark neutral carrying a trace of your base hue — somewhere around 6–9% lightness — gives you room to build surfaces above it and stops the page looking like a hole. Second, the lightest text should not be #FFFFFF. Pure white on near-black produces halation, where the glyph edges bloom and the type feels unstable, particularly for readers with astigmatism. Around 94–96% lightness is enough.

Both rules exist because the dark theme needs headroom in both directions: space below the base surface for pressed states and space above it for elevation.

Elevation by lightness, not shadow

In a light theme a card floats because it casts a shadow onto the page. In a dark theme that shadow is nearly invisible — dark on dark. The convention that works is the opposite one: things closer to the viewer are lighter. Each level of elevation adds a fixed lightness step to the surface.

Surface ramp generated for a dark theme
TokenRoleApprox. lightnessLight-theme counterpart
--bg-basePage canvas7%98%
--surface-1Cards, panels11%100%
--surface-2Popovers, dropdowns15%100% + shadow
--surface-3Modals, tooltips19%100% + larger shadow
--border-subtleDividers22%90%
--border-strongInput outlines32%72%

Keep the steps even. Uneven steps make some layers look like mistakes. Four percent per level is a workable default: large enough to be perceptible on a mediocre laptop screen, small enough that four levels do not drift into mid-grey.

Desaturate the brand, then re-saturate the accent

A saturated colour that reads as confident on a white background reads as aggressive on a dark one, because the surrounding luminance is low and the chroma has nothing to compete with. Vivid saturated hues on dark grounds also amplify chromatic aberration at small sizes, making text edges look fringed.

The generator handles this with two adjustments. Brand primaries drop 8–15% saturation and gain 6–12% lightness when the theme flips to dark, keeping them recognisable while taking the edge off. Accents used only for small highlights move the other way — slightly more chroma, because at pip and badge sizes they need to survive a glance.

States in both themes

Hover in a light theme usually means darker. Hover in a dark theme usually means lighter. If you hard-code "hover equals darken by 8%", your dark theme's hover states will disappear into the background. Express state changes as a direction relative to the theme, not an absolute operation:

:root {
  --state-step: 8%;
  --state-direction: -1; /* light theme: darken */
}
:root[data-theme="dark"] {
  --state-direction: 1;  /* dark theme: lighten */
}

Active or pressed states should move roughly twice as far as hover, in the same direction, so the two are distinguishable by feel. Focus rings should not change colour between themes if you can avoid it — a single accent ring that clears 3:1 against both the light and dark surfaces is one fewer thing to maintain and one fewer thing to break.

Semantic naming makes the swap free

The single decision that determines how painful theming will be is whether your components reference semantic tokens or raw values. A component that says background: var(--surface-1) works in every theme forever. A component that says background: #FFFFFF needs an override in every theme you ever add, including the high-contrast one someone will ask for later.

Define the raw palette once as primitives (--violet-500), map primitives to semantics per theme (--surface-1: var(--violet-975)), and let components touch only semantics. Then a theme is a block of custom property reassignments on the root element and nothing else.

Respecting the user's choice

Default to prefers-color-scheme — the operating system already knows what the user wants and asking again is noise. Offer an explicit override for the minority who want the site to differ from their system setting, persist that override, and make sure the stored value can express three states: light, dark and "follow system". A two-state toggle silently traps users in whichever theme they last tapped.

One implementation detail worth getting right: apply the stored theme before first paint, in a small inline script in the head, or users will see a flash of the wrong theme on every navigation. Continue to tokens, export and preview for the export format that keeps both themes in one file.