Design6 min read

Building a dark mode design system: tokens, surfaces, contrast

Semantic tokens, dark grey surfaces, elevation, contrast, imagery, and a checklist that keeps both themes honest.

Material Design's dark theme guidance asks for a surface dark enough that white text on it reaches a contrast ratio of at least 15.8:1, then tells you not to make that surface black. Those two rules, held together, explain most of what goes wrong when a team ships dark mode as a CSS afterthought.

Dark mode in a design system is a second set of values for the same semantic color tokens, so every component changes theme without changing its code. Surfaces, text, borders, and states each get a dark value that is chosen and contrast-checked on its own terms, not inverted from the light one.

Key takeaways

  • Dark mode works best as a mode of your semantic color tokens, so components reference roles, never hex values.
  • Material Design recommends #121212, a dark grey, as the base surface and reserves true black for OLED battery savings.
  • Text needs 4.5:1 contrast and UI boundaries need 3:1 in both themes, so every dark token gets its own check.
  • The system preference, read through prefers-color-scheme, should be the default; a toggle is a deliberate extra.

Why dark mode belongs in the token layer

Most broken dark themes share a root cause: components that know their colors. A card that hard-codes #FFFFFF has no way to change when the theme does, so someone writes a dark override per component, and the overrides drift.

The fix is a semantic layer between palette and components. Primitives describe values (gray-950, blue-300). Semantic tokens describe jobs (surface-default, text-primary, border-subtle, focus-ring). Components reference only semantic tokens, and each token holds one value per mode.

Apple's Human Interface Guidelines agree: use colors that adapt to the current appearance, and remember that dark colors aren't necessarily inversions of light ones.

Dark mode is a second set of design decisions, not a filter over the first.

Why not use pure black for dark mode?

Material 2's dark theme guidance, still the clearest written rationale, recommends #121212 for three reasons:

  • Shadows are easier to see on dark grey than on black, so grey surfaces can still show depth.
  • Light text on dark grey has slightly less contrast than on black, which Material says reduces eye strain.
  • A grey base leaves room to go lighter for raised surfaces.

The same guidance asks you to desaturate brand colors. Saturated colors can fail 4.5:1 against dark surfaces and create an optical vibration that tires the eye. Material's baseline dark theme uses the 200 tone of the primary palette and saves full saturation for one or two elements, such as a logo.

True black has one legitimate use: OLED wearables and other battery-sensitive products, whose screens switch black pixels off. Material warns it can blur content during scrolling.

How should elevation and surfaces work in a dark theme?

Shadow carries elevation in a light theme. Against near-black, it barely registers, so the surface itself has to change:

Platform guidanceRaised surfaces in dark modeLesson for your tokens
Material 2White overlay from 5% at 1dp to 16% at 24dpHigher layers get lighter
Material 3Five surface container roles, not tied to elevationName surfaces by role
Apple HIG (iOS)A brighter background set for popovers and sheetsKeep levels few

Material 3 dropped elevation-based tinting in March 2023 for tone-based roles: surface container lowest, low, container, high, and highest. The naming is the part worth copying. A token called surface-container-high survives a redesign; elevation-3-dark doesn't.

Five surface tokens cover most products: page, container, raised (cards, menus), overlay (dialogs, sheets), and inverse (a light snackbar in a dark UI).

Contrast checks that catch dark-mode regressions

WCAG's thresholds don't change between themes. Text needs 4.5:1 (3:1 for large text), and UI components and meaningful graphics need 3:1 against adjacent colors under success criterion 1.4.11. Apple sets 4.5:1 as the floor and suggests 7:1 for custom colors, especially small text.

What changes is where things break:

  1. Opacity-based text. Material uses white at 87%, 60%, and 38% opacity for high-emphasis, medium-emphasis, and disabled text. The result depends on the surface underneath, so test secondary text on your lightest surface.
  2. Borders. A border-subtle that reads on white often vanishes on #121212. Inputs, checkboxes, and toggle tracks need 3:1 boundaries.
  3. Focus rings. A brand-blue ring tuned for white can drop below 3:1 on dark. Give focus-ring its own dark value.

Material's worst-case test is the one to automate: body text on the highest, lightest surface. If it passes there, it passes everywhere below.

Imagery, illustrations, and icons

Color tokens don't reach inside a PNG. Apple's guidance: slightly darken images with white backgrounds so they don't glow, outline icons that lose their edge on dark, and make separate light and dark assets when one version fails. On the web, a picture element with a (prefers-color-scheme: dark) source swaps files per theme.

  • Draw icons with currentColor so they inherit text tokens.
  • Ship light and dark logos and pale-background illustrations.
  • Make chart series colors tokens with dark values.
  • Skip invert() filters, which wreck photos and brand colors.

prefers-color-scheme, toggles, and testing both modes

The prefers-color-scheme media feature reports whether the user asked their system or browser for a light or dark theme, and MDN lists it as available across browsers since January 2020. The light-dark() CSS function, Baseline since May 2024, returns one of two colors based on the active scheme once you set color-scheme: light dark.

A toggle is a product decision. Apple advises against app-specific appearance settings, because people expect apps to follow their systemwide choice. Web products often add one anyway. If you do, make it three-state (System, Light, Dark), persist it, and apply the theme before first paint so nothing flashes white.

Then test both modes on purpose. Chrome DevTools emulates prefers-color-scheme: dark from its Rendering panel, and Playwright takes a colorScheme option per browser context, so snapshots and contrast checks can run in both themes. A dark-mode bug usually ships in the theme nobody opened.

The dark-mode token checklist

CheckPass condition
Semantic coverageNo component uses a hex value
Base surfaceDark grey; white text at 15.8:1 or higher
Surface rolesNamed surfaces, lighter as they rise
Brand colorDesaturated, with one or two saturated accents
Text4.5:1 on the lightest surface
Boundaries and focus3:1 on every surface
StatesHover, pressed, selected, and error have dark values
ImageryDual logos, currentColor icons, softened images
PreferenceSystem default; optional three-state toggle
TestsSnapshots and contrast checks in both modes

Where Pegasus fits

Pegasus treats dark mode as a token mode, not a second theme maintained by hand. Its 470 design tokens ship as Figma variables with light and dark modes, covering color, type, spacing, radius, and shadow, and all 1,540 components have light and dark variants. One token source themes both the Figma variables and the React code's theme object, so a surface or text color you adjust once changes on both surfaces. Component names and variant structure match too, so a dark-mode fix spotted in Figma has a named counterpart in code.

Contrast is documented per component as part of building to WCAG 2.1 AA, alongside keyboard paths and focus states, which gives you a baseline to recheck after you retheme. Storybook ships with stories and tests for the components, a natural place to add your own dark-mode snapshots.

FAQ

What color should a dark mode background be?

Use a dark grey rather than pure black. Material Design recommends #121212 and asks that white text on the base surface reach at least 15.8:1 contrast, so body text still passes 4.5:1 on the lightest raised surface. Pure black mainly suits battery-sensitive OLED products such as wearables.

Should my app follow the system setting or offer a dark mode toggle?

Follow the system setting by default, through prefers-color-scheme on the web or adaptive colors on native platforms. Apple advises against app-specific appearance settings. If you add one, offer System, Light, and Dark, remember the choice, and apply it before first paint.

Do I need separate color tokens for dark mode?

No. You need the same semantic tokens with a second value each. Components reference roles like surface, text, and border, and each role holds a light and a dark value. Separate token sets per theme tend to drift; one set with two modes keeps names identical, so components never know which theme is active.

Sources

One system. In Figma and in code.

Pegasus ships 1,540 components, 470 tokens, matching React code, Storybook, and a local MCP server. $149, one-time.

Get the full system