Theme Controller

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

19 min read 4107 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 will 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:

$lib/styles/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.

$lib/styles/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];
export type SystemScheme = Theme['scheme'];
export type Mode = 'fixed_day' | 'fixed_night' | 'sync_system';
export type ModeApplied = 'day' | 'night';
$lib/styles/themeController.svelte.ts
import type { SystemScheme, Mode, Theme, ModeApplied } from './themeUtils';

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);

	get initializedOnClient() {
		return this.#initializedOnClient;
	}
	get systemScheme() {
		return this.#systemScheme;
	}
	get mode() {
		return this.#mode;
	}
	get modeApplied() {
		return this.#modeApplied;
	}
	get themeDay() {
		return this.#themeDay;
	}
	get themeNight() {
		return this.#themeNight;
	}
	get themeApplied() {
		return this.#themeApplied;
	}
}

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:

$lib/styles/themeUtils.ts
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'}`
	| typeof STORAGE_KEY_THEME_SYNC_MODE;

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 = Theme['scheme'];
export type Mode = 'fixed_day' | 'fixed_night' | 'sync_system';
export type ModeApplied = 'day' | 'night';
$lib/styles/storeTheme.ts
import type { StorageKey } from './themeUtils';

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

export function setStorage(key: StorageKey, value: string): 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.

$lib/styles/themeUtils.ts
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'}`
	| typeof STORAGE_KEY_THEME_SYNC_MODE;

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 = Theme['scheme'];
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 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;
};
$lib/styles/storeTheme.ts
import type { StorageKey } from './themeUtils';

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

export function setStorage(key: StorageKey, value: string): void {
	localStorage.setItem(key, value);
}
import { normalizeThemeDay, normalizeThemeMode, normalizeThemeNight, STORAGE_KEY_THEME_SYNC_MODE } from './themeUtils';
import type {
	Mode,
	ModeApplied,
	STORAGE_KEY_THEME_NIGHT,
	STORAGE_KEY_THEME_DAY,
	StorageKey,
	Theme,
} from './themeUtils';
import type { Cookies } from '@sveltejs/kit';

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=/`;
}

export const storeTheme = ({ kind, theme }: { kind: ModeApplied; theme: Theme }) => {
	const storageKey: typeof STORAGE_KEY_THEME_DAY | typeof STORAGE_KEY_THEME_NIGHT = `theme_${kind}`;
	setBrowserCookie(`${storageKey}_name`, theme.name);
	setBrowserCookie(`${storageKey}_scheme`, theme.scheme);
};

export const storeMode = (mode: Mode) => {
	setBrowserCookie(STORAGE_KEY_THEME_SYNC_MODE, mode);
};

export 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),
});

Document

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

Let's create the necessary helper functions.

$lib/styles/themeUtils.ts
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'}`
	| typeof STORAGE_KEY_THEME_SYNC_MODE;

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 = Theme['scheme'];
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 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 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;
	}
};

Now we can add our set methods on our controller.

$lib/styles/themeController.svelte.ts
import { storeMode, storeTheme } from './storeTheme'; 
import type { SystemScheme, Mode, Theme, ModeApplied } from './themeUtils';
import { setThemeOnDoc } from './themeUtils';

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);

	get initializedOnClient() {
		return this.#initializedOnClient;
	}
	get systemScheme() {
		return this.#systemScheme;
	}
	get mode() {
		return this.#mode;
	}
	get modeApplied() {
		return this.#modeApplied;
	}
	setMode(mode: 'fixed_day' | 'fixed_night' | 'sync_system') {
		this.#mode = mode;
		storeMode(this.#mode);
		setThemeOnDoc(this.#themeApplied);
	}
	set mode(mode: 'fixed_day' | 'fixed_night' | 'sync_system') {
		this.setMode(mode);
	}
	get themeDay() {
		return this.#themeDay;
	}
	get themeNight() {
		return this.#themeNight;
	}
	get themeApplied() {
		return this.#themeApplied;
	}
	setTheme({ kind, theme }: { kind: ModeApplied; theme: Theme }) {
		if (kind === 'day') this.#themeDay = theme;
		else this.#themeNight = theme;
		storeTheme({ kind, theme });
		setThemeOnDoc(this.#themeApplied);
	}
	set themeDay(theme) {
		this.setTheme({ kind: 'day', theme });
	}
	set themeNight(theme) {
		this.setTheme({ kind: 'night', theme });
	}
}

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.

$lib/styles/themeUtils.ts
...

export const setSystemSchemeOnDoc = (systemScheme: SystemScheme) => {
	document.documentElement.setAttribute('data-prefer-scheme', systemScheme);
};
$lib/styles/themeController.svelte.ts
import { storeMode, storeTheme } from './storeTheme';
import { SystemScheme, Mode, Theme, ModeApplied } from './themeUtils';
import { setThemeOnDoc, setSystemSchemeOnDoc } from './themeUtils';

export type InitialTheme = {
	systemScheme: 'light' | 'dark';
	mode: 'fixed_day' | 'fixed_night' | 'sync_system';
	themeDay: Theme;
	themeNight: Theme;
};

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);
			};
		});
	}
	...
}

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.

$lib/styles/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;
}

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

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 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>

		<meta charset="utf-8" />
		<link rel="icon" href="%sveltekit.assets%/favicon.png" />
		<meta name="viewport" content="width=device-width, initial-scale=1" />
		%sveltekit.head%
	</head>
	<body data-sveltekit-preload-data="hover">
		<div style="display: contents">%sveltekit.body%</div>
	</body>
</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 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.

$lib/styles/themeController.svelte.ts
import { tick } from 'svelte';

class ThemeController {
	...
	get initializedOnClient() {
		return this.#initializedOnClient;
	}
	get systemScheme() {
		return this.#systemScheme;
	}
	get mode() {
		return this.#mode;
	}
	get modeApplied() {
		return this.#modeApplied;
	}
	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 });
	}
	get themeDay() {
		return this.#themeDay;
	}
	get themeNight() {
		return this.#themeNight;
	}
	get themeApplied() {
		return this.#themeApplied;
	}
	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);
	}
	set themeDay(theme) {
		this.setTheme({ kind: 'day', theme, animate: false });
	}
	set themeNight(theme) {
		this.setTheme({ kind: 'night', theme, animate: false });
	}

	#animateThemeOnDoc() {
		/**
		* Credit [@hooray](https://github.com/hooray)
		* @see https://github.com/vuejs/vitepress/pull/2347
		*/
		const allowTransition =
			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;
		}

		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)',
				},
			);
		});
	}
}
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: October 22, 2024

Changelog

  • Improve diffing.

  • Update to runes.
  • Add view transition.

Have a suggestion? File an issue.