Theme / Dark Mode

← Back to components

Overview

Dark mode is managed by two modules working together:

  • theme.js — Cookie-based preference storage. Reads and writes a theme cookie on .marketdata.app so 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 })
Click to toggle theme
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.

getThemeCookie()
getUserThemePreference()
getBrowserThemePreference()
getEffectiveTheme()
isSystemMode()
html.classList
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.

Current theme:
Change count: 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"] *));
  • .dark class 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">
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