Multi-Theme & Dark Mode Support for API Portals
Adding dark mode to an API portal is less about colors and more about a token system that survives framework updates and a CI gate that catches contrast failures before they ship. This guide is part of Developer Portal Frameworks & UI Setup, and it covers system-preference detection, a semantic CSS custom-property architecture, [data-theme] switching that works across portal frameworks, and automated WCAG AA validation. It does not cover per-framework theme files exhaustively — those live in each framework’s own guide — but it shows how to map one token set onto any of them.
Most modern doc frameworks abstract the toggle UI, so the real work is upstream: defining tokens that components reference indirectly, avoiding the flash of the wrong theme on first paint, and proving contrast holds in both modes on every build. The patterns below apply equally to Docusaurus, Mintlify, and a Redoc reference rendered through Redocly & OpenAPI UI Configuration.
Prerequisites & Environment Setup
- A portal framework that exposes the document root for a
[data-theme]attribute (Docusaurus, Mintlify, Stoplight, or a custom static site all qualify). - Node.js 18+ for the validation tooling.
- Accessibility and visual-regression tools installed in CI.
npm install -g pa11y-ci@^3 # contrast / WCAG checks
npm install -D @playwright/test # cross-theme visual regression
npx playwright install --with-deps chromium
Decide the switching mechanism before writing any CSS. Use a [data-theme] attribute on documentElement rather than relying solely on @media (prefers-color-scheme), because the attribute approach lets a user override the OS preference and lets you force a theme on a subtree. Reserve the media query for the initial default only.
Core Configuration
Two pieces make the system robust: a blocking head script that resolves the theme before first paint, and a semantic token set that components reference indirectly. Each is annotated inline.
The blocking script must run synchronously in <head>, before the stylesheet, to avoid a flash of the wrong theme:
<!-- in <head>, before any stylesheet link -->
<meta name="color-scheme" content="light dark"> <!-- native chrome (scrollbars, inputs) follows the theme -->
<script>
(function () {
var stored = localStorage.getItem('theme'); // explicit user choice, if any
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var theme = stored || (prefersDark ? 'dark' : 'light'); // override beats OS preference
document.documentElement.setAttribute('data-theme', theme); // set BEFORE first paint
})();
</script>
Define semantic tokens at :root, then override only the values that change inside [data-theme='dark']. Components must never read raw hex — they read tokens, so a single override flips the whole UI:
/* global.css */
:root {
--bg-primary: #ffffff;
--text-primary: #16295d; /* ink text from the portal palette */
--code-bg: #f5f5f5;
--border-subtle: #d8dff2; /* subtle border from the palette */
--accent-primary: #16306d; /* navy brand accent */
}
[data-theme='dark'] {
--bg-primary: #0d1117;
--text-primary: #c9d1d9;
--code-bg: #161b22;
--border-subtle: #30363d;
--accent-primary: #4c9aff;
}
@media print {
/* dark backgrounds are unreadable on paper — force light tokens */
:root {
--bg-primary: #ffffff;
--text-primary: #000000;
--code-bg: #f5f5f5;
}
}
Add a runtime listener so the page tracks OS changes when the user has not set an explicit override:
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
if (!localStorage.getItem('theme')) { // respect an explicit choice
document.documentElement.setAttribute('data-theme', e.matches ? 'dark' : 'light');
}
});
Integration Pattern
Map the same token set onto each framework rather than maintaining a separate palette per tool. Frameworks expose their own variable namespace; point those variables at your tokens. For a Redoc reference configured through redocly.yaml:
{
"theme": {
"colors": {
"primary": { "main": "var(--accent-primary)" },
"text": { "primary": "var(--text-primary)" }
},
"typography": {
"code": { "fontFamily": "var(--font-mono, 'JetBrains Mono', monospace)" }
}
}
}
Redoc compiles its theme into a static bundle at build time, so a var(--accent-primary) value only resolves if that variable is already defined in the page stylesheet at render time — load global.css before the Redoc bundle.
Gate contrast in CI so a regression in either mode fails the build. The workflow below checks both themes and runs cross-theme visual regression:
# .github/workflows/a11y.yml
name: Theme & A11y Validation
on:
pull_request:
branches: [main]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build and serve the portal
run: |
npm run build
npx serve -l 3000 ./build &
npx wait-on http://localhost:3000
- name: Contrast / WCAG AA check (both themes)
run: |
npm install -g pa11y-ci
pa11y-ci --config .pa11yci.json --threshold 0 # max errors allowed, NOT a ratio
- name: Cross-theme visual regression
run: npx playwright test # compares against committed baselines
The .pa11yci.json exercises both modes by visiting the dark variant explicitly:
{
"urls": [
"http://localhost:3000",
"http://localhost:3000?theme=dark"
],
"standard": "WCAG2AA"
}
This validation belongs alongside the spec-lint gates described in Spec Linting & Governance so the portal is checked for both contract correctness and accessibility on the same PR.
Advanced Options
High-contrast and reduced-motion layers
Layer an extra token set for users who request it, keyed off prefers-contrast: more, so the default dark theme stays comfortable while a stricter variant is available:
@media (prefers-contrast: more) {
[data-theme='dark'] {
--text-primary: #ffffff;
--border-subtle: #8b949e;
}
}
Cross-subdomain persistence
localStorage is per-origin, so a preference set on docs.example.com will not apply on portal.example.com. Persist the choice in a cookie scoped to the apex domain:
document.cookie = `theme=${theme}; domain=.example.com; path=/; max-age=31536000; samesite=lax`;
Read that cookie in the blocking head script before falling back to prefers-color-scheme.
Forcing a theme on a subtree
Apply data-theme="dark" to any container to flip just that section — useful for a hero block or an embedded demo. Because components read tokens rather than hex, the cascade handles the rest with no per-component code.
The parts a naive dark theme leaves behind
Flipping the body background and the text colour is about a fifth of the work, and it produces a page that looks finished at a glance and fails the moment a reader scrolls. The remaining surfaces are the ones with colour baked in somewhere other than your token file: a syntax-highlighting theme chosen at initialisation, an inline diagram whose canvas is a white rectangle, a screenshot captured in light mode, and any third-party component that ships its own complete stylesheet.
Inline diagrams deserve particular attention because they fail silently and comprehensively. A diagram drawn with a white canvas rectangle and dark navy labels is perfectly legible in light mode and becomes a bright white block with invisible text the moment the surrounding page goes dark. The fix is to recolour diagram interiors through the same theme system as everything else — attribute selectors keyed on the exact palette values the diagrams are drawn with, so the canvas darkens and the label text lightens together. That approach only works if diagrams are authored from a fixed palette; a one-off colour chosen to fix a contrast warning will not be matched by any selector and will silently stay light.
Testing both schemes, and why one pass is not enough
An accessibility pass reports on whatever the browser was rendering, and a headless browser renders one colour scheme. So a single-scheme check reports zero contrast violations while the other scheme is entirely unexamined — which is worse than no check at all, because it produces a green result that people trust.
Run the pass twice with the colour scheme set explicitly, and assert on both. The cost is one extra browser launch; the benefit is that a contrast regression in whichever scheme you personally do not use cannot reach production. It is also worth exercising a page that expands content on interaction — a schema tree, a response panel, an accordion — because lazily rendered markup is invisible to a check that only loads the page, and lazily rendered markup is exactly where vendor stylesheets tend to reassert themselves.
The same logic applies to screenshots. An image captured in light mode sits unchanged on a dark page, and no CSS will fix it. If the rendered output of something is itself the lesson, capture a frame in each scheme and swap them by the active theme — otherwise remove the screenshot, because a bright rectangle in the middle of a dark page is worse than a description.
Verification & Testing
# 1. Establish baselines for both modes once, then commit them
npx playwright test --update-snapshots
# 2. Run the contrast gate locally against a served build
npx serve -l 3000 ./build &
pa11y-ci --config .pa11yci.json --threshold 0 # expect "0 errors"
# 3. Re-run visual regression without updating to detect drift
npx playwright test
Configure Playwright to snapshot both schemes so a single command covers them:
// playwright.config.ts
export default {
projects: [
{ name: 'light', use: { colorScheme: 'light' } },
{ name: 'dark', use: { colorScheme: 'dark' } }
]
};
A passing run shows zero pa11y errors in both URLs and no Playwright snapshot diffs. Spot-check manually by toggling the OS appearance setting and confirming the portal flips without a reload and without a flash on a hard refresh.
Choosing the tokens rather than inheriting them
A theme system is only as good as its vocabulary. Tokens named for their appearance — blue-600, grey-100 — force every component to know what it should look like, so a change of palette means editing every component. Tokens named for their role — surface, surface-raised, text, text-muted, border, accent — let components state what they are, and a scheme change becomes a change to one block of values.
The set does not need to be large. Six or seven semantic tokens cover almost every documentation surface, and a small vocabulary is easier to keep consistent than a comprehensive one nobody remembers. What matters is that every colour in the site resolves through one of them, because a single hardcoded value is a single place the dark theme will not reach — and it will be found by a reader rather than by you.
Contrast has to be checked per pair rather than per colour. A token set can contain only well-chosen values and still produce a failing combination, because contrast is a property of two colours together. The pairs worth checking explicitly are body text on the page background, muted text on a raised surface, link text on both surfaces, and any text placed on an accent-coloured block — that last one is where most failures actually occur, because accent colours are chosen for brand recognition rather than legibility.
Persistence, and getting the first paint right
Two behaviours separate a theme toggle that feels solid from one that feels broken. The first is that an explicit choice must outrank the operating-system preference: a reader who selected light on a dark-mode machine chose that deliberately, and re-imposing dark on their next visit is a bug regardless of how sensible the default is. The second is that the resolved theme must be applied before the first paint, which means a small synchronous script in the document head rather than anything deferred — otherwise the page renders light for a moment and then flips, which is jarring and reads as a defect.
Persist to storage on an explicit choice only, never on the OS-derived default. Storing the derived value on first visit silently converts a preference the reader never expressed into one they now have to change manually, and it stops the site from following their machine when they later switch it. The rule is simple to state and easy to get subtly wrong: read stored choice first, fall back to the OS preference, and write only when the reader acts.
Where a portal spans several subdomains, storage scoped to one origin will not carry the choice across them, and readers experience the theme resetting as they navigate. A cookie scoped to the parent domain is the usual answer; whichever mechanism you pick, decide it before the second subdomain exists, because retrofitting cross-origin persistence means auditing every place the preference is read.
Troubleshooting
Flash of the wrong theme on first paint — The theme attribute is set after the framework hydrates rather than in a blocking head script. Move the resolution logic into a synchronous <script> in <head> ahead of the stylesheet, so data-theme is present before the first paint.
Hardcoded hex values do not switch in dark mode — A component override injects raw hex (for example color: '#16295d') instead of a token, bypassing the cascade. Replace every literal with the corresponding var(--token) so the [data-theme='dark'] override reaches it.
Native UI elements render in the wrong scheme — Scrollbars, date pickers, and input backgrounds ignore your CSS because the color-scheme meta tag is missing. Add <meta name="color-scheme" content="light dark"> to <head> so the browser themes native chrome to match.
Dark-on-dark output when printing or exporting to PDF — Print stylesheets inherit the dark tokens, producing unreadable pages. Add an explicit @media print block that forces the light token set, as shown in Core Configuration.
Treat the dark theme as a shipped surface, not a preference
The habit that keeps a two-scheme site healthy is to stop thinking of dark mode as an option some readers enable and start thinking of it as half the pages you publish. Every new component, every new diagram, every embedded screenshot doubles the surface, and the second half is only checked if something checks it automatically.
That means the two-scheme contrast pass belongs in the same required gate as the rest of your quality checks rather than in a periodic audit, and it means reviewing new visual content in both schemes before it merges. Preview deployments make this practical: a reviewer can toggle the theme on the published preview in a second, which is the difference between a check people actually perform and one they intend to.
The residual risk is content authored outside that loop — a screenshot pasted into a guide, a diagram copied from a slide deck, an embedded third-party widget. Each of those is a light-mode rectangle waiting to appear on a dark page. A short authoring rule covers it: anything visual must either follow the theme tokens or ship a variant per scheme, and anything that can do neither does not go in the page at all.
FAQ
How do I persist theme preferences across documentation subdomains?
Use a cookie scoped to the top-level domain such as domain=.example.com so portal.example.com and docs.example.com both read it. localStorage is scoped per origin and will not carry a preference across subdomains.
Can I force dark mode for a specific page section?
Apply a data-theme="dark" attribute to the container element and the CSS variable cascade applies the dark token set to all of its children. Also respect prefers-contrast: more by layering high-contrast tokens for users who need them.
How do I validate theme changes without manual screenshot review?
Use Playwright screenshot comparison for pixel-level regression, or a hosted visual-diff service for PR integration. Snapshot both the light and dark viewports on every CI build so a regression in either mode fails the check.
Why does my dark mode flash light on first paint?
The theme attribute is being set after the framework hydrates instead of before first paint. Run a tiny blocking script in the document head that reads the stored preference and sets data-theme before the stylesheet applies.
Related
- Developer Portal Frameworks & UI Setup — the parent overview of portal options.
- Docusaurus for API Portals — mapping tokens onto Infima variables.
- Mintlify Setup & Migration — dual-theme logo and color config.
- Redocly & OpenAPI UI Configuration — theming a compiled Redoc bundle.
- Spec Linting & Governance — pairing accessibility gates with contract checks.