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