lily-design-system-react-theme-picker
v0.1.1
Published
Lily Design System React 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 (React helper)
A reusable, headless React 19 theme picker 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
- The rendered markup
- How it works
- Keyboard
- Default theme
- Props
- Custom button glyph
- Persistence
- Following the OS colour scheme
- Exported helpers
- Accessibility
- SSR and hydration
- Preloading for zero-flicker switching
- Multiple selects in one app
- Recipes
- Troubleshooting
- Testing
Why this exists
Most theme pickers 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 React 19 host (Next.js App Router, Vite + React, Remix, Astro React islands, 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 react ≥ 19.
import { ThemePicker } from "./lily-design-system-react-theme-picker";
import type { Props, ChildArgs } from "./lily-design-system-react-theme-picker";Quick start
- Drop theme CSS files into a directory served by your app, e.g.
public/assets/themes/light.css,public/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.
"use client";
import { useState } from "react";
import {
ThemePicker,
themeName,
} from "./lily-design-system-react-theme-picker";
export function ThemePicker() {
const [theme, setTheme] = useState("");
return (
<>
<ThemePicker
label="Theme"
themesUrl="/assets/themes/"
themes={["light", "dark", "abyss"]}
value={theme}
onChange={setTheme}
storageKey="lily-theme"
/>
<p className="theme-picker-status" aria-live="polite">
Active theme: {themeName(theme)}
</p>
</>
);
}The status line is part of the pattern, not an optional extra. The
closed control is an icon button showing only a glyph — it never states
the active theme — so without this line the active theme is announced to
nobody and shown to nobody. aria-live="polite" announces mutations
only, so it stays quiet on first paint and speaks once per user change.
Keep it visible where you can (it helps sighted and cognitively-loaded
users too); if your design cannot spare the space, hide it with the
visually-hidden recipe in docs/styling.md rather
than dropping it. Full rationale:
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.
The rendered markup
The control is an icon button that opens a dropdown listbox, following the WAI-ARIA APG listbox pattern:
<div class="theme-picker">
<input type="hidden" name="theme" value="light" />
<button
type="button"
class="theme-picker-button"
aria-label="Theme"
aria-haspopup="listbox"
aria-expanded="false"
aria-controls="theme-picker-«r0»-list"
>
<span class="theme-picker-icon" aria-hidden="true">◑</span>
</button>
<ul
class="theme-picker-list"
id="theme-picker-«r0»-list"
role="listbox"
aria-label="Theme"
tabindex="-1"
hidden
>
<li
class="theme-picker-option"
id="theme-picker-«r0»-option-0"
role="option"
aria-selected="true"
data-active
>
Light
</li>
<li
class="theme-picker-option"
id="theme-picker-«r0»-option-1"
role="option"
aria-selected="false"
>
Dark
</li>
<li
class="theme-picker-option"
id="theme-picker-«r0»-option-2"
role="option"
aria-selected="false"
>
Abyss
</li>
</ul>
</div>Notes:
- The glyph is U+25D1 CIRCLE WITH RIGHT HALF BLACK (
◑,◑), exported fromThemePicker.tsxasCIRCLE_WITH_RIGHT_HALF_BLACK. It isaria-hidden, so the button's accessible name comes entirely from thelabelprop. - The hidden
<input>carries the active slug under thenameprop, so the control still submits with a surrounding form. - Ids come from React's
useId, so they are stable and hydration-safe. - While open, the listbox carries
aria-activedescendantpointing at the active option, and that option carriesdata-active. - The listbox is toggled with the
hiddenattribute. The package ships no CSS, so you supply the positioning — see docs/styling.md.
The closed button shows only the glyph; it never states the active
theme. That is why the quick start above pairs it with a
.theme-picker-status live region — pairing them is the default
pattern, and dropping the status line is the deliberate opt-out. See
docs/accessibility.md.
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 useEffect, which never runs on the server.
Keyboard
The component implements the whole contract itself; nothing is
inherited from a native <select>.
On the button:
| Key | Action |
| ------------------------------- | ------------------------------------------------------------------------------------ |
| Tab / Shift+Tab | Move focus to / from the button (one tab stop). |
| ArrowDown / Enter / Space | Open, with the active theme's option active (or the first). Focus moves to the list. |
| ArrowUp | Open with the last option active. |
On the listbox:
| Key | Action |
| ----------------------- | ----------------------------------------------------------------------------------------------- |
| ArrowDown / ArrowUp | Move the active option. Clamps at the ends — it does not wrap. |
| Home / End | Jump to the first / last option. |
| Enter / Space | Select the active option, apply it, close, and return focus to the button. |
| Escape | Close and return focus to the button, leaving the theme unchanged. |
| 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. |
| Any printable character | Typeahead over the option labels; 500 ms buffer. A single character advances to the next match and repeats cycle; differing characters refine. |
With the mouse: click an option to select it; click the button again, click outside, or move focus out of the control to close it without changing anything.
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, controlled mode)localStorage[storageKey](ifstorageKeyis set and readable)matchSystemTheme(themes)(only whendetectFromSystemis 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 default
to the slug with each hyphen-separated word title-cased
(e.g. "light" → "Light", "high-contrast" → "High Contrast");
override with themeLabels.
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. |
| themesUrl | string | yes | Trailing / is auto-added. |
| themes | string[] | yes | Available slugs. |
| value | string | no | Controlled value (omit for uncontrolled). |
| defaultValue | string | no | Initial when nothing else applies. |
| storageKey | string | no | localStorage persistence. |
| detectFromSystem | boolean | no | Resolve prefers-color-scheme on first visit; defaults to false. |
| name | string | no | Hidden input name + 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: string) => void | no | Callback fired after apply. |
| children | (args: ChildArgs) => React.ReactNode | no | Replaces the glyph inside the button. |
See docs/props-reference.md for a field-by-field reference.
Custom button glyph
Pass a children render prop to replace the half-circle glyph inside
the button. It does not render the options — the component owns
those, their ids, their ARIA state, and the keyboard contract. The
function receives { value, open, labelFor }:
<ThemePicker
label="Theme"
themesUrl="/assets/themes/"
themes={["light", "dark", "abyss"]}
value={theme}
onChange={setTheme}
>
{({ value, open, labelFor }) => (
<>
<span
className="theme-picker-swatch"
data-theme={value}
aria-hidden="true"
/>
<span aria-hidden="true">{labelFor(value)}</span>
<span aria-hidden="true">{open ? "▴" : "▾"}</span>
</>
)}
</ThemePicker>Keep custom glyph content aria-hidden — the button's accessible name
comes from label, and visible text that disagrees with it is a
"Label in Name" hazard.
Working example: examples/custom-rendering.tsx.
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/next-cookie/ recipe.
Following the OS colour scheme
Pass detectFromSystem to resolve prefers-color-scheme on a first
visit — when there is no value and nothing in storage:
<ThemePicker
label="Theme"
themesUrl="/assets/themes/"
themes={["light", "dark"]}
detectFromSystem
storageKey="lily-theme"
/>Storage beats detection, so a user who has explicitly picked a theme
keeps it even if their OS says otherwise. Detection resolves once, at
mount; to keep tracking the OS as it changes, add a matchMedia
listener and write to a controlled value — see
examples/system-preference.tsx.
This mirrors detectFromNavigator in the sibling
locale-picker helper.
Exported helpers
Besides the component, the barrel exports four pure functions:
| Export | Purpose |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| themeName(slug) | The title-casing label rule — "high-contrast" → "High Contrast". The component's internal labelFor delegates to it, so use it rather than re-deriving the rule. Mirrors localeName in locale-picker. |
| matchSystemTheme(themes) | Resolves prefers-color-scheme to a supported slug, or "" when the slug is absent or matchMedia is unavailable (SSR). Backs detectFromSystem. Mirrors matchNavigatorLanguage in locale-picker. |
| normalizeThemesUrl(url) | Ensures exactly one trailing /. |
| themeHref(url, slug, ext) | Builds the stylesheet href for a slug. |
ThemePicker.tsx also exports CIRCLE_WITH_RIGHT_HALF_BLACK, the
default button glyph (U+25D1), so a children override can re-use the
exact character.
Accessibility
- The control follows the WAI-ARIA APG listbox pattern: a button with
aria-haspopup="listbox"/aria-expanded/aria-controls, and a<ul role="listbox">that tracks the active option witharia-activedescendant. aria-label={label}names both the button and the listbox. The glyph isaria-hidden, solabelis the only accessible name the control has — it is not optional.- The component implements the full keyboard contract itself (see
Keyboard) and returns focus to the button when a
selection or
Escapecloses the list. - The active state is exposed in three independent channels:
aria-selectedon the option,data-activeon the keyboard-active option, anddata-themeon the target. No colour-only meaning is required. - WCAG 2.2 AAA is the target; visible focus styling is the consumer's CSS responsibility — for the button and for the listbox, which takes focus while open.
- A custom listbox does not get the platform-level assistive-technology
treatment a native
<select>does. Readdocs/accessibility.mdfor the honest tradeoffs before shipping.
Topic guide: docs/accessibility.md.
SSR and hydration
The select compiles cleanly under Next.js App Router (server +
client components), Remix, and Vite SSR. On the server no effects
run and no DOM is touched, so the markup renders using whatever
value the consumer supplies.
For zero-flicker SSR, resolve the theme on the server (e.g. from a
cookie via cookies() in Next.js) and pass it as value. See
docs/ssr.md and the working example in
examples/next-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.tsx.
Multiple selects 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.tsx.
Recipes
Quick cookbook in docs/recipes.md:
- Following the OS colour scheme via
prefers-color-scheme. - Reading a theme cookie in Next.js before render.
- Migrating from a
localStorage-only select to a cookie-backed one. - Positioning the dropdown listbox.
- 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 hydration mismatch. 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). - "useEffect / useState is null" or similar React errors. Confirm
the file using the select carries
"use client"at the top — selects run as client components. - The dropdown renders inline and pushes the page around. The
package ships no CSS: give the root
position: relativeand the listposition: absolute. See docs/styling.md.
Testing
pnpm test under a vitest + jsdom + @testing-library/react setup
exercises every numbered acceptance criterion in
spec/index.md §7.
Files in this directory
| File | Purpose |
| ----------------------- | ------------------------------------------------------- |
| spec/index.md | Single source of truth — API, behaviour, tests. |
| ThemePicker.tsx | The component implementation. |
| ThemePicker.test.tsx | vitest suite covering every spec §7 item. |
| index.ts | Re-export barrel. |
| index.md | This file — quick start + worked examples. |
| CHANGELOG.md | Per-version history. |
| AGENTS.md | AI-agent metadata pointer. |
| AGENTS/ | Per-topic AI-agent guides. |
| CLAUDE.md | Loads AGENTS.md. |
| docs/ | Deep-dive guides — see Documentation. |
| examples/ | Runnable React 19 examples — see Examples. |
Documentation
| Guide | Covers |
| ------------------------------------------------------ | ----------------------------------------------------------------- |
| docs/props-reference.md | Field-by-field reference for every prop. |
| docs/accessibility.md | WCAG 2.2 AAA contract, keyboard map, and the pattern's tradeoffs. |
| docs/styling.md | Class hooks, attribute hooks, positioning, baseline CSS. |
| docs/ssr.md | Next.js App Router cookies, Remix loaders, Vite SSR. |
| docs/preloading.md | Three strategies for instant theme switching. |
| docs/custom-rendering.md | Replacing the button glyph via the children render prop. |
| docs/recipes.md | Cookbook of adjacent problems. |
| docs/troubleshooting.md | Symptoms, root causes, fixes. |
Examples
| # | File | Demonstrates |
| --- | ----------------------------------------------------------- | ------------------------------------------------------ |
| 1 | basic.tsx | Minimal three-theme picker + the default status line. |
| 2 | two-way-binding.tsx | Controlled value + onChange. |
| 3 | persistence.tsx | localStorage survival across reloads. |
| 4 | custom-labels.tsx | themeLabels for i18n / display names. |
| 5 | custom-rendering.tsx | children render prop — custom button glyph. |
| 6 | preloaded.tsx | Zero-flicker switching via preloading. |
| 7 | multiple-pickers.tsx | Two selects in one page via name. |
| 8 | system-preference.tsx | Follow prefers-color-scheme. |
| 9 | lily-themes.tsx | All 41 Lily / DaisyUI themes at once. |
| 10 | next-cookie/ | Next.js App Router SSR-resolved cookie. |
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.
