Theme Controller

  • dark mode
  • multiple themes
  • tailwind
  • css variables
  • FOUC

16 min read 3454 words

Appearance

When should we use your themes?
Choose your day and night themes.
day theme
SampleKit
Profile

bellflower

night theme
SampleKit
Profile

amethyst

When given the opportunity, I nearly always switch to dark mode. From cursory Googling, it seems a majority of people share that preference. For example, an Android Authority poll showed 81.9% of their users choose dark mode.

If you clicked on that link you may have noticed the option to toggle between light and dark mode. However, there's no option to sync with device setting. Also, if you opened the page while your device was set to dark mode, you were probably blinded by an #FFF background until the JavaScript kicked in. These are not uncommon issues, but in this article we're going to overcome them.

Goals

Let's build a color theming system for SvelteKit with all these benefits:

Compatibility with design tokens from popular sources like Radix, USWDS, DaisyUI, or TailwindCSS
Integration and intellisense with Tailwind
Full user control
Persistent preference storage available on the client and server

And none of the downsides:

Flash of Unstyled Content – or more precisely, incorrectly styled content – while loading a user's stored settings
Bloated light and dark css classes for every component
Synchronization problems with system settings

This website shows one such implementation using the methods described below. The complete source code can be found at $lib/styles.

Organizing the CSS

Before we create the components and logic to handle multiple themes, we should think about how we want to implement the colors in our CSS. Let's consider two ways to organize our color design tokens that will make it easy to use in CSS (and later Tailwind).

Two design token sets for each theme

First an example using Radix UI Colors:

adaptiveColorThemes.css
.light {
	--blue-5: 205.6deg 100% 88%;
}
.dark {
	--blue-5: 206.9deg 100% 22.7%;
}
[data-theme='adaptive-theme'] {
	--info-5: var(--blue-5);
}
tailwind.config.ts
colors: {
	...,
	info: {
		'1': `hsl(var(--info-5) / <alpha-value>)`
	},
	...
}

Organizing the theme this way means each color variable references two separate design tokens – one for light mode and one for dark mode.

By changing class="light" to class="dark", all the themed color variables will change.

src/app.html
<html lang="en" data-theme="adaptive-theme" class="light">
	...
	<p class="border-info-5 border">Hi</p>
	...
</html>
src/app.html
<html lang="en" data-theme="adaptive-theme" class="dark">
	...
	<p class="border-info-5 border">Hi</p>
	...
</html>

One design token set for each theme

Now lets look at an example using a DaisyUI theme:

staticColorThemes.css
[data-theme='cupcake'] {
	/* --p is short for primary */
	--p: 0.76172 0.089459 200.026556;
}
[data-theme='business'] {
	--p: 0.417036 0.099057 251.473931;
}
src/app.html
<!-- only the data-theme matters for the css.
	   class="light" is optional here,
	   but it can be useful for overriding with tailwind -->
<html lang="en" data-theme="cupcake" class="light">
	...
	<p class="border-primary border">Hi</p>
	...
</html>
src/app.html
<html lang="en" data-theme="business" class="dark">
	...
	<p class="border-primary border">Hi</p>
	...
</html>

With this organization, the theme is always fixed to either a light or dark mode.

Lifting design tokens up

If we look back at our Radix UI example:

adaptiveColorThemes.css
.light {
	--blue-5: 205.6deg 100% 88%;
}
.dark {
	--blue-5: 206.9deg 100% 22.7%;
}
[data-theme='adaptive-theme'] {
	--info-5: var(--blue-5);
}

We can see that there is no direct way to use a light --blue-5 while in dark mode. We can fix this by pulling the variables up one level.

adaptiveColorThemes.css
:root {
	--blue-5-light: 205.6deg 100% 88%;
	--blue-5-dark: 206.9deg 100% 22.7%;
}
.light {
	--blue-5: var(--blue-5-light);
}
.dark {
	--blue-5: var(--blue-5-dark);
}
[data-theme='adaptive-theme'] {
	--info-5: var(--blue-5);
}

Similarly:

staticColorThemes.css
:root {
	--cupcake-p: 0.76172 0.089459 200.026556;
	--business-p: 0.417036 0.099057 251.473931;
}
[data-theme='cupcake'] {
	--p: var(--cupcake-p);
}
[data-theme='business'] {
	--p: var(--business-p);
}

Choosing an organization method

Both methods of organization work equally, but one might be more convenient depending on where the design tokens are coming from and how the customization options are presented to the user. The rest of this article will use the "two tokens per theme" or "Radix UI" approach.

Controller Components

Now that we've pulled some design tokens into our css, we can control the entire theme using just data-theme and class attributes on some top level HTML tag. Our state with then look like:
type Theme = { name: string, scheme: 'light' | 'dark' };
. We can force specific components to use a different theme by adding a wrapper with those properties.

This website uses these themes:

themeUtils.ts
export const THEMES = [
	{ name: 'daffodil', scheme: 'light' },
	{ name: 'desert', scheme: 'dark' },
	{ name: 'bellflower', scheme: 'light' },
	{ name: 'amethyst', scheme: 'dark' },
] as const satisfies { name: string; scheme: 'light' | 'dark' }[];

export type Theme = (typeof THEMES)[number];

But how should we let the user interact with these two attributes?

Depending on how many themes are supported and whether we allow syncing the theme to a user's browser preference, we have the following options of components for our users:

Theme CountSystem SyncKindExamples
2NoDay / Night switch [Source]Svelte, React
2YesDay / Night / System select [Source]Tailwind, Supabase, LinkedIn
3+NoFixed theme pickerDaisyUI, Rust Book, Gitlab
3+YesDay / Night theme pickers [Source] + Day / Night / System select [Source]GitHub

The first two are by far the most familiar. Most sites have a specific character they want to showcase, and that includes a carefully chosen color palette.

Of those two, the Day / Night switch is simpler, but because it doesn't consider if the user wants to sync with their system, they would have to manually change the site theme when their system changed (or override the changes from an EventListener if we set one).

The third, – fixed theme picker – gives the user the power to personalize the site.

The controller can be as simple as this:

Fixed Theme Picker Controller
import { THEMES, getStoredThemeOnClient, storeThemeOnClient, setThemeOnDoc, type Theme } from './themeUtils';

/**
 * stores theme_fixed_name and theme_fixed_scheme in document.cookies
 *
 * applies the theme onto the document as data-theme="{{ Theme }}" and class="light" | class="dark"
 */
export class ThemeController {
	#theme: Theme = $state(getStoredThemeOnClient());

	get scheme() {
		return this.#theme.scheme;
	}

	get theme() {
		return this.#theme.name;
	}

	set theme(value: Theme['name']) {
		const theme = THEMES.find((t) => t.name === value);
		if (!theme) return
		if (theme.name === this.#theme.name && theme.scheme === this.#theme.scheme) return;
		this.#theme = theme;
		storeThemeOnClient({ theme });
		setThemeOnDoc(this.#theme);
	}
}

This website, however, implements the last and most flexible option.

Controller

Our theme service will have state synced in three places.

  1. Component reactivity:
    $state()
    or
    writable()
  2. Permanent storage: Either Cookies or LocalStorage
  3. HTML:
    <html data-theme="{{ themeApplied.name }}" class="{{ themeApplied.scheme }}" data-prefer-scheme="{{ SystemScheme }}">

State

Because we'll be using the data in our theme controller components, we'll need some Svelte store or rune reactivity.

themeUtils.ts
export type SystemScheme = 'light' | 'dark';
export type Mode = 'fixed_day' | 'fixed_night' | 'sync_system';
export type ModeApplied = 'day' | 'night';
themeController.svelte.ts
export class ThemeController {
	// the browser `prefers-color-scheme`
	#systemScheme = $state() as SystemScheme;
	// the toggle switch between fixed light/dark modes or system sync
	#mode = $state() as Mode;
	// the user's preferred day theme
	#themeDay = $state() as Theme;
	// the user's preferred night theme
	#themeNight = $state() as Theme;

	// the mode applied after converting sync_system to the user's preferred scheme
	#modeApplied: ModeApplied = $derived(
		this.#mode === 'fixed_day'
			? 'day'
			: this.#mode === 'fixed_night'
				? 'night'
				: this.#systemScheme === 'light'
					? 'day'
					: 'night',
	);

	// the theme actually applied
	#themeApplied = $derived(this.#modeApplied === 'day' ? this.#themeDay : this.#themeNight);

	// knowing if the store is initialized can be useful when loading external scripts
	// that render light/dark components (for example Turnstile or reCAPTCHA)
	#initializedOnClient = $state(false);
}

Storage

Each setter will also need to store the state so it's available when refreshing. If using localStorage, it can be as simple as:

themeUtils.ts
const STORAGE_KEY_THEME_DAY = 'theme_day';
const STORAGE_KEY_THEME_NIGHT = 'theme_night';
const STORAGE_KEY_THEME_SYNC_MODE = 'theme_sync_mode';

type Key =
	| `${typeof STORAGE_KEY_THEME_DAY | typeof STORAGE_KEY_THEME_NIGHT}_${'name' | 'scheme'}`
	| 'theme_sync_mode';

function getStorage(name: Key): string | null {
	return localStorage.getItem(key);
}

function setStorage(name: Key, value: string, days?: number): void {
	localStorage.setItem(key, value);
}

Note that we store theme_day_scheme and theme_night_scheme without assuming theme_day_scheme=light and theme_night_scheme=dark. This gives the user the option, for example, to have a "light" theme during the day and a dimmer "light" theme during the night.

If we want to have access to the values on the server, we might prefer Cookies.

+layout.server.ts
const getThemeOnServer = (cookies: Cookies) => ({
	mode: normalizeThemeMode(cookies.get('theme_sync_mode' satisfies StorageKey)),
	themeDay: normalizeThemeDay(cookies.get('theme_day_name' satisfies StorageKey), cookies.get),
	themeNight: normalizeThemeNight(cookies.get('theme_night_name' satisfies StorageKey), cookies.get),
});

export const load: LayoutServerLoad = async ({ cookies }) => {
	return { theme: getThemeOnServer(cookies) };
	// {
	//   mode: 'fixed_night',
	//   themeDay: { name: 'bellflower', scheme: 'light' },
	//   themeNight: { name: 'amethyst', scheme: 'dark' }
	// }
};
themeUtils.ts
function getBrowserCookie(name: StorageKey): string | null {
	const nameEQ = `${name}=`;
	const cookies = document.cookie.split(';');

	for (let i = 0; i < cookies.length; i++) {
		let cookie = cookies[i];
		while (cookie?.charAt(0) === ' ') {
			cookie = cookie.substring(1, cookie.length);
		}
		if (cookie?.indexOf(nameEQ) === 0) {
			return cookie.substring(nameEQ.length, cookie.length);
		}
	}

	return null;
}

function setBrowserCookie(name: StorageKey, value: string, days?: number): void {
	let expires = '';

	if (days) {
		const date = new Date();
		date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
		expires = `; expires=${date.toUTCString()}`;
	}

	document.cookie = `${name}=${value}${expires}; path=/`;
}

Document

And finally, none of this matters if the state isn't actually applied to the document.

Let's create the necessary helper functions.

themeUtils.ts
// Compile this to /static/themeUtils.js and import into app.html to prevent FOUC

export const STORAGE_KEY_THEME_DAY = 'theme_day';
export const STORAGE_KEY_THEME_NIGHT = 'theme_night';
export const STORAGE_KEY_THEME_SYNC_MODE = 'theme_sync_mode';
export type StorageKey =
	| `${typeof STORAGE_KEY_THEME_DAY | typeof STORAGE_KEY_THEME_NIGHT}_${'name' | 'scheme'}`
	| 'theme_sync_mode';

function getBrowserCookie(name: StorageKey): string | null {
	const nameEQ = `${name}=`;
	const cookies = document.cookie.split(';');

	for (let i = 0; i < cookies.length; i++) {
		let cookie = cookies[i];
		while (cookie?.charAt(0) === ' ') {
			cookie = cookie.substring(1, cookie.length);
		}
		if (cookie?.indexOf(nameEQ) === 0) {
			return cookie.substring(nameEQ.length, cookie.length);
		}
	}

	return null;
}

export const THEMES = [
	{ name: 'daffodil', scheme: 'light' },
	{ name: 'desert', scheme: 'dark' },
	{ name: 'bellflower', scheme: 'light' },
	{ name: 'amethyst', scheme: 'dark' },
] as const satisfies { name: string; scheme: 'light' | 'dark' }[];

export type Theme = (typeof THEMES)[number];
export type SystemScheme = 'light' | 'dark';
export type Mode = 'fixed_day' | 'fixed_night' | 'sync_system';
export type ModeApplied = 'day' | 'night';

const DEFAULT_THEME_DAY = { name: 'bellflower', scheme: 'light' } as const satisfies Theme;
const DEFAULT_THEME_NIGHT = { name: 'amethyst', scheme: 'dark' } as const satisfies Theme;
const DEFAULT_THEME_SYNC_MODE: Mode = 'sync_system';

export const getSystemScheme = (): SystemScheme => {
	if (typeof window === 'undefined' || !window.matchMedia) return 'light';
	return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
};

export const normalizeThemeMode = (val: string | null | undefined): Mode => {
	if (!val) return DEFAULT_THEME_SYNC_MODE;
	if (val === 'fixed_day' || val === 'fixed_night' || val === 'sync_system') return val;
	return DEFAULT_THEME_SYNC_MODE;
};

export const normalizeThemeDay = (
	name: string | null | undefined,
	getter: (key: StorageKey) => string | null | undefined,
): Theme => {
	if (!name) return DEFAULT_THEME_DAY;
	const scheme = getter(`${STORAGE_KEY_THEME_DAY}_scheme`);
	if (!scheme) return DEFAULT_THEME_DAY;
	return THEMES.find((t) => t.name === name && t.scheme === scheme) ?? DEFAULT_THEME_DAY;
};

export const normalizeThemeNight = (
	name: string | null | undefined,
	getter: (key: StorageKey) => string | null | undefined,
): Theme => {
	if (!name) return DEFAULT_THEME_NIGHT;
	const scheme = getter(`${STORAGE_KEY_THEME_NIGHT}_scheme`);
	if (!scheme) return DEFAULT_THEME_NIGHT;
	return THEMES.find((t) => t.name === name && t.scheme === scheme) ?? DEFAULT_THEME_NIGHT;
};

export const getStoredThemeModeClient = (): Mode => {
	return normalizeThemeMode(getBrowserCookie(STORAGE_KEY_THEME_SYNC_MODE));
};

export const getStoredThemeDayClient = (): Theme => {
	return normalizeThemeDay(getBrowserCookie(`${STORAGE_KEY_THEME_DAY}_name`), getBrowserCookie);
};

export const getStoredThemeNightClient = (): Theme => {
	return normalizeThemeNight(getBrowserCookie(`${STORAGE_KEY_THEME_NIGHT}_name`), getBrowserCookie);
};

export const setThemeOnDoc = ({ name, scheme }: Theme) => {
	if (scheme === 'dark') {
		document.documentElement.classList.remove('light');
		document.documentElement.classList.add('dark');
		document.documentElement.dataset['theme'] = name;
	} else {
		document.documentElement.classList.add('light');
		document.documentElement.classList.remove('dark');
		document.documentElement.dataset['theme'] = name;
	}
};

export const setSystemSchemeOnDoc = (systemScheme: SystemScheme) => {
	document.documentElement.setAttribute('data-prefer-scheme', systemScheme);
};

Now we can add our set methods on our controller.

themeController.svelte.ts
export class ThemeController {
	...
	setTheme({ kind, theme }: { kind: ModeApplied; theme: Theme }) {
		if (kind === 'day') this.#themeDay = theme;
		else this.#themeNight = theme;
		storeTheme({ kind, theme });
		setThemeOnDoc(this.#themeApplied);
	}

	setMode(mode: 'fixed_day' | 'fixed_night' | 'sync_system') {
		this.#mode = mode;
		storeMode(this.#mode);
		setThemeOnDoc(this.#themeApplied);
	}
	...
}

We'll also need to make sure that if the user changes their system preference while the site is open, the systemScheme is changed, which will then update the modeApplied and themeApplied. We can set up a listener in the constructor.

themeController.svelte.ts
export class ThemeController {
	...
	constructor(initial: InitialTheme) {
		this.#initializedOnClient = false;
		this.#systemScheme = initial.systemScheme;
		this.#mode = initial.mode;
		this.#themeDay = initial.themeDay;
		this.#themeNight = initial.themeNight;

		const listener = (prefersDark: MediaQueryListEvent) => {
			this.#systemScheme = prefersDark.matches ? 'dark' : 'light';
			setThemeOnDoc(this.#themeApplied);
			setSystemSchemeOnDoc(this.#systemScheme);
		};

		$effect(() => {
			this.#initializedOnClient = true;
			window.matchMedia?.('(prefers-color-scheme: dark)').addEventListener('change', listener);
			return () => {
				window.matchMedia?.('(prefers-color-scheme: dark)').removeEventListener('change', listener);
			};
		});
	}
	...
}

And now we can implement the getters and setters consumed by the controller.

themeController.svelte.ts
class ThemeController {
	...
	get initializedOnClient() {
		return this.#initializedOnClient;
	}
	get systemScheme() {
		return this.#systemScheme;
	}
	get mode() {
		return this.#mode;
	}
	set mode(mode: 'fixed_day' | 'fixed_night' | 'sync_system') {
		this.setMode(mode);
	}
	get themeDay() {
		return this.#themeDay;
	}
	set themeDay(theme) {
		this.setTheme({ kind: 'day', theme });
	}
	get themeNight() {
		return this.#themeNight;
	}
	set themeNight(theme) {
		this.setTheme({ kind: 'night', theme });
	}
	get modeApplied() {
		return this.#modeApplied;
	}
	get themeApplied() {
		return this.#themeApplied;
	}
	...
}

Prevent FOUC

The only thing left on our checklist is to remove the flash of the wrong theme when the user refreshes.

We can completely eliminate this by calling some (blocking) init logic in app.html.

I prefer to write a short script inside themeUtils.ts, compile it with a script into /static, and call it in app.html.

themeUtils.ts
export const initTheme = () => {
	const mode = getStoredThemeModeClient();
	const systemScheme = getSystemScheme();
	const appliedMode =
		mode === 'fixed_day' ? 'day' : mode === 'fixed_night' ? 'night' : systemScheme === 'light' ? 'day' : 'night';
	const themeApplied = appliedMode === 'night' ? getStoredThemeNightClient() : getStoredThemeDayClient();
	setThemeOnDoc(themeApplied);
	setSystemSchemeOnDoc(systemScheme);
};
package.json
"generate:theme_utils": "./scripts/generate-theme-utils.sh",
scripts/generate-theme-utils.sh
cd src/lib/styles &&
	cp themeUtils.ts themeUtils.tmp.ts &&
	sed -i '' 's/export //g' themeUtils.tmp.ts &&
	npx tsc themeUtils.tmp.ts &&
	mv themeUtils.tmp.js ../../../static/themeUtils.js &&
	rm themeUtils.tmp.ts
app.html
<!doctype html>
<html lang="en" data-theme="amethyst" class="dark">
	<head>
		<!-- theme -->
		<script src="/themeUtils.js"></script>
		<script>
			initTheme();
		</script>
		...
	</head>
	...
</html>
Alternatively, you could write a placeholder like
<html lang="en" replace-me-with-theme>
in app.html and then replace it in hooks.server.ts using the request cookies. That's nice because it doesn't require a separate init script, but it won't work with adapter-static.

Using our theme

CSS

In our css, we can write:

<span class="red-box">I'm red!</span>

<style>
	.red-box {
		background-color: hsl(var(--red-5));
		border: 1px solid hsl(var(--red-7));
		width: fit-content;
	}
</style>

And it will render as: I'm red!

Notice that we didn't have to write light, dark, or theme specific styles. The correct design token is used automatically when the user switches modes or theme.

And our design tokens aren't just limited to colors! Things like border radii can make something seem more fun or trustworthy and definitely belong in the theme.

Tailwind

Getting this working with Tailwind is easy. This is for Radix UI, so each Tailwind class references one of its 12 css variables. If it's gray, it also has alpha colors. Otherwise, it has a special "contrast" color. Just adapt it to whatever design tokens you have.

tailwind.config.ts
const createAllColors = () => {
	// light/dark/adaptive with 1-12 + 9-contrast (25 tokens, 37 vars)
	const token = ['green', 'red', 'amber', 'blue', 'iris'] as const;
	// light/dark/adaptive with 1-12 + 1-12 alpha (48 tokens, 72 vars)
	const tokenGray = ['mauve', 'sand'] as const;
	// adaptive with 1-12 + 9-contrast (0 tokens, 13 vars)
	const semantic = ['success', 'error', 'warning', 'info', 'accent'] as const;
	// adaptive with 1-12 + 1-12 alpha (0 tokens, 24 vars)
	const semanticGray = ['gray'] as const;

	const res: Record<string, string | Record<string, string | Record<string, string>>> = {
		transparent: 'transparent',
		black: 'hsl(var(--black) / <alpha-value>)',
		white: 'hsl(var(--white) / <alpha-value>)',
		logo: 'hsl(var(--logo) / <alpha-value>)',
		'sun-moon': 'hsl(var(--sun-moon) / <alpha-value>)',
		svelte: 'hsl(var(--svelte) / <alpha-value>)',
	};

	res['app'] = {
		bg: 'hsl(var(--app-bg) / <alpha-value>)',
	};

	for (const theme of THEMES) {
		res['app'][`bg-${theme.name}`] = `hsl(var(--app-bg-${theme.name}) / <alpha-value>)`;
	}

	for (const t of token) {
		const outer: Record<string, Record<string, string>> = {};
		for (const i of Array(12).keys()) {
			const inner: Record<string, string> = {
				DEFAULT: `hsl(var(--${t}-${i + 1}) / <alpha-value>)`,
				light: `hsl(var(--${t}-${i + 1}-light) / <alpha-value>)`,
				dark: `hsl(var(--${t}-${i + 1}-dark) / <alpha-value>)`,
			};
			if (i === 8) inner['contrast'] = `hsl(var(--${t}-${i + 1}-contrast) / <alpha-value>)`;
			outer[`${i + 1}`] = inner;
		}
		res[t] = outer;
	}

	for (const t of tokenGray) {
		const outer: Record<string, Record<string, string>> = {};
		for (const i of Array(12).keys()) {
			const inner: Record<string, string> = {
				DEFAULT: `hsl(var(--${t}-${i + 1}) / <alpha-value>)`,
				light: `hsl(var(--${t}-${i + 1}-light) / <alpha-value>)`,
				dark: `hsl(var(--${t}-${i + 1}-dark) / <alpha-value>)`,
			};
			const alpha: Record<string, string> = {
				DEFAULT: `var(--${t}-a${i + 1})`,
				light: `var(--${t}-a${i + 1}-light)`,
				dark: `var(--${t}-a${i + 1}-dark)`,
			};
			outer[`${i + 1}`] = inner;
			outer[`a${i + 1}`] = alpha;
		}
		res[t] = outer;
	}

	for (const t of semantic) {
		const outer: Record<string, string | Record<string, string>> = {};
		for (const i of Array(12).keys()) {
			if (i === 8) {
				outer[`${i + 1}`] = {
					DEFAULT: `hsl(var(--${t}-${i + 1}) / <alpha-value>)`,
					contrast: `hsl(var(--${t}-${i + 1}-contrast) / <alpha-value>)`,
				};
			} else {
				outer[`${i + 1}`] = `hsl(var(--${t}-${i + 1}) / <alpha-value>)`;
			}
		}
		res[t] = outer;
	}

	for (const t of semanticGray) {
		const outer: Record<string, string> = {};
		for (const i of Array(12).keys()) {
			outer[`${i + 1}`] = `hsl(var(--${t}-${i + 1}) / <alpha-value>)`;
			outer[`a${i + 1}`] = `var(--${t}-a${i + 1})`;
		}
		res[t] = outer;
	}
	return res;
};

export default {
	darkMode: ['class'],
	content: ['./src/**/*.{html,svelte,js,ts}'],
	theme: {
		...
		colors: createAllColors(),
		...
	},
	...
} satisfies Config;

Now our css example from before:

<span class="red-box">I'm red!</span>

<style>
	.red-box {
		background-color: hsl(var(--red-5));
		border: 1px solid hsl(var(--red-7));
		width: fit-content;
	}
</style>

Can be written (with intellisense) as:

<span class="bg-red-5 border-red-7 w-fit border">I'm red!</span>

And it will still render as: I'm red!

Bonus Animation

I saw a fun View Transition pull request on the VitePress repo by user hooray. As of August 2024, the API only has 72% global usage, but it's a fun progressive enhancement, so let's add it.

themeController.svelte.ts
#animateThemeOnDoc() {
	/**
	 * Credit [@hooray](https://github.com/hooray)
	 * @see https://github.com/vuejs/vitepress/pull/2347
	 */
	const allowTransition =
		// @ts-expect-error – experimental
		document.startViewTransition &&
		!window.matchMedia('(prefers-reduced-motion: reduce)').matches &&
		// Too buggy on mobile. The clip path is offset by the status bar height and it causes some elements to be cut off
		window.innerWidth >= 620;

	const el: SVGElement | null = document.querySelector('label[for="theme-switch-btn"]');

	if (!allowTransition || !el) {
		setThemeOnDoc(this.#themeApplied);
		return;
	}

	// @ts-expect-error – experimental
	const transition = document.startViewTransition(async () => {
		setThemeOnDoc(this.#themeApplied);
		await tick();
	});

	const rect = el.getBoundingClientRect();
	const x = rect.left + rect.width / 2;
	const y = rect.top + rect.height / 2;

	const endRadius = Math.hypot(Math.max(x, window.innerWidth - x), Math.max(y, window.innerHeight - y));
	const clipPath = [`circle(0px at ${x}px ${y}px)`, `circle(${endRadius}px at ${x}px ${y}px)`];

	transition.ready.then(() => {
		document.documentElement.animate(
			{
				clipPath: this.#modeApplied === 'night' ? [...clipPath].reverse() : clipPath,
			},
			{
				duration: this.#modeApplied === 'night' ? 250 : 350,
				easing: 'ease-in-out',
				pseudoElement: this.#modeApplied === 'night' ? '::view-transition-old(root)' : '::view-transition-new(root)',
			},
		);
	});
}

setTheme({ kind, theme, animate }: { kind: ModeApplied; theme: Theme; animate: boolean }) {
	if (kind === 'day') this.#themeDay = theme;
	else this.#themeNight = theme;
	storeTheme({ kind, theme });
	if (animate) this.#animateThemeOnDoc();
	else setThemeOnDoc(this.#themeApplied);
}

setMode(mode: 'fixed_day' | 'fixed_night' | 'sync_system', opts: { animate: boolean }) {
	this.#mode = mode;
	storeMode(this.#mode);
	if (opts.animate) this.#animateThemeOnDoc();
	else setThemeOnDoc(this.#themeApplied);
}

set mode(mode: 'fixed_day' | 'fixed_night' | 'sync_system') {
	this.setMode(mode, { animate: false });
}
set themeDay(theme) {
	this.setTheme({ kind: 'day', theme, animate: false });
}
set themeNight(theme) {
	this.setTheme({ kind: 'night', theme, animate: false });
}
app.css
::view-transition-old(root),
::view-transition-new(root) {
	animation: none;
	mix-blend-mode: normal;
}
::view-transition-old(root) {
	z-index: 1;
}
::view-transition-new(root) {
	z-index: 9999;
}
.dark::view-transition-old(root) {
	z-index: 9999;
}
.dark::view-transition-new(root) {
	z-index: 1;
}

Conclusion

This is just one way of implementing a theming system in SvelteKit, but we can safely say we've checked all the boxes we set out to check.

Attain

Compatibility with design tokens from popular sources
Integration and intellisense with Tailwind
Full user control
Persistent preference storage available on the client (and optionally the server)

Avoid

Flash of Unstyled Content
Bloated light and dark css classes for every component
Synchronization problems with system settings

Do you have an even better way? I'd love it hear it! Share it in the GitHub discussions !

Published
Last Updated

Previous Article


Simple URL State Controller

Store state in the URL with a few simple Svelte stores.
March 7, 2024

Next Article


TypeSafe Fetch Handler

A typesafe fetch handler that stores the route, method, res/req types, and fetch state.
Updated: August 13, 2024

Changelog

  • Update to runes.
  • Add view transition.

Have a suggestion? File an issue.