Skip to content
Domphy

Dark Mode

How Domphy handles dark mode

Domphy uses the data-theme attribute on <html> (or any ancestor element) to switch between light and dark. All CSS variables are scoped to [data-theme] — no separate dark stylesheet is needed.

<!-- Light mode -->
<html data-theme="light">

<!-- Dark mode -->
<html data-theme="dark">

The light theme is the default: themeCSS() emits it on :root as well as on [data-theme="light"], so omitting the attribute entirely renders the light theme rather than an unstyled page. [data-theme="dark"] overrides it — same specificity, later in the stylesheet.

Reading the theme

import { toState } from "@domphy/core"

const theme = toState<"light" | "dark">(
  document.documentElement.getAttribute("data-theme") as "light" | "dark" ?? "light"
)

Switching themes

function setTheme(t: "light" | "dark") {
  document.documentElement.setAttribute("data-theme", t)
  localStorage.setItem("dp-theme", t)
  theme.set(t)
}

const ThemeToggle = {
  button: (l) => theme.get(l) === "dark" ? "☀ Light" : "◑ Dark",
  onClick: () => setTheme(theme.get() === "dark" ? "light" : "dark"),
}

System preference detection

applySystemTheme() helper

The fastest way to wire up system preference detection: one call that reads localStorage, falls back to the OS preference, sets data-theme, and listens for OS changes at runtime:

import { applySystemTheme } from "@domphy/theme"

// Call once before mounting. Reads localStorage → falls back to OS preference.
const cleanup = applySystemTheme()

// Optional: custom target element or storage key
const cleanup = applySystemTheme(document.getElementById("app")!, {
  storageKey: "my-theme",
})

When the user manually toggles the theme, persist their choice so applySystemTheme() honours it on reload:

import { toState } from "@domphy/core"
import { applySystemTheme } from "@domphy/theme"

const STORAGE_KEY = "dp-theme"
const cleanup = applySystemTheme(document.documentElement, { storageKey: STORAGE_KEY })

const theme = toState<"light" | "dark">(
  document.documentElement.getAttribute("data-theme") as "light" | "dark" ?? "light"
)

function toggleTheme() {
  const next = theme.get() === "dark" ? "light" : "dark"
  document.documentElement.setAttribute("data-theme", next)
  localStorage.setItem(STORAGE_KEY, next)
  theme.set(next)
}

Blocked storage is handled: a sandboxed <iframe> without allow-same-origin, blocked third-party cookies, or Safari private mode make localStorage throw on read (SecurityError), not return null. applySystemTheme() catches that and falls back to the OS preference instead of taking app startup down with it.

applySystemTheme() returns a cleanup function — call it if you ever tear down the app:

// On SPA unmount
cleanup()

Manual implementation (no helper)

If you need full control, build the same pattern by hand:

function initTheme(): "light" | "dark" {
  const saved = localStorage.getItem("dp-theme") as "light" | "dark" | null
  if (saved) return saved
  return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
}

const currentTheme = initTheme()
document.documentElement.setAttribute("data-theme", currentTheme)
const theme = toState<"light" | "dark">(currentTheme)

// Update if the system preference changes and no user choice is saved
const mql = window.matchMedia("(prefers-color-scheme: dark)")
mql.addEventListener("change", (e) => {
  if (!localStorage.getItem("dp-theme")) {
    setTheme(e.matches ? "dark" : "light")
  }
})

SSR-safe initialization

Prevent flash-of-wrong-theme (FOWT) by injecting a blocking script in the HTML <head>:

<head>
  <!-- Must run synchronously before any rendering — no defer/async -->
  <script>
    (function() {
      const saved = localStorage.getItem("dp-theme");
      const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
      document.documentElement.setAttribute(
        "data-theme",
        saved ?? (prefersDark ? "dark" : "light")
      );
    })();
  </script>
</head>

@domphy/press (and apps/web/html-template.ts) injects this script automatically as RUNTIME_SCRIPT.

CSS-only dark mode (no JS)

If you only need system preference (no user toggle), use media query only:

/* Light is default */
:root[data-theme="light"], :root:not([data-theme]) {
  color-scheme: light;
  --neutral-0: #ffffff;
  --neutral-9: #111111;
}

/* Explicit opt-in */
:root[data-theme="dark"] {
  color-scheme: dark;
  --neutral-0: #111111;
  --neutral-9: #ffffff;
}

/* System preference, unless the user explicitly chose light */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    color-scheme: dark;
    --neutral-0: #111111;
    --neutral-9: #ffffff;
  }
}

An @media rule cannot appear inside a selector list — it has to be its own top-level block, as above.

Per-component dark mode

Apply dark mode to a specific component (e.g. a code editor with forced dark background):

import { themeColor } from "@domphy/theme"

const CodeEditor = {
  div: EditorContent,
  dataTheme: "dark",   // force dark within this subtree (resolvers walk dataTheme)
  style: {
    background: (l) => themeColor(l, "shift-1", "neutral"),   // near-dark edge in dark theme
    color: (l) => themeColor(l, "shift-12", "neutral"),       // near-light in dark theme
  },
}

Color-scheme property

Native UI — scrollbars, <select>, <input type="date">, range tracks, spellcheck underlines — follows the CSS color-scheme property, not custom properties. Without it a dark page still renders white scrollbars and white form controls.

themeCSS() emits it for you, in every theme block:

:root,
[data-theme="light"] { color-scheme: light; --neutral-0: #ffffff; … }
[data-theme="dark"]  { color-scheme: dark;  --neutral-0: #000000; … }

The value is derived from the theme's direction — "lighten" (a theme that lightens away from its edge, i.e. dark-based) emits dark, "darken" emits light — so a custom theme registered with setTheme() gets the right one automatically. Because it ships in the same stylesheet as the tokens, SSR output is already correct and there is no flash of light chrome before hydration.

Nothing to call: setting data-theme is enough. Set style.colorScheme by hand only to override the derived value for one subtree.

TypeScript: typed theme state

type Theme = "light" | "dark"

const theme = toState<Theme>("light")

function toggleTheme() {
  theme.set(theme.get() === "light" ? "dark" : "light")
}