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.
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.
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.
The default setting. The page follows the machine's setting.
View in light mode regardless of the system setting.
View in dark mode regardless of the system setting.
In a header
<!-- 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>
<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.<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”.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.
Accents hold in both modes: Open Blue and Access Lime stay themselves, which is why the text on them is a fixed token too.
<!-- 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 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.<html>.color-scheme beside the
tokens, so native form controls, scrollbars and the default canvas take the same mode as the page. Do not
set it yourself to force a mode: it governs those browser-rendered widgets only and does nothing to any
stylesheet's prefers-color-scheme rules. Use data-theme.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.
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.
<!-- 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>
@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.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.
In order. Each step links to the reasoning below. If you follow only this list and read nothing else, dark mode will work.
--ds-surface. Not
--ds-canvas: the page ground is white in light and Repository Brown in dark, so anything
filled with it disappears.--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.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 modedata-theme on the demo's own
container, not on the page. Pages should follow the reader.
Scoping a modedata-theme, mirror the dark block under the attribute as
well as the media query — including inherited code you did not write.
How the attribute workscolor-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.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.
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.
| Token | Light | Dark | Between modes |
|---|