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.

src/routes/layout.css
@custom-variant dark ([data-theme='dark'] &);

The theme module

src/lib/docs/theme.svelte.ts
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.)

src/routes/+layout.svelte
<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-scheme on 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 --ring mirrors --primary and shifts lightness per theme so focus stays visible.
  • prefers-reduced-motion still applies — the toggle itself uses a transform/opacity transition that respects the media query via the motion token layer.