Dark Mode
A single attribute — `data-theme` on the html element — flips every token. Here's the module that manages it and how to wire it in.
Live demo
Flip the switch — the whole site rerenders against the dark palette. Same DOM, same utilities; only the CSS variable values change.
Dark mode
Currently: light
How it works
One Svelte module owns the state: it reads localStorage, falls back to prefers-color-scheme, and writes data-theme on <html>. Tailwind v4’s @custom-variant dark ([data-theme='dark'] &); then activates the dark branch of every token.
@custom-variant dark ([data-theme='dark'] &);The theme module
import { browser } from '$app/environment';
const STORAGE_KEY = 'bloom-theme';
type Theme = 'light' | 'dark';
export const theme = $state<{ current: Theme }>({ current: 'light' });
export function initTheme() {
if (!browser) return;
const stored = localStorage.getItem(STORAGE_KEY);
const system = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
theme.current = stored === 'dark' || stored === 'light' ? stored : system;
document.documentElement.setAttribute('data-theme', theme.current);
}
export function toggleTheme() {
theme.current = theme.current === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', theme.current);
localStorage.setItem(STORAGE_KEY, theme.current);
}The $state rune makes theme.current reactive across the app — bind it to a Switch, read it in a badge, or subscribe from a component that swaps an illustration.
Wiring it into the root
Call initTheme once on mount from the root layout so the attribute lands before first paint of interactive content. (Server-side it’s a no-op.)
<script lang="ts">
import { onMount } from 'svelte';
import { initTheme } from '$lib/docs/theme.svelte';
let { children } = $props();
onMount(initTheme);
</script>
{@render children()}Avoiding the flash of wrong theme
The current setup reads storage on mount, which means there’s a ~1 frame window where dark users see light surfaces. For production, inline a tiny script in app.html that sets the attribute synchronously before the stylesheet loads — Bloom doesn’t yet, because prototype surfaces tolerate the flash. If you’re shipping to real users, add it.
Accessibility
- Honor
prefers-color-schemeon first load; persist once the user explicitly toggles. - Never rely on dark mode as a semantic signal — status colors work in both themes.
- Test focus rings in both. Bloom’s
--ringmirrors--primaryand shifts lightness per theme so focus stays visible. prefers-reduced-motionstill applies — the toggle itself uses a transform/opacity transition that respects the media query via the motion token layer.