Dark Theme
The default theme. Depth comes from surfaces getting lighter, not from shadows getting bigger — and it is emphatically not an inversion of the light theme.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
dark
--ds-canvas--ds-surface--ds-surface-raised--ds-surface-overlay--ds-surface-insetlight
--ds-canvas--ds-surface--ds-surface-raised--ds-surface-overlay--ds-surface-insetThe same UI in both themes
Identical markup and identical component code. Only the tier-2 tokens change — and note that the dark version is not the light version with the colours flipped.
Why not pure black
Both blocks pass contrast comfortably. The left is what we ship; the right is what maximum contrast actually feels like after two minutes of reading.
Stacked surfaces only go up
The single rule that decides every surface colour in dark mode: whatever sits on top of something else is lighter than the thing behind it. Depth is lightness here, so a surface that goes darker is not "recessed" — it has moved backwards, behind the page it is supposed to be sitting on.
canvas · #101010
panel · #1F1F1F
Two steps, both upward. The control is the lightest thing in the stack, so it is unmistakably the part you touch.
canvas · #101010
panel · #1F1F1F
The control is darker than the panel AND darker than the page behind it. It has fallen through both.
A control is a surface, not a hole
Material 3 puts a filled text field on surface-container-highest — the lightest container in the ramp, never a darker one. The reason is behavioural, not decorative: lightness is what reads as "in front of the page", and anything a person is meant to click or type into has to read that way. The Bible used to back fields with --ds-surface-inset and it was wrong; inset is now reserved for wells nobody interacts with.
Sits above the card and lifts again on hover — it answers the pointer.
Sits below the card it is inside. On a near-black page there is nowhere darker left to go, so it stops reading as a field at all.
The one place darker is right
Inset survives for wells that hold content rather than accept input — a code block, a table header, the unfilled part of a meter. Nothing here invites a click, so reading as "behind the surface" is accurate rather than misleading. If it has a focus ring, it is not one of these.
Colour has to move
The top row is the light-theme value shown on a dark canvas. The bottom row is the dark-theme value. Same role, different step of the ramp.
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
Every part, every measurement, and the reason it is that number.
Four surfaces, each a measured step apart. The shadow contributes almost nothing here — remove it and the hierarchy still reads.
- Canvas#1E1E39
Not #000. Pure black causes halation with light text and removes the ability to render anything darker than the page.
- Surface step≈4–6% lightness per level
Below 3% the levels are indistinguishable; above 8% the top surface starts reading as a different colour rather than a higher one.
- Field#383838 — above every surface, the overlay included
Controls go UP, past every container including the overlay. Material 3 puts a filled field on the lightest container in the ramp: a thing you can act on is a surface standing on the page, not a hole cut into it. An input inside a dialog is where systems get this wrong.
- Inset#181818 — darker than surface
Only for wells nobody clicks: code blocks, table headers, meter tracks, media areas. It used to back inputs too, which is what made fields read as switched off.
- Borderwhite at 6–19% alpha
Alpha, so it composes over any surface. A solid grey border only matches one background and looks wrong on the other three.
- Foreground ceiling#E5E4E3, not #FFFFFF
14.8:1 rather than 21:1. Well past AAA, and specifically below the point where light text starts to bloom against a dark field.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| --ds-canvas | Page background | |
| --ds-surface | Cards, panels | |
| --ds-surface-raised | Elevated cards, active segments | |
| --ds-surface-overlay | Dialogs, menus, toasts | |
| --ds-field | Inputs, selects, textareas — a control is a raised surface | |
| --ds-field-hover | The lift a field takes under the pointer | |
| --ds-surface-inset | Non-interactive wells only: code blocks, table headers, meter tracks | |
| --ds-fg | Primary text — 15.0:1 on canvas | |
| --ds-fg-secondary | Body text — 9.4:1 | |
| --ds-fg-muted | Captions — 8.2:1 on canvas, 4.7:1 on a hovered field | |
| --ds-border-subtle | Dividers, card edges | |
| --ds-layer-hover | Hover wash over any surface | |
| --ds-accent | Brand — C64 Purple, one step down so a white label passes AA | |
| --ds-layer-scrim | Behind modal surfaces |
Shadow
| Token | Value | Used for |
|---|---|---|
| --shadow-e1 … e5 | — | Tighter and darker than their light-theme counterparts |
:root { color-scheme: dark; }Inverted — and now the brand colour is green.
#FFFFFF on #000000 — 21:1 and unpleasant.
Not a checklist to run at the end. These are the requirements the component was built from.
Contrast
- Dark themes need contrast checked independently — a pair that passes on white frequently fails on near-black and vice versa.
- Our primary text is 15.0:1, body 9.4:1 and captions 8.2:1 against the canvas. All comfortably above AA, all deliberately below 21:1. Captions are certified against --ds-field-hover, where they read 4.7:1 — the lightest ground in the system, and the one that decides whether they pass.
- Alpha borders and tints must be composited against their actual surface before measuring. A 6% white border over the canvas and the same border over an overlay are different colours.
Keyboard
| ⌘K → theme | The command palette exposes the theme switch, because a keyboard user should not have to hunt for it. |
Screen readers
- Theme has no effect on assistive technology. Never use it as a signal — a "dark banner means danger" convention is invisible to a screen reader and to a light-theme user.
Focus & touch
- The focus ring uses #8584DD in dark and is drawn with a 2px offset, which is what makes it legal: against the page it reads 5.8:1, while against the accent fill it would only be 1.5:1. A ring that hugs a filled button is measuring itself against the wrong thing.
- Dark interfaces are typically used in low light, where pupils are dilated and glare is worse. Keep large bright surfaces to a minimum and avoid full-white modals.
| Attribute | Applied to | Notes |
|---|---|---|
| color-scheme: dark | :root | Native scrollbars, caret, form controls, autofill and the browser UI all follow it. |
| prefers-color-scheme | Media query | The initial default only. An explicit user choice must always win and must persist. |
| forced-colors | Media query | High Contrast Mode replaces the palette entirely. Test that layout and semantics survive without any of your colours. |
Example usage
1// The theme lives on <html> so a single attribute re-themes everything2document.documentElement.dataset.theme = 'dark'34// Respect the OS default, but let an explicit choice win and persist5function initialTheme(): 'dark' | 'light' {6 const saved = localStorage.getItem('theme')7 if (saved === 'dark' || saved === 'light') return saved8 return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'9}1011// Prevent the flash of the wrong theme: run this before first paint,12// as an inline script in <head>, not in a React effect.13;(function () {14 const t = localStorage.getItem('theme') ||15 (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')16 document.documentElement.dataset.theme = t17})()1819// A light island inside the dark app — used by every preview in this Bible20<div data-theme="light">21 <Card>Renders in light regardless of the app theme</Card>22</div>CSS
:root, [data-theme='dark'] {
color-scheme: dark;
/* Surfaces climb in lightness. Never pure black. */
--ds-canvas: #101010;
--ds-surface: #1f1f1f;
--ds-surface-raised: #282828;
--ds-surface-overlay: #303030;
--ds-surface-inset: #181818; /* darker: wells only, never a control */
/* Foreground stops short of pure white to avoid halation */
--ds-fg: #e5e4e3; /* 15.0:1 */
--ds-fg-secondary: #b6b6b6; /* 9.4:1 */
--ds-fg-muted: #aaaaaa; /* 8.2:1 */
/* Alpha, so one token works over every surface. The alphas are a few
points higher than a mid-tone ground needs: white wins less contrast
against a dark neutral, so an edge at 6% would disappear. */
--ds-border-subtle: rgb(255 255 255 / 0.063);
--ds-border: rgb(255 255 255 / 0.115);
--ds-layer-hover: rgb(255 255 255 / 0.045);
/* Brand lightens and loses a little chroma */
--ds-accent: #6867c9; /* light theme uses #6a55f2 */
/* Shadows are darker and tighter than in light mode */
--ds-shadow-3: 0 4px 8px -4px rgb(0 0 0 / 0.60),
0 12px 20px -6px rgb(0 0 0 / 0.44);
}
/* Take the edge off bright media without altering content */
[data-theme='dark'] img:not([data-no-dim]),
[data-theme='dark'] video {
filter: brightness(0.92);
}Professional tips
- Design dark first if the product is developer-facing. Building light second exposes every place where surface lightness was doing structural work.
- Put the theme attribute on <html> and set it from an inline script in <head>. Setting it in a React effect guarantees a visible flash on every load.
- Test on an OLED phone at minimum brightness. Ramps that look smooth on a calibrated monitor often band badly there.
- Keep code blocks and inputs darker than their container. Inverting that relationship makes an input look like a button and confuses people before they can say why.
Performance
- Switching a data attribute on :root invalidates style for the whole document, which is one full style recalculation — imperceptible for a deliberate theme switch, far too expensive to animate.
- Do not transition colours on a theme switch. A 300ms crossfade of every element on the page is one of the most expensive things a web app can do.
- OLED screens use meaningfully less power on dark pixels. For a mobile-first product that matters more than most performance work.
Common mistakes
- Forgetting color-scheme, then getting white scrollbars, a white autofill background and a light date picker in an otherwise dark app.
- Reusing light-theme shadow values, which look like grey fog on a dark surface.
- Making the input the same colour as its card, so the field boundary disappears entirely.
- Leaving one hard-coded #FFFFFF in a component. It is invisible in the light theme and glaring in the dark one.
Real-world recommendations
- Ship both themes from day one. Retrofitting a second theme means auditing every hard-coded colour in the product, and there are always more than anyone expects.
- Give users three choices — dark, light, system — and remember the choice. "System" alone is not enough for people whose OS setting does not match their preference for your app.
- Screenshot your product in both themes side by side and look at them for a minute. Inconsistencies that are invisible in isolation are obvious in comparison.
- When a designer supplies only light-mode mockups, ask for the dark surface ramp before building. Deriving it during implementation always produces mid-greys with no hierarchy.