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

Follow the OS preference on first visit, then remember the user's choice:

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 -->
  
</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: themeColor("neutral", 0),   // resolves to dark background
    color: themeColor("neutral", 12),       // resolves to light text
  },
}

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

Contrast checking

Use @domphy/audit's checkContrast to verify all text elements have sufficient contrast in both themes:

import { checkContrast } from "@domphy/audit"

// Run in CI for both themes
document.documentElement.setAttribute("data-theme", "light")
const lightResults = await checkContrast(page)

document.documentElement.setAttribute("data-theme", "dark")
const darkResults = await checkContrast(page)

const failures = [...lightResults, ...darkResults].filter(r => !r.ok)

TypeScript: typed theme state

type Theme = "light" | "dark"

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

function toggleTheme() {
  theme.set((t): Theme => t === "light" ? "dark" : "light")
}