Theme Controller
- dark mode
- multiple themes
- tailwind
- css variables
- FOUC
16 min read 3454 words
Appearance
bellflower
amethyst
<script lang="ts">
import { Admonition } from '$lib/components';
import { ThemePicker, THEMES, useThemeControllerCtx } from '$lib/styles';
const optionsArr = ['fixed_day', 'fixed_night', 'sync_system'] as const;
const tsCast = (str: string) => str as (typeof optionsArr)[number];
const themeController = useThemeControllerCtx();
</script>
<section class="space-y-6">
<h1 class="text-h4">Appearance</h1>
<noscript>
<Admonition bold kind="important" title="JS Required">Enable JavaScript to continue.</Admonition>
</noscript>
<div class="js-only space-y-6">
<fieldset>
<legend class="mb-2 text-lg font-bold">When should we use your themes?</legend>
<span class="space-y-1">
{#each optionsArr as value}
{@const checked = themeController.mode === value}
<span class="flex items-center gap-2">
<input
type="radio"
class="radio"
name="mode"
id={value}
{value}
{checked}
onclick={(e) => (themeController.mode = tsCast(e.currentTarget.value))}
/>
<label for={value}>
<span class={checked ? '' : 'font-light text-gray-10'}>
{#if value === 'fixed_day'}
Always use day theme (<span class="capitalize">{themeController.themeDay.name}</span>)
{:else if value === 'fixed_night'}
Always use night theme (<span class="capitalize">{themeController.themeNight.name}</span>)
{:else}Sync with system
<span class="showDay">(currently day)</span>
<span class="showNight">(currently night)</span>
{/if}
</span>
</label>
</span>
{/each}
</span>
</fieldset>
<div class="text-lg font-bold">Choose your day and night themes.</div>
<div class="grid grid-cols-1 gap-x-4 gap-y-8 min-[1130px]:grid-cols-2">
<ThemePicker
mode="day"
preference={themeController.themeDay}
active={themeController.initializedOnClient && themeController.modeApplied === 'day'}
setTheme={(e) => themeController.setTheme({ ...e, animate: false })}
themes={THEMES}
switchToFromInactive={() => (themeController.mode = 'fixed_day')}
/>
<ThemePicker
mode="night"
preference={themeController.themeNight}
active={themeController.initializedOnClient && themeController.modeApplied === 'night'}
setTheme={(e) => themeController.setTheme({ ...e, animate: false })}
themes={THEMES}
switchToFromInactive={() => (themeController.mode = 'fixed_night')}
/>
</div>
</div>
</section>
<script lang="ts">
import { fade } from 'svelte/transition';
import I from '$lib/icons';
import { debounce } from '$lib/utils/common';
import ThemeDemo from './ThemeDemo.svelte';
import type { ModeApplied, Theme } from '../themeUtils';
interface Props {
mode: 'day' | 'night';
themes: Theme[];
active: boolean;
preference: Theme;
switchToFromInactive?: () => void;
setTheme: (detail: { kind: ModeApplied; theme: Theme }) => void;
}
const { mode, themes, active, preference, switchToFromInactive, setTheme }: Props = $props();
const Icon = mode === 'day' ? I.Sun : I.Moon;
let hovered: null | Theme = $state(null);
const displayTheme = $derived(hovered ?? preference);
let saved: 'fading-in' | 'saved' | 'fading-out' | null = $state(null);
const setFadeOutTimer = debounce(() => (saved = 'fading-out'), 1000);
const save = (theme: Theme) => {
setTheme({ kind: mode, theme });
saved = 'fading-in';
};
let wantsToShowOppositeScheme = $state(false);
const hasToShowOppositeScheme = $derived(preference.scheme === (mode === 'day' ? 'dark' : 'light'));
const showOppositeScheme = $derived(wantsToShowOppositeScheme || hasToShowOppositeScheme);
</script>
<div class="overflow-hidden rounded-card border border-gray-6">
<div class="grid grid-cols-2 items-center border-b border-b-gray-6 bg-app-bg px-2 py-3">
<span class="flex items-center gap-2">
<Icon class="h-5" />
<span><span class="capitalize">{mode}</span> theme</span>
</span>
{#if saved === 'fading-in' || saved === 'saved'}
<span
class="contents"
transition:fade={{ duration: 300 }}
onintrostart={setFadeOutTimer}
onoutroend={() => (saved = null)}
></span>
{/if}
{#if !active && switchToFromInactive}
<span class="flex items-center justify-end gap-2">
<button
onclick={switchToFromInactive}
class="btn btn-ghost -m-1 mr-1 p-1 text-sm transition hover:bg-gray-4 hover:text-gray-12 focus:bg-gray-4
{saved ? 'text-bold bg-gray-4 text-gray-12' : 'text-gray-10'}"
>
Activate
</button>
</span>
{:else if saved === 'fading-in' || saved === 'saved'}
<span class="flex items-center justify-end gap-2">
<span class="font-semibold text-gray-12">Saved </span>
<I.Check class="h-5 !stroke-2 text-success-9" />
</span>
{:else if saved === null && active}
<span in:fade|global class="flex items-center justify-end gap-2">
<span class="font-semibold text-gray-12">Active </span>
<I.Check class="h-5 !stroke-2 text-success-9" />
</span>
{/if}
</div>
<div class="m-4 space-y-4">
<ThemeDemo {displayTheme} />
<div class="flex flex-wrap gap-4">
{#each themes as theme (theme)}
{@const chosen = preference.name === theme.name && preference.scheme === theme.scheme}
{#if showOppositeScheme || (theme.scheme === 'light' && mode === 'day') || (theme.scheme === 'dark' && mode === 'night')}
<div>
<button
data-theme={theme.name}
class="{theme.scheme} flex h-10 w-10 overflow-hidden rounded-badge border border-gray-6"
onclick={() => save(theme)}
onmouseenter={() => (hovered = theme)}
onmouseleave={() => (hovered = null)}
aria-label={`Choose ${theme.name} theme`}
>
<span class="h-full flex-1 bg-app-bg"></span>
<span class="h-full flex-1 bg-accent-9"></span>
</button>
{#if active && chosen}
<I.Dot class="-mb-8 -mt-2 h-10 w-10 fill-accent-9 stroke-accent-9" />
{/if}
</div>
{/if}
{/each}
</div>
<label class="mt-4 flex items-center justify-end gap-2">
<input type="checkbox" bind:checked={wantsToShowOppositeScheme} disabled={hasToShowOppositeScheme} />
<span>Show {mode === 'day' ? 'Dark' : 'Light'} Themes</span>
</label>
</div>
</div>
// 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);
};
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);
};
import { tick } from 'svelte';
import { storeMode, storeTheme } from './storeTheme';
import { setThemeOnDoc, type ModeApplied, type Theme, setSystemSchemeOnDoc } from './themeUtils';
export type InitialTheme = {
systemScheme: 'light' | 'dark';
mode: 'fixed_day' | 'fixed_night' | 'sync_system';
themeDay: Theme;
themeNight: Theme;
};
/**
* stores theme_[day|night]_[name|scheme] and theme_sync_mode in document.cookies
*
* calculates the applied theme according to those values
*
* applies the theme onto the document as data-theme="{{ Theme }}" and class="light" | class="dark"
*
* updates the theme if necessary when the user changes their system settings
*
* provides an optional view transition when changing the theme
*/
export class ThemeController {
#initializedOnClient = $state() as boolean;
#systemScheme = $state() as 'light' | 'dark';
#mode = $state() as 'fixed_day' | 'fixed_night' | 'sync_system';
#themeDay = $state() as Theme;
#themeNight = $state() as Theme;
#modeApplied: ModeApplied = $derived(
this.#mode === 'fixed_day'
? 'day'
: this.#mode === 'fixed_night'
? 'night'
: this.#systemScheme === 'light'
? 'day'
: 'night',
);
#themeApplied = $derived(this.#modeApplied === 'day' ? this.#themeDay : this.#themeNight);
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);
};
});
}
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);
}
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, { animate: false });
}
get themeDay() {
return this.#themeDay;
}
set themeDay(theme) {
this.setTheme({ kind: 'day', theme, animate: false });
}
get themeNight() {
return this.#themeNight;
}
set themeNight(theme) {
this.setTheme({ kind: 'night', theme, animate: false });
}
get modeApplied() {
return this.#modeApplied;
}
get themeApplied() {
return this.#themeApplied;
}
#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)',
},
);
});
}
}
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:
And none of the downsides:
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:
.light {
--blue-5: 205.6deg 100% 88%;
}
.dark {
--blue-5: 206.9deg 100% 22.7%;
}
[data-theme='adaptive-theme'] {
--info-5: var(--blue-5);
}
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.
<html lang="en" data-theme="adaptive-theme" class="light">
...
<p class="border-info-5 border">Hi</p>
...
</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:
[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;
}
<!-- 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>
<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:
.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.
: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:
: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
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:
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 Count | System Sync | Kind | Examples |
---|---|---|---|
2 | No | Day / Night switch [Source] | Svelte, React |
2 | Yes | Day / Night / System select [Source] | Tailwind, Supabase, LinkedIn |
3+ | No | Fixed theme picker | DaisyUI, Rust Book, Gitlab |
3+ | Yes | Day / 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:
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.
- Component reactivity:
or$state()
writable()
- Permanent storage: Either
Cookies
orLocalStorage
- 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.
export type SystemScheme = 'light' | 'dark';
export type Mode = 'fixed_day' | 'fixed_night' | 'sync_system';
export type ModeApplied = 'day' | 'night';
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:
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
.
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' }
// }
};
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.
// 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.
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.
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.
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
.
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);
};
"generate:theme_utils": "./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
<!doctype html>
<html lang="en" data-theme="amethyst" class="dark">
<head>
<!-- theme -->
<script src="/themeUtils.js"></script>
<script>
initTheme();
</script>
...
</head>
...
</html>
<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.
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.
#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 });
}
::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
Avoid
Do you have an even better way? I'd love it hear it! Share it in the GitHub discussions !