surface
Colour in an interface is mostly the absence of colour. Get the neutrals and the elevation right and one accent will do the rest.
Tokens first
Name colours by role, never by value. A palette that survives both themes has roughly this shape:
| Token | Role |
|---|---|
--page |
The outermost background, behind the app frame |
--frame |
The app surface |
--raised |
Cards, popovers, anything lifted one step |
--hover |
Hover fill for rows and items |
--line |
Default border |
--line-2 |
Stronger border, focus rings, dividers that must be seen |
--fg |
Primary text and icons |
--fg-2 |
Secondary text |
--fg-3 |
Tertiary, meta, placeholders |
--fg-4 |
Faint: decorative, disabled, dividers in text |
--accent |
One. For the primary action and focus, and nothing else |
--ok, --warn, --danger |
Status, muted, with a text-safe variant each |
Everything else derives. Components never reference a hex; they reference a token. Dark mode is a second set of values for the same tokens, not a second set of components.
Neutrals
Near-black and near-white, both slightly warm or slightly cool, consistently. Pure #000 and #fff are too hard; a dark theme on #080808 with text at #ededed reads as considered where #000/#fff reads as unset.
A workable dark set:
page #080808 · frame #0d0d0d · raised #141414 · hover #161616
line #1c1c1c · line-2 #262626
fg #ededed · fg-2 #9b9b9b · fg-3 #6b6b6b · fg-4 #454545
And light:
page #f2f2f0 · frame #fbfbfa · raised #ffffff · hover #f1f1ef
line #ebebe8 · line-2 #dcdcd8
fg #151515 · fg-2 #5f5f5c · fg-3 #8d8d89 · fg-4 #b8b8b3
Steps are small on purpose. Surfaces differ by 2–4% lightness; that is enough for the eye to separate them without stripes. Test by taking a screenshot and squinting: you should see the structure, not a patchwork.
Build neutrals in OKLCH when generating them, so that steps are perceptually even and the hue stays constant as lightness changes.
Dark mode is not inverted light mode
- Light mode lifts things with shadows. Dark mode lifts things with lighter surfaces and borders. A shadow on
#0d0d0dis invisible. - Dark surfaces get a 1px inset top highlight at 4% white to read as lit from above:
box-shadow: 0 1px 0 rgba(255,255,255,.04) inset. - Saturated colours glow on dark. Desaturate the accent 10–20% and lower its lightness for dark mode.
- Pure white text on pure black vibrates. Lower both.
- Images and illustrations may need a subtle dim (
opacity: .9) or a dark-mode variant. color-scheme: darkon the root so form controls, scrollbars and the browser UI match.
Borders vs shadows
| Theme | Card / popover edge |
|---|---|
| Dark | border: 1px solid var(--line-2) + inset highlight; a soft outer shadow only for floating layers: 0 12px 40px -12px rgba(0,0,0,.9) |
| Light | box-shadow: 0 1px 0 rgba(255,255,255,.9) inset, 0 12px 32px -14px rgba(0,0,0,.18); border only where the surface sits on the same colour |
Shadows are one layer, soft, with negative spread, in the direction of a single light source. Never 0 0 20px (a glow), never multiple stacked blurs, never a coloured shadow in product UI.
Borders are 1px. Not 0.5px (it renders inconsistently), not 2px unless it is a focus ring.
Alpha
Overlays, hover fills and dividers on unknown backgrounds use alpha, so they work on any surface:
- Hover fill: foreground at 4–6%
- Pressed fill: foreground at 8–10%
- Divider: foreground at 8–12%
- Backdrop behind dialogs: page colour at 60–70%, with
backdrop-filter: blur(4px)at most - Glass surfaces (
backdrop-filter) are expensive; one per screen, never animated, never nested
The accent
One hue. It goes on the primary button, the focus ring if the neutral ring is too quiet, links in running text, and the active state of a control. It does not go on icons, headings, borders, backgrounds or badges.
If everything is the accent, nothing is. Count the accent-coloured elements on a screen; if it is more than three, remove some.
Status colours are muted (a green at ~55% chroma, not a traffic light) and always paired with a word or icon. Colour is never the only signal.
Contrast
- Body and secondary text: 4.5:1 against its surface.
- Large text (18px+ or 14px+ bold) and UI boundaries (icons, borders on inputs): 3:1.
- Tertiary text is allowed to sit at 3:1 only when it is not needed to use the interface.
- Faint (
--fg-4) is decoration. Never put information in it. - Check both themes. A grey that passes on white will fail on
#0d0d0d.
Placeholders count as text if they carry the only label; then they must pass. Better: always have a visible label.
Gradients, noise, texture
Product UI: none, or one very quiet radial behind a hero region at 2% opacity to give the page a light source. Marketing: allowed, and still one.
Noise or dither over a gradient hides banding and adds material; keep it at 3–6% opacity and tile a small SVG rather than a large PNG.
A backdrop pattern (grid, ticks, dots) at the --line level sits behind the frame and stops at its edge; it never runs under content.
Selection, focus, scrollbars
::selection { background: var(--fg); color: var(--frame); }
:focus-visible { outline: 1px solid var(--fg-2); outline-offset: 2px; }
* { scrollbar-width: thin; scrollbar-color: var(--line-2) transparent; }
These three lines are the difference between a themed app and a page with a theme applied to it.
Theme switching
- Decide the theme before first paint with an inline script reading storage and
prefers-color-scheme. A flash of the wrong theme is a bug. - Store the choice; respect the system when there is no choice.
- Transition
background-coloron the root over ~400ms so the switch is not a strobe. Or use a View Transition to reveal the new theme from the toggle; either way, under reduced motion it is instant. - Both themes are first-class. If the team only ever looks at dark, light will rot, and vice versa. Review in both.
Marks of a cheap surface
| Sign | Fix |
|---|---|
#000 page, #fff text |
#080808 / #ededed |
| Cards with border and shadow and a different background | one of the three, two at most |
Glow shadows (0 0 24px accent) |
soft directional shadow or none |
| Every card a different tint | one surface token |
| Accent on icons and headings | neutrals; accent on the one action |
| Grey text at 2.5:1 "because it looks lighter" | --fg-2 that passes; use size and weight for hierarchy |
| Gradient buttons | flat fill, border-only secondary, ghost tertiary |
| Dark mode with the same shadows as light | borders and inset highlights |
From the catalogue
- Radix Colors — scales with a defined step for each role, in both themes.
- Open Props — sensible defaults for shadows and neutrals.
- Realtime Colors — see a palette on a real layout before committing.
- Contrast checkers — check it before someone else does.
- Dark Mode Design — sites that got dark right.
- fffuel — SVG noise and grain generators.
- More at stealth.pm/assets.