Recipe , dark mode (no flash)
A complete, copy-pasteable dark/light theme that survives navigation and reload and never flashes. This
gathers the pieces from Theming and No-FOUC into one place.
1. The atom
0000000000// atoms/theme.ts
import { atom } from "ekko:rune/mimir";
export type Theme = "dark" | "light";
export const themeAtom = atom<Theme>({ key: "site-theme", default: "dark" });
2. The CSS tokens
0000000000// styles/global.scss
:root { --bg:#f7f9fc; --surface:#fff; --text:#10151c; --muted:#5b6675; --border:#e3e8ef; --accent:#5e81ac; }
.dark { --bg:#0d1117; --surface:#161b22; --text:#e6edf3; --muted:#8a94a3; --border:#222b36; --accent:#88c0d0; }
body { background:var(--bg); color:var(--text); }
3. The bridge (class + session + localStorage mirror)
0000000000// components/ThemeBridge.tsx
import { useEffect } from "@ekko/react";
import { mimir } from "ekko:rune/mimir";
import { themeAtom } from "../atoms/theme";
export default function ThemeBridge() {
const apply = (v: string) => { document.documentElement.classList.toggle("dark", v !== "light"); try { localStorage.setItem("site-theme", v); } catch {} apply(mimir.get(themeAtom)); return mimir.subscribe(themeAtom, apply); }
Render <ThemeBridge /> once in the root layout.
4. The toggle
0000000000import { useAtom } from "ekko:rune/mimir";
import { themeAtom } from "../atoms/theme";
export function ThemeToggle() {
const [theme, setTheme] = useAtom(themeAtom); return <button onClick={() => setTheme(t => t === "dark" ? "light" : "dark")}>{theme === "dark" ? "🌙" : "☀️"}</button>; }
5. The no-FOUC script + atom seed
0000000000// server.tsx
const noFouc = `<script>try{if(localStorage.getItem('site-theme')!=='light')document.documentElement.classList.add('dark');}catch(e){}</script>`;
const head = `<style>${globalCSS}</style>${noFouc}${seo.headTags()}`;
0000000000// each page's ssr() (seed a neutral default on cached pages)
export function ssr() { return { title: "...", __atoms: { "site-theme": "dark" } }; }
Why this works
- The no-FOUC script sets
.dark synchronously before paint → correct first paint. - The atom seed makes the React tree match → clean hydration, no flicker.
- The bridge + session apply changes, persist to IndexedDB, and mirror to
localStorage → correct on
navigation and reload.
All three keys must match ("site-theme"). See No-FOUC for the full reasoning.
Respecting the OS preference
Default to the system setting when the user has not chosen, by reading prefers-color-scheme in the no-FOUC
script:
0000000000const noFouc = `<script>try{var s=localStorage.getItem('site-theme');var d=s?s!=='light':matchMedia('(prefers-color-scheme: dark)').matches;if(d)document.documentElement.classList.add('dark');}catch(e){}</script>`;
Once the user toggles, their choice (persisted) takes precedence over the OS preference.