LilyDesignSystem.Blazor.ThemePicker
0.2.0
dotnet add package LilyDesignSystem.Blazor.ThemePicker --version 0.2.0
NuGet\Install-Package LilyDesignSystem.Blazor.ThemePicker -Version 0.2.0
<PackageReference Include="LilyDesignSystem.Blazor.ThemePicker" Version="0.2.0" />
<PackageVersion Include="LilyDesignSystem.Blazor.ThemePicker" Version="0.2.0" />
<PackageReference Include="LilyDesignSystem.Blazor.ThemePicker" />
paket add LilyDesignSystem.Blazor.ThemePicker --version 0.2.0
#r "nuget: LilyDesignSystem.Blazor.ThemePicker, 0.2.0"
#:package LilyDesignSystem.Blazor.ThemePicker@0.2.0
#addin nuget:?package=LilyDesignSystem.Blazor.ThemePicker&version=0.2.0
#tool nuget:?package=LilyDesignSystem.Blazor.ThemePicker&version=0.2.0
ThemePicker (Blazor helper)
A reusable, headless Blazor theme select that loads themes dynamically at runtime from a developer-specified directory.
The control is an icon button (a bundled contrast/half-circle SVG) that opens a dropdown
listbox of the available themes, built to the WAI-ARIA Authoring
Practices listbox pattern. It is not a native <select>.
The single source of truth is spec/index.md. This file is the comprehensive user 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
- Parameters
- Events
- Custom button content
- Persistence
- Accessibility
- SSR and hydration
- Preloading for zero-flicker switching
- Multiple selects 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 (typicallywwwroot/assets/themes/). - 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 Blazor 10 host (Server, WebAssembly, Web App with mixed render modes, static SSR + interactivity) and against any theme catalog — Lily™'s 41 DaisyUI-inspired themes, NHS-aligned themes, or your own bespoke set.
The component is a direct port of the Svelte canonical
@lilydesignsystem/svelte-theme-picker. APIs and behaviour match;
only the framework idioms differ.
Install
Drop the folder into your Razor class library or app and add the
namespace import to your _Imports.razor:
@using LilyDesignSystem.Blazor.Helpers
The only runtime dependency is
Microsoft.AspNetCore.Components.Web 10.0. There is no extra NuGet
package; the helper is two source files (ThemePicker.razor +
ThemePicker.razor.cs).
Quick start
- Drop theme CSS files into a directory served by your app, e.g.
wwwroot/assets/themes/light.css,wwwroot/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.
@using LilyDesignSystem.Blazor.Helpers
<ThemePicker
Label="Theme"
ThemesUrl="/assets/themes/"
Themes="@(new []{ "light", "dark", "abyss" })"
@bind-Value="theme"
StorageKey="lily-theme" />
<p class="theme-picker-status" aria-live="polite">
Active theme: @ThemeLabel(theme)
</p>
@code {
private string theme = "";
private static string ThemeLabel(string slug) =>
string.IsNullOrEmpty(slug)
? "none"
: char.ToUpperInvariant(slug[0]) + slug[1..];
}
The status line is part of the pattern, not decoration. The closed
control is a bare icon, so nothing on screen says which theme is
active; this line is the only place a sighted user reads the current
selection back without opening the listbox. aria-live="polite"
announces changes only, staying silent on first paint. Render it visible
by default; hide it with a visually-hidden class only if the design
truly cannot spare the space. 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"], - invokes
ValueChanged("dark")(drives@bind-Value), - invokes
OnChange("dark")(one-shot side effect hook).
How it works
On every theme change the select performs four steps via a single
IJSRuntime.InvokeVoidAsync("eval", …) call:
- 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}"ondocument.documentElement. Theme CSS files match this attribute via their:root[data-theme="…"]selector. - Persist: if
StorageKeyis set, write tolocalStorage(silently swallowing private-mode / quota errors).
All four steps are gated on OnAfterRenderAsync(firstRender: true),
so static-SSR / prerender renders the markup with no DOM mutation.
Rendered markup
<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-1-list"
>
<svg class="theme-picker-icon" viewBox="0 0 16 16" aria-hidden="true">…</svg>
</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="true"
>
Light
</li>
<li
class="theme-picker-option"
id="theme-picker-1-option-1"
role="option"
aria-selected="false"
>
Dark
</li>
</ul>
</div>
- The root
<div>carries thetheme-pickerclass hook plus yourCssClass, and everything captured byAdditionalAttributesspreads onto it. - The hidden input carries
NameandValueso the control still participates in a form. The listbox itself is not a form control. - The icon is a bundled contrast/half-circle SVG (
viewBox="0 0 16 16"), not a Unicode character (reversed 2026-09-16), wrapped inaria-hidden="true". The accessible name therefore comes wholly fromLabelvia the button'saria-label— never from the icon. An emptyLabelleaves the control unnameable. - The listbox carries
hiddenwhile closed and drops it while open;aria-expandedon the button tracks the same state. The sample above is the closed state. While open,aria-activedescendanton the<ul>points at the active option, which also carriesdata-activeas a styling hook — both attributes are emitted only while open. - List and option ids come from a monotonic process-wide counter
(
theme-picker-{n}), so they are stable across re-render and safe under SSR.
The real selection lives in Value, which stays two-way bindable.
The list needs positioning CSS
The package ships no CSS, so the open <ul> is an ordinary in-flow
element and will push the rest of your page down. Give the root
position: relative and the list position: absolute — the ready-made
block is in
docs/styling.md.
Sizing the control
Because the closed control is an icon button, size it to the icon rather than to the widest theme name — and give it a floor so it stays a clear target:
.theme-picker-button {
min-inline-size: 2.25rem;
min-block-size: 2.25rem;
}
See docs/styling.md for the full hook list.
Keyboard
The component implements the WAI-ARIA APG listbox pattern itself; none of it comes free from the browser.
On the button:
| Key | Action |
|---|---|
Tab / Shift+Tab |
Move focus to / away from the button (one stop). |
Arrow Down |
Open, active option = the selected one (else index 0). |
Enter / Space |
Open, active option = the selected one (else index 0). |
Arrow Up |
Open with the last option active. |
Opening moves focus to the <ul>.
On the listbox:
| Key | Action |
|---|---|
Arrow Down |
Move the active option down one; clamps at the last (no wrap). |
Arrow Up |
Move the active option up one; clamps at the first (no wrap). |
Home |
Jump to the first option. |
End |
Jump to the last option. |
Enter / Space |
Select the active option, apply it, close, return focus to the button. |
Escape |
Close and return focus without changing the value. |
PageUp / PageDown |
Move the active option by ten; clamps at the ends. |
Tab |
Close and move on; the browser's default Tab proceeds from the picker's position. |
| Printable chars | Typeahead over the option labels, 500 ms buffer reset. A single character advances to the next match and repeating it cycles onward; differing characters refine the match from the active option. |
Pointer and focus:
- Clicking an option selects it, applies it, and closes the listbox.
- Focus leaving the root closes the listbox without changing the value.
Two clauses deviate from the canonical Svelte implementation because of
Blazor's declarative event bindings — no preventDefault on keydown,
and focusout rather than a document click listener. Both are described
in spec/index.md §6.4 and
docs/accessibility.md.
Default theme
The default theme is "light" whenever "light" appears in your
Themes list. The full resolution order on first interactive render
is:
Valueparameter (if non-empty)localStorage[StorageKey](ifStorageKeyis set and readable)prefers-color-scheme(only whenDetectFromSystemis true)DefaultValueparameter"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 each hyphen-separated word of the slug, title-cased
(e.g. "light" → "Light"); override with ThemeLabels.
Parameters
The complete table is in spec/index.md §4.1. Highlights:
| Parameter | Type | Required | Notes |
|---|---|---|---|
Label |
string |
yes | aria-label on the button AND the listbox. The button is icon-only, so this is its entire accessible name. |
ThemesUrl |
string |
yes | Trailing / is auto-added. |
Themes |
IReadOnlyList<string> |
yes | Available slugs. |
Value |
string (@bind-Value) |
no | Two-way bind for the current slug. |
DefaultValue |
string? |
no | Initial when nothing else applies. |
StorageKey |
string? |
no | localStorage persistence. |
DetectFromSystem |
bool |
no | Follow OS prefers-color-scheme on first visit; off by default. |
Name |
string |
no | name on the hidden input AND the data-lily-theme-picker discriminator on the managed <link>; defaults to "theme". |
Extension |
string |
no | Defaults to ".css". |
ThemeLabels |
IReadOnlyDictionary<string,string> |
no | Per-slug display label override. |
OnChange |
EventCallback<string> |
no | Callback fired after apply. |
ChildContent |
RenderFragment<ThemePickerContext>? |
no | Replaces the default SVG icon inside the button. It does not render options. |
CssClass |
string |
no | Extra CSS class merged into the root <div>. |
AdditionalAttributes |
Dictionary<string,object>? |
no | Unmatched attributes; spread onto the root <div>. |
There is no Placeholder parameter. It existed only to pin a native
<select>'s closed display, and there is no <select> any more.
See docs/parameters-reference.md for a field-by-field reference.
Events
| Event | Payload | When |
|---|---|---|
ValueChanged |
string |
After selection, drives @bind-Value. |
OnChange |
string |
After the select applies a new theme (post-DOM-write). |
ValueChanged is the @bind-Value half; consumers usually only
wire OnChange for analytics, cookie writes, or imperative
side-effect coordination.
Custom button content
Pass a ChildContent RenderFragment<ThemePickerContext> to replace
the default icon inside the button. It does not render the options
— the listbox is owned by the component. The fragment receives a
ThemePickerContext with { Value, Open, LabelFor }:
<ThemePicker
Label="Theme"
ThemesUrl="/assets/themes/"
Themes="@(new []{ "light", "dark", "abyss" })"
@bind-Value="theme">
<ChildContent Context="ctx">
@* A custom icon, overriding the default bundled SVG. *@
<svg class="theme-picker-glyph" aria-hidden="true"
width="18" height="18" viewBox="0 0 20 20">
<circle cx="10" cy="10" r="9" fill="none" stroke="currentColor" />
<path d="M10 1a9 9 0 0 1 0 18Z" fill="currentColor" />
</svg>
</ChildContent>
</ThemePicker>
Keep the replacement aria-hidden="true": the button's accessible name
still comes from Label, and visible text inside the button would
compete with it. Open lets the face react to the listbox state, and
LabelFor resolves a slug to its display label — so a text-plus-icon
button face is also possible:
<ChildContent Context="ctx">
<span aria-hidden="true">@ctx.LabelFor(ctx.Value) @(ctx.Open ? "▴" : "▾")</span>
</ChildContent>
To drive selection imperatively — from your own swatch grid, a keyboard
shortcut, or an OS colour-scheme listener — call SetThemeAsync(string)
on a @ref to the component. The old ctx.SetTheme callback is gone
along with the option-rendering role:
<ThemePicker @ref="select" Label="Theme" ... />
<button type="button" @onclick="@(() => select!.SetThemeAsync("dark"))">Dark</button>
@code {
private ThemePicker? select;
}
Topic guide: docs/custom-rendering.md.
Persistence
Pass a StorageKey to persist the active slug to localStorage. On
a fresh interactive 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/BlazorServerCookie/
recipe.
Accessibility
- The trigger is a
<button type="button">witharia-label="@Label",aria-haspopup="listbox",aria-expanded, andaria-controlspointing at the list. - The popup is a
<ul role="listbox" tabindex="-1">of<li role="option" aria-selected>. Focus stays on the<ul>while open; the active option is conveyed byaria-activedescendant, per the APG listbox pattern. - Arrow /
Home/End/ typeahead semantics are implemented by the component (see Keyboard); none of them come free from the browser. - The active state is exposed in three independent channels:
data-themeon the document root, theValuebinding (mirrored onto the hidden input), andaria-selectedon exactly one option. No colour-only meaning is required. - Tradeoff 1: the button is icon-only and the icon is
aria-hidden, so the accessible name rests entirely onLabel. An empty or untranslatedLabelleaves the control unnameable. - Tradeoff 2: a custom listbox has weaker assistive-technology
support than a native
<select>, which the platform renders with its own picker — behaviour varies more, especially on mobile screen readers and in virtual/browse modes.
(A former Tradeoff 3 — the glyph is a font character, not a shipped asset, so it may render at an unexpected weight or be missing — no longer applies: reversed 2026-09-16, the icon is now a bundled SVG.)
- WCAG 2.2 AAA is the target; visible focus styling is the
consumer's CSS responsibility — including a
[data-active]cue on the active option, which is never focused.
Topic guide: docs/accessibility.md.
SSR and hydration
The select compiles cleanly under every Blazor 10 hosting model.
Under static SSR no OnAfterRenderAsync fires and no DOM is
touched; the markup renders using whatever Value (or empty
string) the consumer supplies.
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/BlazorServerCookie/.
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.razor.
Multiple selects in one app
Pass a distinct Name parameter to each select. The Name is used
as both the hidden input's name (so the selects stay independent in
a form) and the discriminator on the managed <link> element
(data-lily-theme-picker="{Name}").
Example: examples/MultiplePickers.razor.
Recipes
Quick cookbook in docs/recipes.md:
- Following the OS colour scheme via
prefers-color-scheme. - Reading a theme cookie in Blazor Server 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.
- Two-way binding patterns.
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). - 404 on theme href. Check the file is served from
ThemesUrland uses the configuredExtension(defaults to.css). - Prerender mismatch. Pass a server-resolved
Value(cookie) so the SSR markup matches what the lifecycle hook will set on the client. - Theme does not persist. Confirm
StorageKeyis set and thatlocalStorageis available (not blocked by private mode). OnAfterRenderAsyncnever fires. The component is rendered with a static (non-interactive) render mode. Set@rendermode="InteractiveServer"(orInteractiveAuto) on the surrounding component.
Testing
dotnet test against the bUnit suite 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. |
AGENTS.md |
Fast-index pointer; loads the AGENTS bundle. |
AGENTS/ |
Topic-by-topic agent files. |
CLAUDE.md |
@AGENTS.md. |
ThemePicker.razor |
Razor markup. |
ThemePicker.razor.cs |
C# code-behind (partial class). |
ThemePickerTests.cs |
bUnit + xUnit spec covering every spec §7 item. |
index.md |
This file. |
docs/ |
Deep-dive topic guides. |
examples/ |
Runnable .razor files. |
CHANGELOG.md |
Version history. |
License
MIT or Apache-2.0 or GPL-2.0 or GPL-3.0 or BSD-3-Clause. Contact joel@joelparkerhenderson.com for other terms.
Lily™ and Lily Design System™ are trademarks.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Microsoft.AspNetCore.Components.Web (>= 10.0.11)
NuGet packages (1)
Showing the top 1 NuGet packages that depend on LilyDesignSystem.Blazor.ThemePicker:
| Package | Downloads |
|---|---|
|
LilyDesignSystem.Blazor.PickerBar
Lily Design System Blazor picker bar: composes theme-picker, locale-picker, text-size-picker, and share-picker into one page-header row. Headless, SSR-safe, no CSS. |
GitHub repositories
This package is not used by any popular GitHub repositories.