Dark mode

How dark mode works in this system. Every colour comes from a token and the tokens flip, so a page or component built from them follows the reader without any work of its own; nobody hand-picks a dark value.

The theme control

One control, identical on both surfaces, because a reader who learns it on arxiv.org should not have to learn it again in a moderation queue. It ships as .ds-theme-toggle plus theme.js, and every page under docs/ carries it.

Why a control at all, when the standing preference is to honour OS-level signals rather than offer in-product settings: an OS setting is one choice for a whole machine, and reading is per-context — the same laptop at night in a bright room, a shared library machine whose OS the reader does not control. It stays one control, not the first item of a settings page.

Note

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

System

The default: the page follows the machine's setting, and follows it again if it changes.

Light

Always light, whatever the machine says.

Dark

Always dark. The next press returns to System.

In the header, where it lives

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

<!-- in the header bar -->
<button type="button" class="ds-theme-toggle"></button>
<span id="ds-theme-status" class="is-sr-only" role="status"></span>
<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 and the accessible name. It is the last item in the header bar, one button; the three states are never shown side by side.
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.

A component that follows the tokens

The same card four times. Nothing in its markup changes between the cells except the data-theme attribute and, for internal tools, .ds-internal: every colour inside comes from a token, and the tokens are declared again under the attribute, so the nearest ancestor that carries it wins.

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

A page locked to light

Any page can opt out and lock to light, which is what a page whose job is to show light-mode rendering needs — colour specimens, side-by-side comparisons. Lock a whole page only when the entire page exists to demonstrate light-mode rendering; a single demo that must show one mode takes the attribute on its own container instead.

Note

Use the theme control in the header: the page changes and 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 carries 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.

Accessibility essentials

  • Respect the setting the reader already made. A page follows the operating system's colour scheme unless the reader says otherwise on the page itself. Without JavaScript there is no button and the page follows the OS: the control is an addition, never the only route to a readable page.
  • The icon is the only visible part, so the name carries the state. The button's accessible name says which state is on (“Theme: following the system. Activate to change.”), and a press announces its result in the live region. A sighted reader learns the three icons from the cards above; a screen reader user is told, and is never guessing whether the moon means “it is dark” or “make it dark”.
  • Never a dark value by hand. Take every colour from a token and it flips with the rest of the page. A hex written into a page or a component cannot flip, and it will not announce itself.
  • Check contrast in both modes. Verify computed styles and measure contrast in every state a reader can reach: OS dark, OS light, and the attribute forced either way. Do not trust the cascade and do not judge by eye. Anything that uses colour to carry meaning keeps its second cue (icon, word, position) in dark mode too — the WCAG 1.4.1 requirement applies in both modes.
  • Let form controls follow. The stylesheet sets 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.

Rules

Checklist: 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. Trap 1
  3. Fill raised things with --ds-surface. Not --ds-canvas: the wash is a pale tint in light and is the canvas in dark, so anything filled with it disappears. Trap 2
  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. Trap 3
  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. Why the attribute, not the toggle
  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.

Traps that only show up on a real page

Three ways a token-driven page still comes out wrong in dark mode. All three were found building the blog, which is the first arXiv site to run dark end to end.

TrapWhat goes wrongWhat to do
A token that fills a large field Tokens whose dark value is a text accent go pale — Archival Blue flips to a light blue meant for headings. Painted across a masthead, the field washes out. Pin the literal value and say why in a comment, the way the accent colors are pinned. An identity field is a fixed-role color, not a flipping one.
A fill that equals the canvas Warm Wash is a pale tint in light and is the canvas in dark. Anything filled with it — placeholder blocks, quiet panels — disappears in dark mode. Fill raised things with --ds-surface, which is white in light and a lifted surface in dark. Reserve the wash for the page itself.
Text color set outside the tokens A platform preset or inherited color (a theme's global text setting, an inline style) cannot flip, so body text stays dark on a dark canvas. Set body color and background from tokens, and let everything inherit. Check computed styles rather than trusting the cascade.

Scoping a mode

Never hand-pick dark values into a light page

Dark tokens flip automatically with the OS, or not at all. If a demo needs a fixed mode, put data-theme on the demo's own container rather than locking the whole page — and if you do lock a page, 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.

Spec

The mechanism

Dark values live in one @media (prefers-color-scheme: dark) block in each stylesheet, keyed to the OS setting, and the same declarations are written again under [data-theme="dark"] so the reader's choice wins over the machine's. The mirror is generated from the @media block and must stay identical to it — python3 verification/check-drift.py fails if the two disagree, on both stylesheets.

The OS preference is the default and covers most readers, but dark also has to win on a light-OS machine whenever something sets data-theme="dark", which is why the mirror exists. The trigger is the attribute, not a toggle: inherited code sets it too. The HTML paper mockup has no toggle of its own, yet the ar5iv reader it builds on ships a theme control that writes ar5iv_theme to localStorage, and a bootstrap script turns that into the attribute. Keying only to the media query left a reader who picked dark there on a light-OS machine with a dark canvas and light tokens. If a page can ever carry the attribute, mirror the block:

<html data-theme="dark">    <!-- forces dark regardless of the OS -->
<html data-theme="light">   <!-- forces light, and opts out of the @media block -->
<html>                      <!-- no attribute: follow the OS -->

Tokens that flip

The full public palette: Repository Brown becomes the dark canvas, accents hold their light values, and the button constructions deepen their borders and shadows for definition. The secondary zone ground, --ds-zone-secondary-bg, is Card Grey in light and the canvas in dark, because Card Grey is the raised surface in dark and the two zones would not show. Both columns are read at runtime from design-system.css through a light island and a dark island, so the values cannot drift from the stylesheet and do not depend on the mode this page is in.

TokenLightDarkBetween modes

Internal tools

Internal tools load the same tier 1 and take their accent from .ds-internal on <html>, so everything above applies to them unchanged. Their own stylesheet, internal-tools.css, re-points only the tokens whose dark value differs on that surface: --ds-surface, --ds-surface-muted, --ds-surface-hover, --ds-surface-hover-strong, --ds-surface-active and --ds-text-strong, a shade apart from tier 1 because internal screens are dense with tables and want more separation between a row and its ground. Its dark block is mirrored under [data-theme="dark"] the same way and checked by the same script. Components riding those tokens: buttons, icon buttons, info card, alerts, forms, data table, sortable headers, filter select, segmented control, toggle, type badges, metadata panel.