Mode Watcher
mode-watcher manages light/dark mode by mutating the global <html> element. Because it reads the DOM and localStorage, the whole thing lives in a mochi:hydrate island — the page renders on the server, then mode-watcher takes over on the client.
mode-watcher needs a little setup: you mount its <ModeWatcher /> component once, and it reads localStorage and applies the mode
to the global <html> element — adding a dark class and setting color-scheme. This site drives its own theme off a data-theme attribute, so the two coexist without collision.
bun add mode-watcherMount it once, then any button can drive the mode:
import { ModeWatcher, toggleMode } from 'mode-watcher';
// Mount ModeWatcher once; it applies the mode to <html>.
// <ModeWatcher />
// <button onclick={toggleMode}>Toggle</button>Full API at mode-watcher.dev.
Toggle & set the mode
toggleMode, setMode, and resetMode drive the global theme; the mode, userPrefersMode, and systemPrefersMode runes report the current state.
server sent: (no cookie yet) toggleMode
flip the global <html> between light and darksetMode / resetMode
user pick vs resolved vs OS- userPrefersMode
- system
- mode (resolved)
- —
- systemPrefersMode
- —
SSR-friendly theme, no flash
mode-watcher only persists to localStorage, which the server can't read — so on its own the first paint can flash. The bar above fixes that: the island mirrors
the resolved mode into a cookie, serverProps reads it back into <ModeWatcher defaultMode>, and a transformPage hook stamps class="dark" onto <html> before the response is sent. Pick a mode, then reload — the page comes back correct with no flicker (server sent: shows the value the server used). "system" can't be server-resolved without JS, so the cookie stores the resolved light/dark.
// routes.ts — read the cookie server-side, feed it to <ModeWatcher> as defaultMode
Mochi.page('./ModeWatcher.svelte', {
serverProps: () => ({ initialMode: cookies.get('mochi-demo-theme') }),
});
// and set the class on <html> before the page is sent — so there's no flash:
export const handle = async ({ event, resolve }) => {
const dark = getRequestContext().cookies.get('mochi-demo-theme') === 'dark';
return resolve(event, dark
? { transformPage: ({ html }) => html.replace('<html lang="en">', '<html lang="en" class="dark">') }
: undefined);
};
// island — mirror the mode back into the cookie whenever it changes (isomorphic cookies API)
$effect(() => {
if (mode.current) cookies.set('mochi-demo-theme', mode.current, { expires: 365 });
});<script>
import CodeSnippet from '../../components/CodeSnippet.svelte';
import ModeControls from './ModeControls.svelte';
import { highlightCode } from '../../lib/highlight.server';
import { files } from './files.ts';
let { initialMode = null } = $props();
const sources = await loadSources(files);
const codeInstall = await highlightCode('bun add mode-watcher', 'bash');
const codeUsage = await highlightCode(
`import { ModeWatcher, toggleMode } from 'mode-watcher';
// Mount ModeWatcher once; it applies the mode to <html>.
// <ModeWatcher />
// <button onclick={toggleMode}>Toggle</button>`,
'typescript',
);
const codeSsr = await highlightCode(
`// routes.ts — read the cookie server-side, feed it to <ModeWatcher> as defaultMode
Mochi.page('./ModeWatcher.svelte', {
serverProps: () => ({ initialMode: cookies.get('mochi-demo-theme') }),
});
// and set the class on <html> before the page is sent — so there's no flash:
export const handle = async ({ event, resolve }) => {
const dark = getRequestContext().cookies.get('mochi-demo-theme') === 'dark';
return resolve(event, dark
? { transformPage: ({ html }) => html.replace('<html lang="en">', '<html lang="en" class="dark">') }
: undefined);
};
// island — mirror the mode back into the cookie whenever it changes (isomorphic cookies API)
$effect(() => {
if (mode.current) cookies.set('mochi-demo-theme', mode.current, { expires: 365 });
});`,
'typescript',
);
</script>
<div class="intro">
<p>
<code>mode-watcher</code> needs a little setup: you mount its <code><ModeWatcher /></code> component once, and it reads <code>localStorage</code> and applies the mode
to the global <code><html></code> element — adding a
<code>dark</code> class and setting <code>color-scheme</code>. This site drives its own theme off a
<code>data-theme</code> attribute, so the two coexist without collision.
</p>
<CodeSnippet html={codeInstall} />
<p>Mount it once, then any button can drive the mode:</p>
<CodeSnippet html={codeUsage} />
<p>
Full API at <a href="https://mode-watcher.dev" target="_blank" rel="noopener noreferrer">mode-watcher.dev</a>.
</p>
</div>
<section class="card">
<header>
<h2>Toggle & set the mode</h2>
<p>
<code>toggleMode</code>, <code>setMode</code>, and <code>resetMode</code> drive the global theme; the
<code>mode</code>, <code>userPrefersMode</code>, and <code>systemPrefersMode</code> runes report the current state.
</p>
</header>
<ModeControls {initialMode} mochi:hydrate />
</section>
<section class="card">
<header>
<h2>SSR-friendly theme, no flash</h2>
<p>
mode-watcher only persists to <code>localStorage</code>, which the server can't read — so on its own the first paint can flash. The bar above fixes that: the island mirrors
the resolved mode into a cookie, <code>serverProps</code>
reads it back into <code><ModeWatcher defaultMode></code>, and a <code>transformPage</code> hook stamps
<code>class="dark"</code> onto <code><html></code> before the response is sent. Pick a mode, then reload — the page comes back correct with no flicker (<code
>server sent:</code
>
shows the value the server used).
<code>"system"</code> can't be server-resolved without JS, so the cookie stores the resolved
<code>light</code>/<code>dark</code>.
</p>
</header>
<CodeSnippet html={codeSsr} />
</section>
Styles
<style>
.intro {
font-size: 0.95rem;
color: var(--text-muted);
margin: 0 0 1.75rem;
}
.intro p {
margin: 0 0 0.75rem;
}
.intro a {
color: var(--accent);
text-decoration: underline;
}
.intro > p:last-child {
margin-bottom: 0;
}
.intro p code {
background: var(--surface-muted);
border: 1px solid var(--border);
color: var(--text);
font-family: var(--font-mono);
padding: 0.1em 0.35em;
border-radius: 4px;
font-size: 0.85em;
}
.card {
margin-bottom: 2rem;
}
.card header {
margin-bottom: 0.9rem;
}
.card header p {
font-size: 0.95rem;
color: var(--text-muted);
margin: 0.25rem 0 0;
}
.card header code {
background: var(--surface-muted);
border: 1px solid var(--border);
color: var(--text);
font-family: var(--font-mono);
padding: 0.1em 0.35em;
border-radius: 4px;
font-size: 0.85em;
}
</style>
More demos