Design Tokens for a Docs Theme: CSS Variables, X-Style Dark Mode, Reduced Motion

Was this helpful?

Your docs look “fine” until a real user shows up with a real setup: dark mode on, reduced motion enabled, OLED screen,
and a browser zoom level you didn’t test. Then the code blocks glow like a welding arc, the sidebar hover states vanish,
and the smooth-scroll animation keeps running like it’s trying to win a marathon.

Themes fail in boring ways: inconsistent colors, unreadable contrast, jumpy layout, and “one-off” CSS that turns into a
landfill. Design tokens—done with CSS variables and a sane dark-mode + reduced-motion strategy—are how you keep a docs
site boring in production. Boring is good.

What design tokens actually are (and what they are not)

A design token is a named decision. Not a color. Not a font. Not “#1d9bf0”.
A decision: “this is the accent color” or “this is the focus ring color” or “this is the spacing between stacked
paragraphs.” Tokens let you talk about decisions in a way that survives redesigns, dark mode, and three different CSS
frameworks trying to occupy the same repo.

Token layers: don’t skip this or you’ll regret it later

If you flatten everything into one pile of variables, you create a theme that can’t evolve. You want layers:

  • Base tokens: raw primitives like colors (in a perceptual space if possible), font families, spacing steps.
  • Semantic tokens: intent-based names like --color-bg, --color-fg, --color-accent.
  • Component tokens: only when needed, e.g., --codeblock-bg or --sidebar-hover-bg.

Base tokens are allowed to change during rebrand. Semantic tokens should mostly stay stable. Component tokens are
a controlled escape hatch—use them sparingly and only when semantic tokens can’t express a component’s constraints.

What tokens are not

  • Not a dumping ground for every random hex value someone pasted from a mock.
  • Not a replacement for layout primitives and sensible component design.
  • Not a license to invent cute names like --blueberry-muffin. You’re not running an ice cream shop.

One reliable heuristic: if a variable name wouldn’t help someone debug a contrast issue at 2 a.m., it’s not a token,
it’s a secret.

Facts and historical context that matter in 2025

Themes and tokens feel trendy. They are, but they’re also the convergence point of a few decades of CSS history,
accessibility work, and platform conventions. Here are concrete facts that influence today’s docs themes:

  1. CSS custom properties (variables) shipped broadly starting around 2017. That’s old enough to trust in production, new enough that some teams still avoid them out of habit.
  2. prefers-color-scheme became widely usable in modern browsers around 2020–2021, turning dark mode from “extra CSS file” into a first-class user preference signal.
  3. prefers-reduced-motion is a media query born from OS-level accessibility settings; ignoring it is equivalent to ignoring “I get motion sickness.” Users notice.
  4. WCAG contrast guidance (notably 2.1 and later) pushed teams away from “looks fine on my laptop” toward measurable readability for normal and large text.
  5. OLED adoption made pure black backgrounds more common—but also made overly bright text and saturated accents feel harsher. “Dark mode” is not “#000 everywhere.”
  6. Syntax highlighting for code blocks is now a major readability surface. It’s not decorative; it’s literally how your docs communicate. Bad tokens here silently tax users.
  7. Design token standards efforts (including community formats and toolchains) normalized the idea of tokens as a pipeline artifact, not just CSS variables in a file.
  8. System UI conventions trained users to expect theme choice to be remembered per-site, but to default to system preference. If you fight this, you lose. Quietly, but you lose.

This isn’t trivia. It’s why the “just pick some colors” approach dies the moment your docs become popular.

A token model that won’t collapse later

Docs themes have a special problem: they’re mostly typography and content density, with small interactive elements
(nav, search, code copy buttons). That tempts teams to under-design tokens. Then the first “minor” change request
lands—new callout types, API method badges, a different syntax theme—and suddenly you’re debugging why #fff
is used 46 times in 12 files.

Start with constraints: docs are not a marketing site

Your docs theme should optimize for:

  • Readability at long session lengths.
  • Consistency across content types (Markdown, tables, code, diagrams).
  • Predictable interaction (focus states, hover states, keyboard nav).
  • Low regression risk when you change tokens.

Your docs theme should not optimize for:

  • Maximum brand saturation on every pixel.
  • Novel animations.
  • “Delight” that breaks scrolling or triggers motion sensitivity.

Opinionated token naming

Use a naming scheme that encodes intent and scale. For example:

  • --space-1, --space-2, --space-3… for spacing steps (base tokens).
  • --radius-1, --radius-2… for corner rounding.
  • --color-neutral-0--color-neutral-100 for neutral ramp (base tokens).
  • --color-bg, --color-fg, --color-muted, --color-border (semantic tokens).
  • --color-link, --color-link-hover, --color-focus-ring (semantic tokens, still intent-based).

Avoid tying semantic tokens to brand names: --color-twitter-blue is a future bug report.
The brand will change or the context will, and your token name will lie.

Token count: fewer than you think, more than you want

If you define 300 tokens on day one, you’re not building a theme—you’re building a second job.
If you define 12 tokens, you’re building a trap.

A practical starting point for a docs theme:

  • Neutrals ramp: 10–14 steps.
  • Accent + states: accent, accent-hover, accent-muted, focus ring.
  • Status colors: success/warn/error/info with two steps each (solid + subtle background).
  • Typography: 2 font families (ui + mono), 1–2 line-height tokens, scale steps for font size.
  • Spacing + radii: 6–8 steps each.

That’s enough to theme the whole site without turning your CSS into interpretive dance.

CSS variables in production: mechanics and sharp edges

CSS custom properties are runtime-resolved variables. That’s the feature and the footgun. They inherit, they can be
overridden, and they participate in the cascade. This makes them perfect for themes.

Use the cascade on purpose

The cascade isn’t chaos. It’s a control plane. Your docs site should define tokens at:

  • :root for defaults.
  • [data-theme="dark"] for dark-mode overrides.
  • Optional: component scopes like .codeblock only for component tokens.

Don’t define tokens at random nested levels unless you’re intentionally scoping them. Otherwise you get “works on one
page” bugs because a container changed a token and the child component inherited it.

Prefer semantic tokens for component styling

A component should mostly refer to semantic tokens. Example:
The code block should use --color-bg-elevated and --color-fg, not --color-neutral-95.
If you hardcode base ramp steps in components, you won’t be able to re-tune contrast later without touching component CSS.

Performance: variables are not your bottleneck until they are

Most themes don’t hit performance limits from variables alone. But you can create trouble:

  • Animating a variable that affects layout or paint-heavy properties across the whole page.
  • Overusing filter, backdrop-filter, or huge box-shadows while toggling themes.
  • Triggering expensive restyles by switching theme on the root for every tiny interaction.

Keep theme toggles infrequent (user action, not hover). Keep transitions subtle and bounded.

One quote worth remembering

“Hope is not a strategy.” — General Gordon R. Sullivan

Theme correctness is not a vibes-based activity. Measure contrast, test reduced motion, and run regression checks.

Dark mode “like X”: high-contrast, low-drama, not pitch-black

People say “dark mode like X” because it’s readable, crisp, and doesn’t look like a sci-fi cockpit.
It tends to use a near-black background (not pure black), slightly elevated surfaces, and bright-but-controlled text.
The key is the neutral ramp and how you allocate it across surfaces.

Why near-black beats pure black for docs

Pure black (#000) maximizes contrast, which sounds good until you read for 40 minutes. Many users report
halation (glowy edges) with bright text on pure black. Near-black reduces glare while still feeling “dark.”

Also: not everyone’s display behaves. Some monitors crush shadows, some phones boost contrast, and some browsers
apply color management differently. Near-black gives you room to create elevation and borders without relying on
invisible values.

Define surfaces: base, elevated, inset

Docs themes need surface tokens. If you only have one background token, you’ll end up inventing ad-hoc grays.
Use a small surface set:

  • --color-bg for the page base.
  • --color-bg-elevated for nav, panels, cards.
  • --color-bg-inset for code blocks, callouts, and inset containers.

Make borders real in dark mode

In light mode you can get away with subtle borders. In dark mode, borders either disappear or become neon outlines.
Tokenize borders with intent:

  • --color-border for default strokes.
  • --color-border-strong for separators and table grid lines.

If you use a single border color, tables and sidebars will blur into mush, especially for users with low contrast perception.

Links and accents: the “too blue” failure mode

X-style themes use a saturated accent, but they control where it appears. Your docs should do the same:

  • Links: accent color, but avoid underlines that are too thin on dark backgrounds.
  • Buttons: accent background with readable text, but keep hover states within a small delta.
  • Focus rings: don’t reuse accent blindly; pick a ring color that’s visible on both light and dark surfaces.

Joke #1: Dark mode isn’t “make everything black.” That’s not theming; that’s a power outage with typography.

Syntax highlighting: don’t let it freeload

Code blocks are where most dark themes quietly fail. The background is dark, then the tokens use saturated colors that
vibrate, and the user’s eyes go on strike. Tokenize syntax colors separately and keep them within a controlled palette.

Practical rule: keep syntax colors less saturated than the accent, and ensure comments are readable but not dominant.
If your comment color is too faint, people can’t tell what’s code vs annotation; if it’s too bright, it becomes visual noise.

Reduced motion: the contract you’re already violating

Reduced motion is not “nice to have.” It’s an explicit user preference. When a user enables reduced motion, they’re
telling you that animation can cause discomfort or nausea. If your docs site still smooth-scrolls, cross-fades, parallax
scrolls, or animates large transforms, you’re breaking the contract.

What to disable (and what to keep)

Under prefers-reduced-motion: reduce:

  • Disable smooth scrolling, parallax effects, auto-advancing carousels, animated background gradients.
  • Reduce large transform transitions (sidebar slide-ins), long fades, and animated highlights.
  • Keep short, non-essential micro-transitions if they don’t create motion across the viewport (e.g., 100ms color changes). But consider disabling those too for simplicity.

Scroll behavior is the classic gotcha

Many docs sites set html { scroll-behavior: smooth; } because it looks “polished.”
Under reduced motion, it’s the opposite of polished.
Your tokens and CSS architecture should allow turning it off cleanly.

Animation tokens: yes, tokenizing motion is worth it

Create motion tokens:

  • --motion-duration-fast, --motion-duration-medium
  • --motion-ease-standard
  • --motion-enabled as a conceptual switch (implemented via media query overrides)

Then, under reduced motion, set durations to near-zero. Don’t rely on auditing every transition line manually.
You will miss one. You always miss one.

Implementation blueprint: tokens, layers, and overrides

CSS structure: split tokens from component CSS

Put tokens in a single file (or a generated bundle) that is loaded early. Then build components that consume tokens.
The anti-pattern is mixing tokens into component files, because it makes the token surface area unreviewable.

A practical token layout

Recommended files:

  • tokens.css: base + semantic tokens, light and dark.
  • components.css: components referencing semantic tokens.
  • overrides.css: rare per-page or per-integration fixes; treat this as radioactive.

Theme switching contract

Support these modes:

  • System (default): follow prefers-color-scheme.
  • Light (user override): force light regardless of system.
  • Dark (user override): force dark regardless of system.

Implement with a data-theme attribute on html or body, plus a “system” state where the attribute is absent.
Don’t do both attribute and class names from different frameworks; you’ll create conflicting selectors and spend an afternoon
losing to specificity.

Reduced motion contract

Use media queries, not user-agent hacks:

  • @media (prefers-reduced-motion: reduce) { ... }

Avoid using JS to “detect” reduced motion unless you absolutely must. CSS is more reliable and doesn’t depend on script execution timing.

Example: token file (conceptual)

I’m not going to drown you in a full theme CSS dump, but here’s the pattern: base ramps, then semantic mapping, then dark overrides.
Keep your tokens tight and legible.

Tooling and pipelines: keep humans out of the hot path

Production theming is like production storage: your failures are usually mundane and self-inflicted. Tools help, but only
if you treat them as guardrails rather than a magic wand.

Linting and consistency checks

Use a style linter to catch:

  • Hard-coded colors in component CSS (when you require tokens).
  • Invalid variable references.
  • Overly-specific selectors that make token overrides impossible.

Contrast testing as a build signal

Don’t ship a theme change without checking contrast for:

  • Body text on background
  • Links on background
  • Code text on code background
  • Muted text and border colors (often the first to fail in dark mode)

You don’t need perfection. You need a consistent policy and a way to block obvious regressions.

Visual regression tests: cheap insurance

For docs sites, visual regression is disproportionately effective because pages are templated.
Capture a small set of canonical pages:

  • Home page / landing
  • API reference page with tables
  • Tutorial page with callouts
  • Search results view
  • Page with long code blocks

Run them in light and dark, and with reduced motion. Yes, reduced motion affects screenshots if you have animations
that might capture mid-transition. That’s the point: you’re catching nondeterminism.

Two theme jokes are the maximum safe dosage

Joke #2: If you think you don’t need tokens, you’ve never met “just this one special blue” in a quarterly rebrand.

Practical tasks: commands, outputs, and decisions (12+)

These are the sorts of checks you run when a theme change is about to ship, or when something feels “off” and you want
to find the actual cause. Each task includes: the command, what the output means, and the decision you make.
Assume a repo with a built site under dist/ and source CSS under src/.

Task 1: Find hard-coded hex colors that bypass tokens

cr0x@server:~$ rg -n --hidden --glob '!dist/**' -e '#[0-9a-fA-F]{3,8}\b' src
src/components/sidebar.css:42:  border-left: 1px solid #2f3336;
src/pages/api.css:118:          color: #1d9bf0;

Output meaning: You have literal colors in component/page CSS.
Decision: Replace with semantic tokens (e.g., --color-border, --color-link) or justify why a component token is required.

Task 2: Find rgb()/hsl() literals (also bypass tokens)

cr0x@server:~$ rg -n --hidden --glob '!dist/**' -e '\brgb(a)?\(' -e '\bhsl(a)?\(' src
src/components/callout.css:9:  background: rgba(29, 155, 240, 0.12);

Output meaning: Color math is embedded in components.
Decision: Move the computed values into tokens (e.g., --color-accent-subtle-bg) so dark mode can override correctly.

Task 3: Enumerate CSS variables defined in tokens

cr0x@server:~$ rg -n '^\s*--[a-z0-9-]+\s*:' src/tokens.css | head
12:  --space-1: 0.25rem;
13:  --space-2: 0.5rem;
14:  --radius-1: 6px;
15:  --color-bg: #ffffff;
16:  --color-fg: #0f1419;

Output meaning: A quick view of token definitions.
Decision: Confirm naming consistency and that semantic tokens exist for every major UI surface (bg/fg/border/link/focus).

Task 4: Detect variables used but never defined (typos and dead tokens)

cr0x@server:~$ python3 - <<'PY'
import re, pathlib
root = pathlib.Path("src")
defs=set()
uses=set()
for p in root.rglob("*.css"):
    t=p.read_text(errors="ignore")
    for m in re.finditer(r'--[a-z0-9-]+(?=\s*:)', t): defs.add(m.group(0))
    for m in re.finditer(r'var\(\s*(--[a-z0-9-]+)', t): uses.add(m.group(1))
missing=sorted(uses-defs)
print("\n".join(missing[:50]))
print(f"\nmissing_count={len(missing)}")
PY
--color-codblock-bg
--motion-duration-fst

missing_count=2

Output meaning: Tokens referenced in CSS are not defined; these are likely typos.
Decision: Fix the spelling, or define the token intentionally. This is a classic source of “works in light mode but not dark mode” regressions.

Task 5: Find where data-theme is applied in built HTML

cr0x@server:~$ rg -n 'data-theme=' dist/**/*.html | head
dist/index.html:2:<html lang="en" data-theme="dark">

Output meaning: Your build is hardcoding a theme.
Decision: Decide if that’s intentional (e.g., docs preview) or a bug. Most production sites should default to system preference unless a user setting is stored.

Task 6: Verify that reduced motion rules exist

cr0x@server:~$ rg -n 'prefers-reduced-motion' src/**/*.css
src/tokens.css:148:@media (prefers-reduced-motion: reduce) {
src/components/search.css:77:@media (prefers-reduced-motion: reduce) {

Output meaning: You have explicit reduced-motion handling.
Decision: Ensure it actually disables smooth scrolling and major transitions (not just “duration: 0” in one component).

Task 7: Detect global smooth scrolling (often wrong for reduced motion)

cr0x@server:~$ rg -n 'scroll-behavior\s*:\s*smooth' src/**/*.css
src/base.css:3:html { scroll-behavior: smooth; }

Output meaning: Global smooth scrolling is enabled.
Decision: Add a reduced-motion override: under prefers-reduced-motion: reduce, set scroll-behavior: auto.

Task 8: Check for “transition: all” (a performance and accessibility smell)

cr0x@server:~$ rg -n 'transition\s*:\s*all\b' src/**/*.css
src/components/tabs.css:21:  transition: all 250ms ease;

Output meaning: You are animating everything, including properties that cause layout/paint churn.
Decision: Replace with explicit properties (e.g., color, background-color, opacity) and shorten duration; disable under reduced motion.

Task 9: Check CSS bundle size and growth over time

cr0x@server:~$ ls -lh dist/assets/*.css
-rw-r--r-- 1 cr0x cr0x 184K Dec 29 10:11 dist/assets/site.css
-rw-r--r-- 1 cr0x cr0x  62K Dec 29 10:11 dist/assets/vendor.css

Output meaning: Bundle sizes are visible and measurable.
Decision: If your site CSS is ballooning release-to-release, suspect “token bypass” and component duplication. Decide whether to dedupe, split, or enforce token usage.

Task 10: Inspect computed values in a headless browser (dark vs light)

cr0x@server:~$ node - <<'NODE'
const { chromium } = require('playwright');
(async () => {
  const browser = await chromium.launch();
  const page = await browser.newPage();
  await page.goto('file://' + process.cwd() + '/dist/index.html');
  const bg = await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--color-bg').trim());
  const fg = await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--color-fg').trim());
  console.log({bg, fg});
  await browser.close();
})();
NODE
{ bg: '#0b0f14', fg: '#e7e9ea' }

Output meaning: Tokens resolve to actual values at runtime.
Decision: Compare outputs for light and dark, and verify that semantic tokens change while component CSS stays stable.

Task 11: Confirm the site respects system color scheme without forcing

cr0x@server:~$ node - <<'NODE'
const { chromium } = require('playwright');
(async () => {
  const browser = await chromium.launch();
  const page = await browser.newPage({ colorScheme: 'dark' });
  await page.goto('file://' + process.cwd() + '/dist/index.html');
  const attr = await page.evaluate(() => document.documentElement.getAttribute('data-theme'));
  const bg = await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--color-bg').trim());
  console.log({dataTheme: attr, bg});
  await browser.close();
})();
NODE
{ dataTheme: null, bg: '#0b0f14' }

Output meaning: The page is following system preference (dark) without an explicit override attribute.
Decision: Keep this behavior unless product requirements demand a forced default. “System by default” reduces user friction.

Task 12: Find “!important” usage that can break token overrides

cr0x@server:~$ rg -n '!\s*important' src/**/*.css
src/components/navbar.css:55:  background: var(--color-bg-elevated) !important;

Output meaning: Specificity escalation is present.
Decision: Remove !important unless you’re deliberately fighting third-party CSS. If you must keep it, isolate it in an integration layer so core theme tokens remain overrideable.

Task 13: Validate that the reduced-motion override actually changes computed transition duration

cr0x@server:~$ node - <<'NODE'
const { chromium } = require('playwright');
(async () => {
  const browser = await chromium.launch();
  const page = await browser.newPage({ reducedMotion: 'reduce' });
  await page.goto('file://' + process.cwd() + '/dist/index.html');
  const dur = await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--motion-duration-medium').trim());
  console.log({motionDurationMedium: dur});
  await browser.close();
})();
NODE
{ motionDurationMedium: '1ms' }

Output meaning: Your motion tokens are being overridden for reduced motion.
Decision: If this stays at something like 250ms, your reduced-motion CSS is missing or not loading early enough.

Task 14: Check for layout shift caused by font loading (docs sites are sensitive)

cr0x@server:~$ rg -n 'font-display' src/**/*.css src/**/*.scss
src/fonts.css:4:  font-display: swap;

Output meaning: Fonts are configured to swap, reducing “invisible text” time but potentially causing minor shift.
Decision: For docs, swap is usually the right choice; if you see ugly shifts, adjust fallback font metrics or consider optional depending on UX goals.

Fast diagnosis playbook

When a docs theme “feels wrong,” the trick is to avoid debating taste and instead isolate the bottleneck: contrast,
token mapping, motion behavior, or cascade conflicts. Here’s the order that gets you to answers fast.

First: verify the theme state and the cascade

  • Is the site following system preference or forcing a theme via data-theme?
  • Are token files loaded before component CSS?
  • Are there !important rules or framework resets overriding tokens?

Why first: If the cascade is wrong, everything else is noise. You can’t debug contrast if the wrong theme is applied.

Second: validate semantic tokens resolve to sane values

  • Check computed --color-bg, --color-fg, --color-link, --color-border.
  • Confirm that dark mode changes the semantic tokens, not component CSS.

Why second: Semantic tokens are your contract. If they’re inconsistent, components will never be consistent.

Third: accessibility signals—reduced motion and contrast

  • Under reduced motion: is smooth scrolling disabled and are major transitions near-zero?
  • Is focus visible on keyboard navigation in both themes?
  • Spot-check contrast on body text, links, code blocks, callouts, and tables.

Why third: These are user-impacting failures that can persist unnoticed for months. They don’t always show up in happy-path testing.

Fourth: performance and determinism

  • Look for “transition: all,” large shadows, filters, and animated layout-affecting properties.
  • Confirm visual regression snapshots are stable (no mid-transition captures).

Why fourth: Theme performance issues are usually self-inflicted and obvious once you know where to look.

Common mistakes: symptoms → root cause → fix

1) “Dark mode looks washed out, but only on some pages”

Symptom: Most pages look fine; a few have gray-on-gray text or unreadable callouts.
Root cause: Nested containers override a semantic token (e.g., --color-fg) or a component uses base ramp tokens directly.
Fix: Restrict token overrides to root theme selectors; refactor components to use semantic tokens only.

2) “Links are invisible in dark mode”

Symptom: Links look like body text unless hovered.
Root cause: Link token too close to foreground token; hover token relies on brightness increase that doesn’t work on near-black backgrounds.
Fix: Increase hue separation and/or add underline thickness; define --color-link and --color-link-hover explicitly per theme.

3) “Focus ring disappears on code blocks and buttons”

Symptom: Keyboard users can’t see focus in some components.
Root cause: Focus ring color token reuses accent color that blends into backgrounds; or focus styles use outline: none without replacement.
Fix: Create --color-focus-ring with high visibility on both themes; ban outline: none unless a better outline is added.

4) “Theme toggle causes a flash or flicker”

Symptom: Switching themes causes a jarring flash of white or a mid-state palette.
Root cause: Token file loads late, or JS applies data-theme after initial paint.
Fix: Inline minimal theme init script early (or avoid forced theme by default); ensure token CSS is loaded in the critical path.

5) “Reduced motion is enabled but scrolling still animates”

Symptom: User reports motion discomfort; dev says “we support reduced motion.”
Root cause: Global scroll-behavior: smooth not overridden under reduced motion; JS smooth-scroll library ignores preference.
Fix: Add @media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } }; gate JS animation behind preference checks.

6) “Tables are unreadable in dark mode”

Symptom: Grid lines vanish; alternating row backgrounds don’t differ enough.
Root cause: Single border token used everywhere; row striping uses alpha overlays that collapse on near-black backgrounds.
Fix: Add --color-border-strong and explicit table row background tokens per theme.

7) “Code blocks look like a nightclub”

Symptom: Syntax colors are painfully saturated, comments are too faint, or selection color is unreadable.
Root cause: Syntax palette not tokenized; copied from a different background value; selection color left as browser default.
Fix: Define syntax tokens (--syntax-keyword, --syntax-string, etc.) and selection tokens; validate contrast with the code background.

8) “Everything is a token now, and nobody can change anything”

Symptom: Engineers complain that changing one component requires editing token files and waiting for design review.
Root cause: Tokens include component-specific layout details and micro-decisions; the semantic layer is bloated.
Fix: Push component details back into component CSS; keep tokens for cross-cutting decisions (colors, typography, spacing scale, motion scale).

Three corporate mini-stories from the theming trenches

Incident: a wrong assumption about “system dark mode”

A mid-size enterprise rolled out a refreshed docs portal. The team had a theme toggle, a clean token file, and a
“system default” behavior. It looked good in staging. It shipped on a Tuesday because that’s when everyone feels brave.

Within hours, support tickets started coming in: “Docs are flashing white when I open them.” The team’s first guess
was cache issues. Second guess: the CDN. Third guess: “users must be exaggerating.” That guess did not age well.

The root cause was a wrong assumption: they assumed prefers-color-scheme would always be available and stable
at first paint. But their implementation forced a theme via JS after load to apply the saved preference. If the script
loaded late (slow device, blocked third-party script chain, strict corporate proxy), the page rendered in light mode
first, then switched to dark. Flash. Pain.

Fixing it was not glamorous. They inlined a tiny theme init script in the document head to set data-theme
before CSS evaluation, and they made the default “system” mode not require JS at all. The result: no flash, fewer tickets,
and a team that stopped treating CSS as “frontend magic.”

Optimization that backfired: token math everywhere

Another org tried to get clever: they wanted a single accent color token and computed everything else from it using
CSS color-mix() and alpha overlays. In theory: fewer tokens, more consistency, easier rebrands.
In practice: a slow-motion train derailment of browser differences and unreadable subtle backgrounds.

The first problem was predictability. Different browsers and color spaces produced slightly different results. On a
dark background, a 12% accent overlay looked okay in one browser, muddy in another, and nearly invisible in high-contrast modes.
The second problem was control. When design wanted callout backgrounds to be calmer in dark mode, the team had to tweak the math,
which changed everything else too.

The system also became hard to debug. When someone asked “what color is this background,” the answer was “it depends.”
That’s a fun answer for a design workshop and a terrible answer for an on-call rotation.

They eventually rolled back to explicit semantic tokens for key surfaces and states, keeping computed colors only where
they were safe and non-critical. Token count went up. Incidents went down. That’s a trade you make every time.

Boring but correct practice that saved the day: canonical pages and visual diff

A third company ran a docs site with multiple product lines. The theme had to support long tables, embedded diagrams,
and code examples in three languages. They were not special. They were just disciplined.

They kept a small set of canonical pages in the repo: one page for each nasty content pattern. Every theme change
ran screenshot diffs in light, dark, and reduced motion. If a diff was surprising, it didn’t ship until someone
explained it in plain English.

One day, a seemingly harmless change updated the neutral ramp to make dark mode “a bit darker.” The diff flagged that
table borders nearly vanished and the inline code background merged into the paragraph background. Nobody noticed it
by eyeballing a single page, because most pages didn’t have dense tables or inline code.

Because the diff caught it before release, the fix was trivial: adjust two semantic tokens and add a stronger border token.
No incident. No hotfix. No emergency Slack thread. Just a normal PR review and everyone going home on time.

Checklists / step-by-step plan

Step-by-step: build a tokenized docs theme from scratch

  1. Define surfaces and text roles: bg, elevated bg, inset bg, fg, muted fg, border, strong border.
  2. Pick a neutral ramp with enough steps to support elevation and borders without resorting to alpha hacks.
  3. Define semantic tokens for links, focus ring, selection, and code surfaces.
  4. Define motion tokens (durations + easing) and set up reduced-motion overrides.
  5. Implement theme switching with system default and user override via data-theme.
  6. Refactor components to consume semantic tokens only; allow component tokens only for real constraints.
  7. Add guardrails: lint for raw colors, find undefined tokens, ban transition: all.
  8. Add canonical page snapshots in light/dark/reduced motion.
  9. Ship and observe: watch support tickets for contrast, focus, and motion complaints; they’re signal, not noise.

Release checklist: before merging a theme change

  • No raw color literals introduced outside token files (or explicitly justified).
  • Semantic tokens updated for both light and dark modes.
  • Focus visible on all interactive elements in both themes.
  • Reduced-motion mode disables smooth scrolling and major transitions.
  • Code blocks readable: background, selection, comments, and inline code all tested.
  • Tables: borders and zebra striping remain visible in dark mode.
  • Visual regression diffs reviewed on canonical pages.
  • Theme switching does not flash on first paint.

Ops checklist: diagnosing a production theme complaint

  • Reproduce with the user’s reported OS settings (dark mode, reduced motion, high contrast if applicable).
  • Check if a forced theme attribute is being set late by JS (network timing, blocked scripts, CSP issues).
  • Inspect computed semantic token values on the affected page.
  • Search for local overrides and !important rules near the failing component.
  • Compare against canonical pages to see if the issue is systemic or content-specific.

FAQ

1) Should I store tokens in CSS variables or generate them from a design tool?

Store the runtime contract as CSS variables. You can generate that file from a tool if you want, but keep a human-readable
token output. When production breaks, you want to debug in the browser, not in an export pipeline.

2) How close should “dark mode like X” be to pure black?

Use near-black for the base background, not pure black. Pure black can be harsh for reading and makes subtle elevation
harder. Near-black gives you room for surfaces and borders without neon outlines.

3) Is it okay to use alpha overlays for subtle backgrounds?

Sometimes. Alpha overlays are fragile across backgrounds and can collapse in dark mode. Prefer explicit semantic tokens
for critical surfaces (callouts, code blocks). Use alpha for non-critical decoration, and test it on both themes.

4) Do I need separate tokens for code blocks?

Yes, usually. Code blocks have different constraints: dense content, syntax colors, selection styling, and copy buttons.
Treat them as a surface with explicit background, border, and syntax tokens.

5) What’s the simplest theme toggle implementation that won’t flash?

Default to system preference via CSS media query. Only apply data-theme when the user explicitly chooses
light or dark. If you must apply it on load, set it before first paint with a tiny inline script in the document head.

6) How do I support reduced motion without rewriting all CSS?

Tokenize motion durations and reference them in transitions. Under prefers-reduced-motion: reduce, override
the duration tokens to near-zero and disable smooth scrolling. This catches most motion without hunting every transition line.

7) Why not just use a UI framework theme and call it done?

Framework themes are fine until your docs site has code blocks, tables, callouts, and content-specific components that
weren’t in the framework’s happy path. Tokens let you adapt without forking the whole framework CSS or fighting specificity forever.

8) How many tokens is “too many”?

When you can’t answer “what is this token for” without opening Figma, you have too many. Tokens should encode stable
decisions. If a token exists only to make one component slightly different, it’s probably component CSS, not a token.

9) Do tokens help with reliability, or is this just design hygiene?

They help reliability because they reduce change blast radius. A semantic token adjustment can fix contrast across the site
without touching component CSS, and regression tests can focus on the token layer. That’s fewer weird edge cases in production.

Conclusion: next steps that won’t waste your week

Build the theme like you run production: define contracts, reduce unknowns, and keep the blast radius small.
Design tokens are that contract. CSS variables are the runtime mechanism. Dark mode and reduced motion are not
“features”—they’re user expectations you either meet or you don’t.

Practical next steps:

  1. Audit your CSS for raw colors and transition: all; move decisions into semantic tokens.
  2. Implement system-default dark mode with an explicit user override via data-theme.
  3. Add motion tokens and a strict reduced-motion override (including smooth scrolling).
  4. Create 5–8 canonical pages and run visual diffs in light/dark/reduced motion before merging theme changes.
  5. Make borders, tables, code blocks, focus rings, and selection styling first-class token citizens. That’s where themes actually fail.

If you do this well, nobody will compliment your docs theme. They’ll just read it. That’s the win.

← Previous
PostgreSQL vs MongoDB Transactions: Where Reality Differs From Docs
Next →
Core and Core 2: Intel’s Comeback After NetBurst (and What Ops Learned)

Leave a comment