Dark mode strategies

Merid's tokens have a light and a dark value. Which one applies is decided by one attribute, data-theme, and the prefers-color-scheme media query. The token values themselves are listed in Dark mode.

1. Follow the system

Do nothing. With no data-theme on <html>, Merid follows the operating system and switches live when it changes. Add color-scheme awareness to your own CSS by using the tokens (var(--mrd-bg), var(--mrd-ink)) instead of fixed colours.

2. A toggle

Store the choice, apply it to <html> before first paint, and fall back to the system when nothing is stored.

tsx
// Inline in <head>, before any CSS-dependent paint.
const themeInitScript = `
try {
  const t = localStorage.getItem("theme");
  if (t === "light" || t === "dark") document.documentElement.dataset.theme = t;
} catch {}`;
tsx
"use client";
import { SegmentedControl } from "@merid/react";

type Choice = "system" | "light" | "dark";

function apply(choice: Choice) {
  const root = document.documentElement;
  if (choice === "system") {
    delete root.dataset.theme;
    try { localStorage.removeItem("theme"); } catch {}
  } else {
    root.dataset.theme = choice;
    try { localStorage.setItem("theme", choice); } catch {}
  }
}

export function ThemeSwitcher() {
  const [choice, setChoice] = useState<Choice>("system");
  useEffect(() => {
    const t = document.documentElement.dataset.theme;
    setChoice(t === "light" || t === "dark" ? t : "system");
  }, []);
  return (
    <SegmentedControl
      aria-label="Theme"
      value={choice}
      onValueChange={(v) => { setChoice(v as Choice); apply(v as Choice); }}
      options={[
        { value: "system", label: "System" },
        { value: "light", label: "Light" },
        { value: "dark", label: "Dark" },
      ]}
    />
  );
}

In Next.js put the script in the root layout's <head> with dangerouslySetInnerHTML and add suppressHydrationWarning to <html>. localStorage can throw (private mode, blocked storage), so both reads and writes are wrapped.

To sync across tabs, listen for the storage event and re-apply.

3. Per subtree

data-theme works on any element and nests in both directions: a dark panel in a light page, or a light card in a dark one.

Production

Live

Tokens resolve from the nearest data-theme ancestor.

The attribute sets tokens; it does not paint. Give the element a background from the tokens so its contents sit on the right surface. Accent presets and density nest the same way — see Subtree attributes.

Portalled content (Dialog, Popover, DropdownMenu, Select, Tooltip, Toast) renders into document.body and so takes the page theme, not the subtree's. To keep an overlay in a subtree's theme, pass container (where the component supports it) pointing at an element inside the subtree.

Images and charts

  • Use <picture> with media="(prefers-color-scheme: dark)" only when you follow the system. With a toggle, switch sources on [data-theme="dark"] in CSS instead.
  • For charts, read colours from CSS variables at render time (getComputedStyle(el).getPropertyValue("--mrd-accent")) and re-render on theme change.