Dark mode

In the design system, every colour comes from a token and the tokens flip in each mode. A page or component built from the color tokens follows the mode without any additional work and no hand-picking of values.

The theme control

One control that is identical everywhere it appears. It ships as .ds-theme-toggle plus theme.js.

Though we honor the user's OS-level mode preference, it is still useful for users to have a manual override. A person might be on a shared library computer, not everyone knows how to set the OS-level preference, and the environment can change quickly. Think of a user looking at a paper on their phone on a train, moving from tunnels to the glare of sunlight. A simple affordance makes papers more accessible in more situations.

Note

The three cards show the icon for each state; the bar below them is the control itself, in the header, and one control cycles through the three. It is live, and so is the one at the top of this page.

Automatic/System

The default setting. The page follows the machine's setting.

Light

View in light mode regardless of the system setting.

Dark

View in dark mode regardless of the system setting.

In a header

Relevant code
<!-- in the head, NOT deferred -->
<script src="theme.js"></script>

<!-- in the header bar, after the navigation -->
<div class="ds-site-header-tools">
  <button type="button" class="ds-theme-toggle"></button>
  <span id="ds-theme-status" class="is-sr-only" role="status"></span>
</div>
<script src="theme.js">
Goes in the head, and it is not deferred; that is load-bearing. The attribute has to be on <html> before the first paint or the page renders light and then flips — worst for exactly the reader who chose dark because light hurts. check-policies.py fails on a page that defers it.
.ds-theme-toggle
The control. Three states, not two: follow the system, always light, always dark — and follow the system is the default a reader has to be able to get back to. A two-way switch cannot express it, so the first touch would lose the machine's setting for good. That is also why it is a <button> cycling a value rather than the switch, which means on or off. Leave the element empty: the script fills in the icon, the accessible name and the state as hidden text. In the header bar it follows the navigation and comes before the account area, as on the header unit; it is one button, and the three states are never shown side by side. When a public header folds on a phone, it is a row in the menu, and the hidden text shows beside the icon: “Theme: following the system”.
id="ds-theme-status"
The live region the result goes to. The button's name does not change when the theme does — the name of a control is what it does, and it is still the theme control; the live region says “Theme is now dark”. Same rule as the copy button and the switch.

Example: a card component in light and dark modes

The same card in light and dark modes, on a public page vs. an internal page. Nothing in its markup changes except the data-theme attribute and, for internal tools, .ds-internal is added to the parent element.

Note

Accents hold in both modes: Open Blue and Access Lime stay themselves, which is why the text on them is a fixed token too.

Public, light

Body text on a raised surface, with an inline link.

Preferences saved.

Public, dark

Body text on a raised surface, with an inline link.

Preferences saved.

Internal tools, light

Body text on a raised surface, with an inline link.

Preferences saved.

Internal tools, dark

Body text on a raised surface, with an inline link.

Preferences saved.

Relevant code
<!-- The component: nothing in it knows which mode it is in -->
<div class="ds-card">
  <p>Body text on a raised surface, with <a href="…">an inline link</a>.</p>
  <div class="ds-alert ds-alert--success" role="status">…</div>
  <div class="ds-btn-group">
    <button class="ds-btn ds-btn-primary" type="button">Primary</button>
    <button class="ds-btn" type="button">Secondary</button>
  </div>
</div>

<!-- The same component, forced dark on any page -->
<div class="ds-card" data-theme="dark" style="color: var(--ds-text)">…</div>

<!-- Internal tools: the accent comes from the class, the mode from the attribute -->
<div class="ds-card ds-internal" data-theme="dark" style="color: var(--ds-text)">…</div>
data-theme="dark", data-theme="light"
On any element, not only the root. The tokens are declared on any element with the attribute and custom properties inherit, so the nearest ancestor with data-theme wins, and nesting works in both directions: a dark sample inside a light panel inside a page following the OS renders correctly. The attribute re-points tokens and paints nothing itself. Anything inside that already reads tokens follows; the container has to take its own ground and text from tokens too, which here is .ds-card for the ground and color: var(--ds-text) for the text.
.ds-internal
Internal tools. Re-points the accent to Access Lime, from tier 1, and composes with the attribute: lime holds its light value in dark, the wash goes to a dark olive, and text that sat on the wash turns lime to stay legible. An internal page puts it on <html>.

Accessibility essentials

Modifiers

Locking to light

Any page or element can lock to light mode only by adding data-theme="light". We use this feature when demonstrating light mode, but it can also be useful if the content being worked with has no dark mode. For example, if porting in complex visualizations from an external engine that does not support dark mode.

Note

Test it with the theme control in the header: the page changes but this panel does not.

This panel stays light whatever the page does. Its <code>, its links and its controls all read the light tokens declared on the panel.

Relevant code
<!-- The whole page: only when the page exists to show light rendering -->
<html lang="en" data-theme="light">

<!-- One demo, while the page follows the reader -->
<div class="ds-card" data-theme="light" style="color: var(--ds-text)">…</div>
data-theme="light" on <html>
Locks the page to light and opts it out of the @media block. Check that nothing rewrites it: a static attribute in the markup is not a lock if a script on the page sets the attribute at load. theme.js does exactly that, so a page that includes the theme control cannot also be locked.
data-theme="light" on a container
One demo shows light while the page follows the reader. The same scoping as the dark island above, in the other direction.

Internal tools need no modifier

Internal tools load the same tier 1 stylesheet as public pages. Their speciall accent color comes from adding .ds-internal on <html>, so everything above applies to them unchanged. The internal pages tier 2 stylesheet, internal-tools.css, re-points only the tokens whose dark value differs on that surface. No manual adjustments are needed.

Rules

Adding or changing a component

In order. Each step links to the reasoning below. If you follow only this list and read nothing else, dark mode will work.

  1. Take every colour from a token. Never write a dark hex into a page or a component. Components that consume tokens flip for free. How it works
  2. Decide whether the colour is content or identity. Content flips. Identity is pinned to a literal with a comment saying why — a masthead, a brand fill, a colour specimen whose subject is the value itself.
  3. Fill raised things with --ds-surface. Not --ds-canvas: the page ground is white in light and Repository Brown in dark, so anything filled with it disappears.
  4. Check any text sitting on an accent fill. Accents hold their light values in dark, so the text on them must not flip either — --ds-text-on-accent on lime and on Open Blue alike. Reaching for the plain token puts near-white text on a light fill, about 1.3:1.
  5. Set the page's own background and text from tokens. An inherited colour from a platform preset or a third-party stylesheet cannot flip, and it will not announce itself.
  6. Do not use color-scheme to force a mode. It governs browser-rendered widgets only — scrollbars, form controls — and does nothing to any stylesheet's prefers-color-scheme rules. Use data-theme. Scoping a mode
  7. Scope a demo that must show one mode. Put data-theme on the demo's own container, not on the page. Pages should follow the reader. Scoping a mode
  8. If anything can set data-theme, mirror the dark block under the attribute as well as the media query — including inherited code you did not write. How the attribute works
  9. Verify computed styles in both modes, and measure contrast. Do not trust the cascade and do not judge by eye. Check the states a reader can actually reach: OS dark, OS light, and the attribute forced either way.

Scoping a mode

  • Component CSS consumes tokens. Do not write hex values directly.
  • color-scheme does not lock a page to a mode. It governs browser-rendered widgets like scrollbars, form controls, and the default canvas. It does not touch a stylesheet's prefers-color-scheme rules. If absolutely needed, use data-theme to lock a mode.

Spec

The mechanism

Dark values are declared twice in each stylesheet: once in a @media (prefers-color-scheme: dark) block that follows the OS, and once under [data-theme="dark"] so the attribute can force a mode. A page takes one of three states:

<html data-theme="dark">    <!-- dark, whatever the OS says -->
<html data-theme="light">   <!-- light, whatever the OS says -->
<html>                      <!-- follows the OS -->

Only semantic tokens change between modes. Primitives such as Repository Brown keep their value; what changes is the role a token points them at.

Color tokens

The table below is rendered at runtime from design-system.css through a light island and a dark island, so the values will not drift from the stylesheet. Do not edit manually.

TokenLightDarkBetween modes