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

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)
}

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]) {
  --neutral-0: #ffffff;
  --neutral-9: #111111;
}

/* Dark mode: both explicit and system preference */
:root[data-theme="dark"],
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --neutral-0: #111111;
    --neutral-9: #ffffff;
  }
}

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,
  "data-theme": "dark",   // force dark within this subtree
  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

Set color-scheme alongside data-theme so the browser renders native elements (scrollbars, inputs) correctly:

function setTheme(t: "light" | "dark") {
  document.documentElement.setAttribute("data-theme", t)
  document.documentElement.style.colorScheme = t   // native element theming
  localStorage.setItem("dp-theme", t)
}

Or in CSS:

:root[data-theme="dark"] { color-scheme: dark; }
:root[data-theme="light"] { color-scheme: light; }

TypeScript: typed theme state

type Theme = "light" | "dark"

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

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