Dark Mode

Every Leed site is dark-mode aware the moment it is built. The mechanism is the plain CSS media query prefers-color-scheme, applied consistently by Leed’s own stylesheets, by whichever documentation color theme you selected, and by the palette in your repository. Nothing is stored, nothing is negotiated, and nothing needs to be switched on.

The mechanism

Two lines carry the whole feature.

:root {
  color-scheme: light dark;
}

@media (prefers-color-scheme: dark) {
  :root {
    /* dark values for the same custom properties */
  }
}

The media query is what every stylesheet in the stack uses to swap its custom properties. color-scheme: light dark is the part people leave out and then wonder about: it tells the browser the page is legible either way, so the browser’s own rendering — form controls, scrollbars, the default canvas behind your background, <input> and <select> chrome — flips with it. Without that declaration you get a dark page with a stack of white native widgets sitting on top of it.

Declare it once, on :root, in the file that holds your palette. leed.ai’s own tailwind/site/theme.css opens its :root block with exactly that line.

Which layer does what

Dark mode is not implemented in one place. Five layers participate, each through its own mechanism, and it helps to know which of them you own.

Layer / fileMechanismYours to change?What it affects
defaults.css (Leed)--default-* values redefined inside @media (prefers-color-scheme: dark) on :rootNoThe last rung of every token chain — what you see when neither a theme nor your site sets a color
docs/themes/<name>.css (Leed)A nested @media (prefers-color-scheme: dark) block inside the theme’s @utility, redefining its --docs-* valuesNo, but you can write your own theme insteadEverything a documentation color theme controls: accent, ground, code card, sidebar row, footer icons
tailwind/site/theme.css (yours)Your --site-* and brand tokens, light on :root, dark under one media queryYesThe whole site, marketing pages and documentation alike
leed/docs/style.hbs (Leed)Emits --nav-logo-url from documentationConfiguration.logo, with the dark logo inside a dark media queryIndirectly — you upload the two files in the CMSThe documentation navigation logo
hljs/<name>.css (Leed)Tailwind’s dark: variant on each highlight.js selector, e.g. text-[#24292e] dark:text-[#c9d1d9]No, but you can write your own code themeSyntax colors inside code blocks
src/static/js/mermaid.theme.json (yours)Two sibling keys, light and dark, merged over a shared baseYesDiagram palettes
dark: utilities in your .hbs templatesTailwind variant, compiled to the same media queryYesAnything you style per-element rather than per-token

The dark: variant is left at Tailwind v4’s default — nothing in Leed’s stylesheets declares a @custom-variant dark, which is the mechanical consequence of there being no toggle. dark: means prefers-color-scheme: dark and nothing else.

Put the whole palette in one file, in three blocks, in this order:

/* tailwind/site/theme.css */

@theme {
  /* Layout tokens only — sizes, widths, fonts. These are not colours and
     they do not swap. */
  --width-content-copy: 65ch;
  --height-header: 73px;
}

:root {
  color-scheme: light dark;

  /* The light palette. Every colour the site uses is named here. */
  --bg: var(--color-white);
  --fg: var(--color-neutral-900);
  --fg-muted: var(--color-neutral-600);
  --border: var(--color-neutral-200);
  --primary: var(--color-pink-700);
  --primary-soft: var(--color-pink-50);
}

@media (prefers-color-scheme: dark) {
  :root {
    /* The dark palette — the same names, nothing else. */
    --bg: var(--color-neutral-900);
    --fg: var(--color-neutral-50);
    --fg-muted: var(--color-neutral-400);
    --border: var(--color-neutral-700);
    --primary: var(--color-pink-500);
    --primary-soft: color-mix(in oklab, var(--color-pink-500) 12%, transparent);
  }
}

The dark block is at the bottom of the file and redefines only names that already exist above it. That is the invariant worth enforcing in review: if a token appears in the dark block and not in the light one, it is undefined in light mode and the var() chain runs off the end — which does not fall back to something sensible, it makes the property invalid and the element renders unpainted. Leed’s own defaults.css carries a comment about five tokens that shipped in exactly that state.

This is the shape leed.ai’s site uses, and it is what makes the next section possible.

Write the swap once, not twice

Here is the technique that changes what you do for the rest of this category.

Every color theme Leed ships repeats its entire token list a second time inside a prefers-color-scheme block, and every code theme repeats every color behind a dark: variant. That is roughly forty hard-coded values per theme, written twice. They have no choice: a shipped theme is written in literal Tailwind ramp references and knows nothing about your site.

You are not in that position. If your :root tokens already swap under one media query, a documentation theme that maps onto them needs no dark block at all:

@utility color-theme-mybrand {
  --docs-bg-color: var(--bg);
  --docs-text-color: var(--fg);
  --docs-border-color: var(--border);
  --docs-primary-color: var(--primary);
  --docs-body-link-color: var(--primary);

  @apply docs-theme-defaults;
}

Light and dark are one definition here. They cannot fall out of step, because there is only one thing to change. Halving the file is the smaller benefit; the real one is that the failure mode where somebody edits the light value and forgets the dark twin simply cannot occur.

The same applies to a code theme. Instead of text-[#24292e] dark:text-[#c9d1d9] on every highlight.js selector, write text-(--fg) once and let the token do the swapping.

The dark: variant in templates

Inside your Handlebars templates, dark: works exactly as it does in any Tailwind v4 project:

<p class="text-center text-neutral-600 dark:text-neutral-400">
  {{ description }}
</p>

Reach for it when you are styling one element and a token would be overkill. Reach for a token when the same color appears more than once — a dark: utility repeated across nine partials is nine places to edit.

What else follows the scheme automatically

The documentation logo. documentationConfiguration.logo.light and .dark are emitted as --nav-logo-url, with the dark URL inside a dark media query, and the docs-theme-defaults utility paints .nav-logo and .logo-footer from it. You upload both files in the CMS; they are covered on Documentation Header, Footer and Logos.

Diagrams. Mermaid re-themes live on a scheme change, with no reload: the page keeps each diagram’s source, so a change event re-initializes the palette and redraws. That is why the palette file has separate light and dark keys rather than relying on var() — the reason, the file format and the version floor are all on Theming Diagrams.

Syntax highlighting. Code themes carry their dark colors on the dark: variant, so a code block flips with everything else.

Every --component-* token. The three-rung chain — --docs-*, then --site-*, then --default-* — resolves at computed-value time, so whichever rung answers in dark mode is the one that paints. Each of the 40 site tokens has a distinct dark default behind it; they are cataloged on the Site Token Contract.

Testing it

You do not need to change your operating system’s setting to check a page. Every desktop browser can emulate the media query, and the emulation is per-tab, so you can keep one window in each scheme side by side.

Under leed site build --serve there is nothing to restart: the media query is evaluated by the browser, so flipping the emulation repaints immediately. Editing a color value under tailwind/ does trigger a rebuild, because any file under that directory forces a full recompile.

Open DevTools, then the Rendering panel (⌘⇧P / Ctrl+Shift+P → Show Rendering). Set Emulate CSS media feature prefers-color-scheme to prefers-color-scheme: dark. The setting survives a reload and applies to that tab only.

Two things to look at once you are there, because they are the ones that break quietly: an element painted from a token that has no light-mode value at all, which renders unpainted rather than wrong; and any color you wrote as a literal hex in a template, which will not have moved.

ESC