Skip to content

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.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

Surface ladder

dark

canvas--ds-canvas
surface--ds-surface
raised--ds-surface-raised
overlay--ds-surface-overlay
inset--ds-surface-inset

light

canvas--ds-canvas
surface--ds-surface
raised--ds-surface-raised
overlay--ds-surface-overlay
inset--ds-surface-inset

The 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.

api-gateway

Healthy · 3 regions

Live

Certificate expires in 6 days

Renewal is automatic, but the DNS challenge record is missing.

api-gateway

Healthy · 3 regions

Live

Certificate expires in 6 days

Renewal is automatic, but the DNS challenge record is missing.

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.

#101010 on #E5E4E3 — 15.0:1

Deployment finished in 42 seconds across three regions. Every request is retried twice before the circuit opens.

#000 on #FFF — 21:1

Deployment finished in 42 seconds across three regions. Every request is retried twice before the circuit opens.

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.

Each layer lighter than the last

canvas · #101010

panel · #1F1F1F

control · #383838

Two steps, both upward. The control is the lightest thing in the stack, so it is unmistakably the part you touch.

The control goes darker than its panel

canvas · #101010

panel · #1F1F1F

control · #181818

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.

--ds-field · a control you can act on
Enter a working title

Sits above the card and lifts again on hover — it answers the pointer.

--ds-surface-inset · reads as switched off
Enter a working title

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.

Code block

holds content

Table header

labels a grid

Meter track

the unfilled part

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.

Light-theme values on a dark canvas — too dark, too saturated

Dark-theme values — lighter, calmer, readable

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.

Canvas#101010
Surface#1F1F1F
Raised#282828
Overlay#303030
Inset#181818
Hoverwhite 4.5%
Borderwhite 11.5%
Scrimblack 72%

Anatomy

Every part, every measurement, and the reason it is that number.

inset · a well, not a lift

Four surfaces, each a measured step apart. The shadow contributes almost nothing here — remove it and the hierarchy still reads.

  1. Canvas#1E1E39

    Not #000. Pure black causes halation with light text and removes the ability to render anything darker than the page.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Color

TokenValueUsed for
--ds-canvasPage background
--ds-surfaceCards, panels
--ds-surface-raisedElevated cards, active segments
--ds-surface-overlayDialogs, menus, toasts
--ds-fieldInputs, selects, textareas — a control is a raised surface
--ds-field-hoverThe lift a field takes under the pointer
--ds-surface-insetNon-interactive wells only: code blocks, table headers, meter tracks
--ds-fgPrimary text — 15.0:1 on canvas
--ds-fg-secondaryBody text — 9.4:1
--ds-fg-mutedCaptions — 8.2:1 on canvas, 4.7:1 on a hovered field
--ds-border-subtleDividers, card edges
--ds-layer-hoverHover wash over any surface
--ds-accentBrand — C64 Purple, one step down so a white label passes AA
--ds-layer-scrimBehind modal surfaces

Shadow

TokenValueUsed for
--shadow-e1 … e5—Tighter and darker than their light-theme counterparts

Do

--ds-canvas
--ds-surface
--ds-surface-raised
--ds-surface-overlay
Lighten to elevateSurface lightness is the primary depth channel in dark mode. It survives greyscale, high-contrast mode and a phone in sunlight; a shadow on near-black does none of those.
same border token
same border token
same border token
Use alpha for overlays and bordersA 6% white border composes correctly over the canvas, a card, a menu and a coloured banner. A solid grey border is correct on exactly one of them.
:root { color-scheme: dark; }
Set color-schemeIt tells the browser to render native scrollbars, the text caret, form controls and autofill backgrounds in dark. Without it you get white scrollbars and a blinding autofill.
Dim images and illustrations slightlyA photograph at full brightness against a dark UI is a light source. A small brightness reduction on non-critical imagery keeps the page comfortable without altering content.

Don't

Inverted — and now the brand colour is green.

Do not invert the light themeInversion produces mid-greys with no meaningful lightness steps, a brand colour that vibrates, and shadows that are invisible. Dark mode is a separate design, not a filter.

#FFFFFF on #000000 — 21:1 and unpleasant.

Do not use pure black or pure whiteMaximum contrast causes halation, and #000 leaves nowhere to go for anything that needs to be darker than the page — like an inset input.
Do not reuse light-theme shadowsA soft grey shadow on a dark surface fogs the edge instead of defining it. Dark shadows are darker, tighter, and always paired with a hairline border.
too darktoo hot
Do not keep light-theme saturationHigh-chroma colours on dark backgrounds vibrate — the eye cannot focus both planes at once. Lighten the value and pull a little chroma out of it.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

1.4.3Contrast (Minimum)AA1.4.8Visual PresentationAAA1.4.11Non-text ContrastAA

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 → themeThe 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.
AttributeApplied toNotes
color-scheme: dark:rootNative scrollbars, caret, form controls, autofill and the browser UI all follow it.
prefers-color-schemeMedia queryThe initial default only. An explicit user choice must always win and must persist.
forced-colorsMedia queryHigh Contrast Mode replaces the palette entirely. Test that layout and semantics survive without any of your colours.

Code

Example usage

tsx
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

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);
}

Notes

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.