Overview
Dark mode is managed by two modules working together:
-
theme.js — Cookie-based preference storage. Reads and writes a
themecookie on.marketdata.appso the preference syncs across all subdomains. -
theme-toggle.js — A framework-agnostic sun/moon toggle button
that calls
setThemeCookie()on click.
Preference hierarchy: cookie > OS preference > light (default).
Setup
Every consuming page needs two pieces: an inline script to prevent flash, and the toggle module for user interaction.
Step 1: Inline script in <head>
ES modules (<script type="module">) are
always deferred by the browser — they execute after HTML parsing,
even in <head>. Without an inline script,
the page renders in light mode first, then flashes to dark once the module loads.
Place this inline script as the first element in
<head>, before any
<link> or
<style> tags. It runs synchronously before
the browser's first paint:
<head>
<script data-cfasync="false">
(function () {
var match = document.cookie.match(/(?:^|;\s*)theme=(dark|light)/);
var saved = match ? match[1] : null;
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var dark = saved === 'dark' || (!saved && prefersDark);
document.documentElement.classList.add(dark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
})();
</script>
</head>
The data-cfasync="false" attribute prevents
Cloudflare Rocket Loader from deferring this script. Without it, Rocket Loader rewrites
the tag to type="text/rocketscript", causing a
flash of wrong theme on first paint.
This mirrors the hierarchy from theme.js: cookie
> OS preference > light. Class and
data-theme attribute are set
symmetrically in both branches —
.dark /
.light and
[data-theme="dark"] /
[data-theme="light"] are always present from the
first paint, so consumers can rely on any of those four selectors.
Step 2: Load the toggle module
At the end of <body>, load the toggle as a
module:
<script data-cfasync="false" type="module">
import { initThemeToggle } from '@marketdataapp/ui/theme-toggle';
initThemeToggle({ container: document.getElementById('theme-toggle') });
</script>
Why two scripts?
The inline script handles the initial render (no flash). The module handles user
interaction and cookie persistence. They must stay in sync — both use the same
theme cookie and the same preference hierarchy.
Theme Toggle
Light/dark mode toggle using Docusaurus-style sun/moon SVG icons. Persists preference via
cross-subdomain cookie on .marketdata.app.
initThemeToggle({ container })
initThemeToggle()
.theme-toggle-button
.theme-toggle-icon-light
.theme-toggle-icon-dark
theme.js API
Low-level functions for reading and writing the theme preference. Import from
@marketdataapp/ui/theme.
Live State
Toggle the theme above and watch these values update in real time.
| Function | Returns | Description |
|---|---|---|
getThemeCookie() |
'dark' | 'light' | null
|
Reads the theme cookie value.
|
setThemeCookie(theme)
|
void |
Sets the cookie on .marketdata.app with
1-year expiry.
|
clearThemeCookie()
|
void |
Clears the cookie, reverting to OS preference. |
getUserThemePreference()
|
'dark' | 'light' | 'no-preference'
|
Checks cookie, then localStorage. Returns explicit preference or
'no-preference'.
|
getBrowserThemePreference()
|
'dark' | 'light' | 'no-preference'
|
Returns OS/browser preference via
prefers-color-scheme media query.
|
getEffectiveTheme()
|
'dark' | 'light' |
Resolves the full hierarchy: cookie > localStorage > OS > light. |
isSystemMode() |
boolean |
True when no explicit preference is saved (follows OS). |
onThemeChange(callback)
|
() => void |
Subscribe to theme changes. Callback receives
'dark' or
'light'. Returns an unsubscribe function.
|
syncThemeCookie()
|
() => void |
Subscribe the cross-subdomain cookie to live theme changes. Writes the cookie
only when an explicit preference already exists (preserves system mode). Runs
migrateLocalStoragePreference() on
subscribe. For consumers whose toggle flips DOM attributes directly (e.g.
Docusaurus). Returns an unsubscribe function.
|
migrateLocalStoragePreference()
|
'dark' | 'light' | null
|
One-shot migration of a legacy
localStorage.theme value into the cookie,
iff no cookie exists yet. No-op if the cookie is already set.
|
onThemeChange()
Subscribe to theme changes with a callback. Useful for JS-driven reactions that can't be
handled with CSS dark: variants alone —
swapping Lottie animations, re-rendering charts, updating canvas elements, etc.
Detects changes via both the
.dark class (Tailwind) and the
data-theme attribute (Docusaurus), plus
prefers-color-scheme media query changes. Uses a
single shared MutationObserver internally —
multiple subscribers don't create multiple observers.
Usage
import { onThemeChange } from '@marketdataapp/ui/theme';
const unsubscribe = onThemeChange((theme) => {
// theme is 'dark' or 'light'
console.log('Theme changed to:', theme);
});
// Later, to stop listening:
unsubscribe();
Examples
// Reload a Lottie animation with the alternate JSON file
onThemeChange((theme) => {
player.destroy();
player.load(theme === 'dark' ? '/anim-dark.json' : '/anim-light.json');
});
// Re-render a chart with theme-appropriate colors
onThemeChange((theme) => {
chart.updateOptions({
theme: { mode: theme },
});
});
Live Demo
Toggle the theme and watch the log update. This page uses
onThemeChange() to track every theme switch.
0
onThemeChange()
unsubscribe()
CSS: Dark Mode Variant
The theme CSS defines a custom variant that supports both Tailwind and Docusaurus conventions:
@custom-variant dark (&:where(.dark, .dark *, [data-theme="dark"], [data-theme="dark"] *));
-
.darkclass on<html>— Tailwind/Flowbite convention -
[data-theme="dark"]attribute — Docusaurus convention
The :where() wrapper adds
zero specificity, preventing dark mode selectors from creating
specificity inflation.
Semantic tokens vs dark: prefix
Semantic tokens from theme.css (e.g.
bg-neutral-primary-medium,
text-heading) handle dark mode automatically
— no dark: prefix needed. Only use
dark: when there's no matching semantic token.
Semantic tokens (automatic)
These use semantic tokens from theme.css. Toggle dark mode — no
dark: prefix needed.
bg-neutral-primary-medium
Heading text
Body text
Subtle text
bg-neutral-secondary-medium
Heading text
Body text
bg-neutral-tertiary-medium
Heading text
Body text
Manual dark: prefix
When no semantic token exists, use explicit
dark: variants.
bg-white dark:bg-gray-800
Manual dark mode text
bg-blue-50 dark:bg-blue-900/20
Custom color with dark variant
Dark Image Swapping
The dark-images module automatically swaps images
between light and dark variants. Toggle the theme to see it in action.
Convention-based (automatic)
Name images with -light or
-dark before the extension. The module detects and
swaps them automatically. If the alternate image 404s, no swap occurs.
<img src="logo-light.avif" alt="MarketData logo">
Usage
import { initDarkImages } from '@marketdataapp/ui/dark-images';
const cleanup = initDarkImages();
// cleanup() stops all observers
Explicit pairs
For images that don't follow the naming convention, register pairs manually before
calling initDarkImages().
import { initDarkImages, addImagePair } from '@marketdataapp/ui/dark-images';
addImagePair('chart.png', 'chart-inverted.png'); // suffix match
addImagePair('/images/photo.jpg', '/images/photo-bw.jpg'); // exact match
const cleanup = initDarkImages();
initDarkImages()
addImagePair()
-light / -dark suffix