lily-design-system-svelte-theme-picker
v0.1.1
Published
Lily Design System Svelte 5 theme picker: an icon button opening an APG listbox that swaps a theme stylesheet and sets data-theme. Headless, SSR-safe, no CSS.
Maintainers
Readme
ThemePicker (Svelte helper)
A reusable, headless Svelte 5 theme picker — an icon button that opens a WAI-ARIA APG listbox — that loads themes dynamically at runtime from a developer-specified directory.
The single source of truth is spec/index.md. This file is the human-readable guide. For topic deep-dives see docs/ and for working code see examples/.
Table of contents
- Why this exists
- Install
- Quick start
- How it works
- Rendered markup
- Keyboard
- Default theme
- Props
- Custom button rendering
- Persistence
- Accessibility
- SSR and hydration
- Preloading for zero-flicker switching
- Multiple pickers in one app
- Recipes
- Troubleshooting
- Testing
Why this exists
Most theme selects couple selection, persistence, and styling into one opinionated widget. This one splits the contract cleanly:
- Authors drop theme CSS files (e.g.
light.css,dark.css) into a directory served by the app. - This component owns selection, dynamic loading, persistence, and accessibility.
- Consumers own the visual style of the select via the
theme-pickerclass hook.
The result is a small reusable widget that works in any Svelte 5 host (SvelteKit, plain Vite, Astro, Storybook) and against any theme catalog — Lily™'s 41 DaisyUI-inspired themes, NHS-aligned themes, or your own bespoke set.
Install
The directory is published as a folder-style import. Consumers either
copy it into their project or wire it as a workspace dependency. The
only runtime dependency is svelte ≥ 5.
import ThemePicker from "./lily-design-system-svelte-theme-picker/ThemePicker.svelte";
// or via the barrel:
import { ThemePicker } from "./lily-design-system-svelte-theme-picker";
import type {
Props,
ChildArgs,
} from "./lily-design-system-svelte-theme-picker";Quick start
- Drop theme CSS files into a directory served by your app, e.g.
static/assets/themes/light.css,static/assets/themes/dark.css. Each theme scopes its tokens to:root[data-theme="<slug>"](the convention every Lily theme uses). - Render the select, pointing it at the directory and listing the available slugs.
<script lang="ts">
import ThemePicker, { themeName } from "./lily-design-system-svelte-theme-picker/ThemePicker.svelte";
let theme = $state("");
</script>
<ThemePicker
label="Theme"
themesUrl="/assets/themes/"
themes={["light", "dark", "abyss"]}
bind:value={theme}
storageKey="lily-theme"
/>
<p class="theme-picker-status" aria-live="polite">
Active theme: {themeName(theme)}
</p>- Style the listbox. The package ships zero CSS, and the popup needs positioning or it will render in normal flow and shove the page down when it opens. See docs/styling.md § Positioning the listbox.
themeName is the same function the options use, so the status line
and the control cannot drift apart.
The status line is recommended, though no longer strictly compensatory.
The listbox marks the active option with aria-selected="true", so a
user who opens the control does hear which theme is current. But the
closed control is one glyph, so nothing on the page states the active
theme unless you state it. aria-live="polite" announces mutations
only, so it stays silent on first paint and speaks once per change.
Full reasoning and when to omit it:
docs/accessibility.md.
When the user picks dark, the component:
- swaps a managed
<link rel="stylesheet">in<head>to/assets/themes/dark.css, - sets
data-theme="dark"on<html>, - writes
"dark"tolocalStorage["lily-theme"], - calls the optional
onChange("dark")callback.
How it works
On every theme change the select performs four steps, in order:
- Locate or create a managed
<link rel="stylesheet" data-lily-theme-picker="{name}">indocument.head. - Swap the href to
${themesUrl}${slug}${extension}so the new theme's CSS is fetched and applied. The previous theme's CSS is unloaded when the href changes. - Set
data-theme="{slug}"on the resolved target element (defaults todocument.documentElement). Theme CSS files match this attribute via their:root[data-theme="…"]selector. - Persist + notify: if
storageKeyis set, write tolocalStorage(silently swallowing private-mode errors); then callonChange?.(slug).
All four steps are SSR-safe — the component only mutates the DOM
inside a $effect, which never runs on the server.
Rendered markup
<div class="theme-picker">
<input type="hidden" name="theme" value="dark" />
<button
type="button"
class="theme-picker-button"
aria-label="Theme"
aria-haspopup="listbox"
aria-expanded="false"
aria-controls="theme-picker-1-list"
>
<span class="theme-picker-icon" aria-hidden="true">◑</span>
</button>
<ul
class="theme-picker-list"
id="theme-picker-1-list"
role="listbox"
aria-label="Theme"
tabindex="-1"
hidden
>
<li
class="theme-picker-option"
id="theme-picker-1-option-0"
role="option"
aria-selected="false"
>
Light
</li>
<li
class="theme-picker-option"
id="theme-picker-1-option-1"
role="option"
aria-selected="true"
>
Dark
</li>
<li
class="theme-picker-option"
id="theme-picker-1-option-2"
role="option"
aria-selected="false"
>
Abyss
</li>
</ul>
</div>Why an icon button
The closed control costs one glyph of page width whether the catalog
holds three themes or forty-five with names like
united-kingdom-national-health-service-england-for-patients. A native
<select> is as wide as its longest option, or truncates it.
The glyph is U+25D1 CIRCLE WITH RIGHT HALF BLACK (◑, ◑),
exported as CIRCLE_WITH_RIGHT_HALF_BLACK. It is aria-hidden; the
accessible name comes from label.
This shape has three real costs — an icon-only control's name rests
entirely on aria-label, a hand-rolled listbox has weaker
assistive-technology support than a native <select>, and the glyph is
a font-dependent character that may substitute or fail to render. They
are set out in full, with mitigations, in
docs/accessibility.md. For some audiences
a native <select> is the better choice — read that page before
adopting this helper in an accessibility-critical context.
The hidden input keeps the control working inside a <form>. Its
name also discriminates the managed <link>, so two selects on one
page need two distinct names.
Keyboard
Follows the WAI-ARIA APG Listbox pattern. Every key is implemented by the component — none of it comes from the platform.
On the button:
| Key | Action |
| -------------------------------- | ------------------------------------- |
| Enter / Space / Arrow Down | Open with the selected option active. |
| Arrow Up | Open with the last option active. |
On the listbox (focus moves there on open):
| Key | Action |
| ------------------------- | -------------------------------------------------- |
| Arrow Down / Arrow Up | Move the active option; clamps, does not wrap. |
| Home / End | Jump to the first / last option. |
| Enter / Space | Select, apply, close, refocus the button. |
| Escape | Close and refocus without changing the theme. |
| PageUp / PageDown | Move the active option by ten; clamps. |
| Tab | Close; focus lands on the button so the default Tab proceeds from the picker's position. |
| Printable character | Typeahead over the labels; 500 ms buffer. A single character advances to the next match and repeats cycle; differing characters refine. |
Clicking an option selects it; clicking outside or moving focus out of the root closes the listbox.
Default theme
The default theme is "light" whenever "light" appears in your
themes list. The full resolution order on first mount is:
valueprop (if non-empty)localStorage[storageKey](ifstorageKeyis set and readable)matchSystemTheme(themes)(only ifdetectFromSystemis set)defaultValueprop"light"(if present inthemes)themes[0]""— nothing is applied; the select waits for user interaction
The select never displays the word "default". Option labels come from
themeName(slug), which title-cases each hyphen-separated word
("high-contrast" → "High Contrast"); override with themeLabels.
Following the OS colour scheme
Pass detectFromSystem to resolve prefers-color-scheme to the
"dark" or "light" slug on first visit — but only when that slug is
in themes, and only when neither value nor storage supplied one, so
an explicit user choice always wins:
<ThemePicker
label="Theme"
themesUrl="/assets/themes/"
themes={["light", "dark"]}
detectFromSystem
storageKey="lily-theme"
/>It resolves once, on mount. To keep following the OS setting for
the whole session, add a matchMedia listener — see
docs/recipes.md. This mirrors locale-picker's
detectFromNavigator.
Props
The complete table is in spec/index.md §4.1. Highlights:
| Prop | Type | Required | Notes |
| ------------------ | ------------------------ | -------- | ------------------------------------------------------------------------------------- |
| label | string | yes | aria-label on the button and the listbox. The control's entire accessible name. |
| themesUrl | string | yes | Trailing / is auto-added. |
| themes | string[] | yes | Available slugs. |
| value | string (bindable) | no | Two-way bind for the current slug. |
| defaultValue | string | no | Initial when nothing else applies. |
| storageKey | string | no | localStorage persistence. |
| detectFromSystem | boolean | no | Resolve prefers-color-scheme on first visit. |
| name | string | no | Hidden input name, and the managed <link> discriminator; defaults to "theme". |
| extension | string | no | Defaults to ".css". |
| target | HTMLElement \| null | no | data-theme target; defaults to <html>. |
| themeLabels | Record<string, string> | no | Per-slug display label override. |
| onChange | (slug) => void | no | Callback fired after apply. |
| children | Snippet<[ChildArgs]> | no | Replaces the button's glyph. |
There is no placeholder prop. It was removed along with the
native <select> it belonged to.
See docs/props-reference.md for a field-by-field reference.
Custom button rendering
Pass a children snippet to replace the glyph inside the trigger
button. It receives { value, open, labelFor } — and it does not
render the options; the listbox is component-owned.
<ThemePicker
label="Theme"
themesUrl="/assets/themes/"
themes={["light", "dark", "abyss"]}
bind:value={theme}
>
{#snippet children({ value, open, labelFor })}
<span aria-hidden="true">◑</span>
<span class="theme-picker-text">{labelFor(value)}</span>
<span aria-hidden="true">{open ? "▴" : "▾"}</span>
{/snippet}
</ThemePicker>Adding a visible word like this is the recommended mitigation for the icon-only naming tradeoff, at the cost of the narrow control. An inline SVG here also removes the font dependency on the Unicode glyph.
Working example: examples/custom-rendering.svelte.
Topic guide: docs/custom-rendering.md.
Persistence
Pass a storageKey to persist the active slug to localStorage. On a
fresh mount the select reads back the stored slug as part of the
initial-value resolution (§ Default theme).
Errors writing to or reading from localStorage (private mode, quota,
disabled storage) are silently swallowed — the select continues to
work in-memory.
If you need cookie-based persistence (so SSR can read the theme before
first paint), see docs/ssr.md and the
examples/sveltekit-cookie/ recipe.
Accessibility
- Built to the WAI-ARIA APG Listbox pattern: a
<button aria-haspopup="listbox">controlling a<ul role="listbox">whose active option is tracked witharia-activedescendant. aria-label={label}names both the button and the listbox.- The full keyboard contract is implemented by the component — see Keyboard.
- The active state is exposed four ways:
aria-selectedon the option,data-themeon the target, the hidden input's value, and thevaluebinding. No colour-only meaning is required. - WCAG 2.2 AAA is the target; visible focus styling — and the
active-option indicator (
[data-active],[aria-selected]) — is the consumer's CSS responsibility.
Three tradeoffs, stated plainly:
- The button is icon-only, so its accessible name rests entirely
on
aria-label. An empty or untranslatedlabelleaves the control announced as a bare "button", with no fallback. - A hand-rolled listbox has weaker assistive-technology support
than a native
<select>— particularly on mobile, where a native select opens the OS picker. For some audiences a native<select>is genuinely the better choice; the headlessThemePickercontainer inlily-design-system-svelte-headlessis one. - The glyph is a font-dependent character. It may substitute to a mismatched font, or render as a "tofu" box, or not render at all, depending on what is installed on the device.
Each has mitigations. Read
docs/accessibility.md before adopting this
helper in an accessibility-critical context.
SSR and hydration
The select compiles cleanly under @sveltejs/vite-plugin-svelte SSR.
On the server no effects run and no DOM is touched. The markup renders
with whatever value was supplied, which decides aria-selected and
the hidden input's value. Option ids come from an incrementing module
counter, so server and client agree and hydration matches.
For zero-flicker SSR, resolve the theme on the server (e.g. from a
cookie) and pass it as value. See
docs/ssr.md and
examples/sveltekit-cookie/.
Preloading for zero-flicker switching
By default the select swaps one <link> href, so the active theme is
fetched on demand. To switch instantly between themes, preload them
all yourself:
<link rel="stylesheet" href="/assets/themes/light.css" />
<link rel="stylesheet" href="/assets/themes/dark.css" />
<link rel="stylesheet" href="/assets/themes/abyss.css" />The select still mutates data-theme, and since every theme's CSS is
scoped to :root[data-theme="…"], the active rules switch
instantly with the attribute change — no network round-trip.
Topic guide: docs/preloading.md. Working
example: examples/preloaded.svelte.
Multiple pickers in one app
Pass a distinct name prop to each select. The name is used as
both the hidden input's name and the discriminator on the managed
<link> element (data-lily-theme-picker="{name}").
Example: examples/multiple-pickers.svelte.
Recipes
Quick cookbook in docs/recipes.md:
- Following the OS colour scheme via
prefers-color-scheme. - Reading a theme cookie in SvelteKit before render.
- Migrating from a
localStorage-only select to a cookie-backed one. - Building a flyout / dropdown UI around the select.
- Loading themes from a CDN.
Troubleshooting
See docs/troubleshooting.md. Common pitfalls:
- CSS does not switch. Check that each theme file scopes its rules
to
:root[data-theme="<slug>"](not:rootalone). Otherwise the first-loaded theme leaks across. - 404 on theme href. Check the file is served from
themesUrland uses the configuredextension(defaults to.css). - SSR mismatch warning. Pass a server-resolved
value(cookie) so the SSR markup matches what the effect will set on the client. - Theme does not persist. Confirm
storageKeyis set and thatlocalStorageis available (not blocked by private mode).
Testing
pnpm test under a vitest + jsdom + @testing-library/svelte setup
exercises every numbered acceptance criterion in
spec/index.md §7.
License
MIT or Apache-2.0 or GPL-2.0 or GPL-3.0 or BSD-3-Clause. Contact [email protected] for other terms.
Lily™ and Lily Design System™ are trademarks.
