Dark mode

One attribute on the root element. Without it, the system decides.

See it

Flip the toggle at the foot of the sidebar. The page, the components in it and the numbers in the margin change together, because they read the same tokens; nothing moves, because dimension is not a token that changes with the light.

ConfirmedThursday, 14:30

Set it

<html data-theme="dark">

Both themes are in styles.css. data-theme="light" or "dark" wins; with no attribute, the viewer's system preference does. color-scheme follows the same rule, so scrollbars, native select popups and autofill match the page.

What changes with the light

The 54 theme tokens and the 2 shadow steps, and nothing else: spacing, radius, stroke and type are the same by day and by night. The ladder does not mirror. In light, a card and a panel are both white and the shadow separates them; in dark the panel is the lighter step, because a shadow stops reading as height there. The accent fill lightens in dark and its label darkens to stay clear of it — a white label would fail at the hover step. One border stays: border/strong is the same primitive in both, the only stop that clears 3:1 on every surface either way.

TokenLightDark
surface/base
App canvas
stone/050
night/950
surface/raised
Cards, panels, table body
white
night/925
surface/overlay
Modals, popovers, dropdowns
white
night/900
text/primary
Headings and body
stone/900
stone/050
interactive/accent
Primary button fill
twilight/600
twilight/400
interactive/on-accent
Label on accent
white
night/950
border/strong
All form control boundaries
stone/500
stone/500

Choosing a default

Follow the system until the viewer says otherwise. Most people never touch a theme control, and the one they set at the operating system is the one they meant; an app that opens dark on a light system has made a decision for them. When you offer a control, keep the choice where the viewer made it — storage, and the attribute — and give a way back to the system, or say plainly that there is none: this site's toggle has two positions, follows the system until the first click, and then the choice is the viewer's to keep. That trade is written on the control.

Name the control for the state it sets. A switch called Dark theme is on or off, which is what a screen reader announces; a button that says Toggle theme says nothing about where it leaves you.

Switching

Write the attribute and store the choice. Remove both to hand the decision back to the system.

function setTheme(theme: 'light' | 'dark' | null) {
  const root = document.documentElement;
  if (theme) root.setAttribute('data-theme', theme);
  else root.removeAttribute('data-theme');
  try {
    if (theme) localStorage.setItem('theme', theme);
    else localStorage.removeItem('theme');
  } catch {
    // Private windows and blocked site data throw. The page still switches.
  }
}

A flip changes the colour of nearly everything at once, and every colour transition on the page fires together: this site measured 99 of them on one page, and the buttons faded while the page snapped. Hold transitions for the frame the attribute is written in — an attribute on the root, transition: none under it, removed on the next frame — and let only the control's own motion run.

Before the first paint

A stored choice has to reach the root before the browser paints, or a viewer who chose dark sees one frame of light. A script in the document head runs while the HTML is parsed, which is early enough:

<script>
  try {
    var theme = localStorage.getItem('theme');
    if (theme === 'light' || theme === 'dark') {
      document.documentElement.setAttribute('data-theme', theme);
    }
  } catch (e) {}
</script>

In Vite or plain HTML it goes in index.html. In the Next.js App Router, not next/script with beforeInteractive: that queues the code for the runtime, which runs after the first paint. This site renders the script from its root layout through a small client component, and InlineScript.tsx explains why it has to be one.

With next-themes

<ThemeProvider attribute="data-theme">{children}</ThemeProvider>

Beside shadcn/ui, whose components read a .dark class, set both, and one switch drives the two systems:

<ThemeProvider attribute={['class', 'data-theme']}>{children}</ThemeProvider>

Accessibility

Every pair on this site is measured in both modes, and the dark figures are on the same pages as the light ones — a system that passes in one mode has passed once. Dark is not the light palette reversed: the surfaces were re-stepped, the labels re-measured on every fill state, and the borders that could not hold were given an alpha. Respecting prefers-color-scheme without a control is the floor; a viewer who set it did so for a reason, and often the reason is their eyes.