Dark Mode
Build system-aware and user-selectable light and dark themes with GenomeJS.
Dark mode is a runtime-context problem.
The interface has stable source colors, while the active surface and foreground depend on environmental or application state.
System preference ──┐
├──→ Effective mode
User selection ─────┘ ↓
Theme tokens
↓
CSS custom propertiesDefine light and dark primitives
Create a Genome with separate light and dark source values:
import { Genome } from "@genomejs/core";
export const themeGenome = new Genome({
primitives: {
lightBackground: "#ffffff",
lightForeground: "#171923",
lightMuted: "#667085",
darkBackground: "#121620",
darkForeground: "#f7f8fb",
darkMuted: "#a5adbd",
brand: "#7c6cff",
},
tokens: {
background: (dna, context) =>
context.mode === "dark" ? dna.darkBackground : dna.lightBackground,
foreground: (dna, context) =>
context.mode === "dark" ? dna.darkForeground : dna.lightForeground,
mutedForeground: (dna, context) =>
context.mode === "dark" ? dna.darkMuted : dna.lightMuted,
primary: (dna) => dna.brand,
},
});
themeGenome.mutate({
mode: "light",
});Use the generated properties in CSS:
body {
background: var(--g-background);
color: var(--g-foreground);
}
.muted {
color: var(--g-muted-foreground);
}
.button {
background: var(--g-primary);
}Toggle the mode manually
themeGenome.mutate({
mode: "dark",
});Return to light mode:
themeGenome.mutate({
mode: "light",
});Multiple theme values can respond to the same context property.
Bind the system preference
GenomeJS provides:
bindMediaQueries(genome);The helper writes these context properties:
{
colorScheme:
"light" | "dark",
reducedMotion:
boolean,
}It does not write mode.
A theme using the built-in helper should read colorScheme:
import { bindMediaQueries, Genome } from "@genomejs/core";
const systemThemeGenome = new Genome({
primitives: {
lightBackground: "#ffffff",
darkBackground: "#121620",
},
tokens: {
background: (dna, context) =>
context.colorScheme === "dark" ? dna.darkBackground : dna.lightBackground,
},
});
const cleanup = bindMediaQueries(systemThemeGenome);Call the returned cleanup when the binding is no longer needed:
cleanup();Support system and explicit user modes
Many applications support three selections:
System
Light
DarkKeep the system preference and user selection as separate context properties.
The built-in binding controls:
colorScheme;The application controls:
selectedColorScheme;Define an effective mode token:
const themeGenome = new Genome({
primitives: {
lightBackground: "#ffffff",
darkBackground: "#121620",
lightForeground: "#171923",
darkForeground: "#f7f8fb",
},
tokens: {
effectiveMode: (_dna, context) => {
if (context.selectedColorScheme === "light") {
return "light";
}
if (context.selectedColorScheme === "dark") {
return "dark";
}
return context.colorScheme === "dark" ? "dark" : "light";
},
background: (dna) =>
dna.effectiveMode === "dark" ? dna.darkBackground : dna.lightBackground,
foreground: (dna) =>
dna.effectiveMode === "dark" ? dna.darkForeground : dna.lightForeground,
},
});
themeGenome.mutate({
colorScheme: "light",
selectedColorScheme: "system",
});Bind the system preference:
const cleanup = bindMediaQueries(themeGenome);Set an explicit user selection:
themeGenome.mutate({
selectedColorScheme: "dark",
});Return to system mode:
themeGenome.mutate({
selectedColorScheme: "system",
});A later system preference change still updates colorScheme, but the explicit user selection takes precedence inside effectiveMode.
React and Next.js
Create a Client Component that binds browser preferences after mounting:
"use client";
import { useEffect } from "react";
import { bindMediaQueries } from "@genomejs/core";
import { themeGenome } from "@/lib/theme-genome";
export function ThemeEnvironment() {
useEffect(() => {
return bindMediaQueries(themeGenome);
}, []);
return null;
}Render it once near the application root:
<body>
<ThemeEnvironment />
{children}
</body>Create a theme control:
"use client";
import { useGenomeTrait } from "@genomejs/react";
import { themeGenome } from "@/lib/theme-genome";
export function ThemeControls() {
const effectiveMode = useGenomeTrait(themeGenome, "effectiveMode");
return (
<div>
<p>Current mode: {effectiveMode}</p>
<button
type="button"
onClick={() => {
themeGenome.mutate({
selectedColorScheme: "system",
});
}}
>
System
</button>
<button
type="button"
onClick={() => {
themeGenome.mutate({
selectedColorScheme: "light",
});
}}
>
Light
</button>
<button
type="button"
onClick={() => {
themeGenome.mutate({
selectedColorScheme: "dark",
});
}}
>
Dark
</button>
</div>
);
}Vue
Bind browser state after the component mounts:
<script setup lang="ts">
import { onMounted, onUnmounted } from "vue";
import { bindMediaQueries } from "@genomejs/core";
import { themeGenome } from "@/lib/theme-genome";
let cleanup: (() => void) | undefined;
onMounted(() => {
cleanup = bindMediaQueries(themeGenome);
});
onUnmounted(() => {
cleanup?.();
});
</script>Svelte
<script lang="ts">
import {
onMount,
} from "svelte";
import {
bindMediaQueries,
} from "@genomejs/core";
import {
themeGenome,
} from "$lib/theme-genome";
onMount(() => {
return bindMediaQueries(
themeGenome,
);
});
</script>Reduced motion
The same media binding writes:
reducedMotion: boolean;Use it in motion-related tokens:
const tokens = {
transitionDuration: (_dna, context) =>
context.reducedMotion ? "0ms" : "180ms",
transitionDistance: (_dna, context) =>
context.reducedMotion ? "0px" : "8px",
};.panel {
transition-duration: var(--g-transition-duration);
}SSR and hydration
bindMediaQueries() accesses window, so it must run only in the browser.
Do not call it at module scope in code that may execute during server rendering:
bindMediaQueries(themeGenome);Use a mount lifecycle or client effect.
Choose stable initial values for server rendering:
themeGenome.mutate({
colorScheme: "light",
reducedMotion: false,
selectedColorScheme: "system",
});After mounting, the browser binding may update those values.
This can cause a visible theme change when the user’s system is dark but the server rendered a light default.
Applications that persist an explicit user choice can read that value earlier and use it as the initial selection.
Persist the user selection
GenomeJS does not manage storage.
Store the selection with your application:
function selectColorScheme(selection: "system" | "light" | "dark") {
localStorage.setItem("color-scheme", selection);
themeGenome.mutate({
selectedColorScheme: selection,
});
}Restore it after mounting:
const saved = localStorage.getItem("color-scheme");
if (saved === "light" || saved === "dark" || saved === "system") {
themeGenome.mutate({
selectedColorScheme: saved,
});
}What can go wrong?
Reading the wrong context key
The built-in binding writes:
context.colorScheme;It does not write:
context.mode;Calling the binding during SSR
window.matchMedia() is unavailable on the server.
Forgetting cleanup
Always call the function returned by bindMediaQueries().
Hydration starts with another mode
Keep server and initial browser state consistent where possible.
System mode overwrites user mode
Keep system preference and explicit selection as separate values.