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
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="LilyDesignSystem.Blazor.ThemePicker" Version="0.2.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="LilyDesignSystem.Blazor.ThemePicker" Version="0.2.0" />
                    
Directory.Packages.props
<PackageReference Include="LilyDesignSystem.Blazor.ThemePicker" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add LilyDesignSystem.Blazor.ThemePicker --version 0.2.0
                    
#r "nuget: LilyDesignSystem.Blazor.ThemePicker, 0.2.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package LilyDesignSystem.Blazor.ThemePicker@0.2.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=LilyDesignSystem.Blazor.ThemePicker&version=0.2.0
                    
Install as a Cake Addin
#tool nuget:?package=LilyDesignSystem.Blazor.ThemePicker&version=0.2.0
                    
Install as a Cake Tool

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

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 (typically wwwroot/assets/themes/).
  • This component owns selection, dynamic loading, persistence, and accessibility.
  • Consumers own the visual style of the select via the theme-picker class 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

  1. 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).
  2. 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" to localStorage["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:

  1. Locate or create a managed <link rel="stylesheet" data-lily-theme-picker="{Name}"> in document.head.
  2. 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.
  3. Set data-theme="{slug}" on document.documentElement. Theme CSS files match this attribute via their :root[data-theme="…"] selector.
  4. Persist: if StorageKey is set, write to localStorage (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 the theme-picker class hook plus your CssClass, and everything captured by AdditionalAttributes spreads onto it.
  • The hidden input carries Name and Value so 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 in aria-hidden="true". The accessible name therefore comes wholly from Label via the button's aria-label — never from the icon. An empty Label leaves the control unnameable.
  • The listbox carries hidden while closed and drops it while open; aria-expanded on the button tracks the same state. The sample above is the closed state. While open, aria-activedescendant on the <ul> points at the active option, which also carries data-active as 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:

  1. Value parameter (if non-empty)
  2. localStorage[StorageKey] (if StorageKey is set and readable)
  3. prefers-color-scheme (only when DetectFromSystem is true)
  4. DefaultValue parameter
  5. "light" (if present in Themes)
  6. Themes[0]
  7. "" — 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"> with aria-label="@Label", aria-haspopup="listbox", aria-expanded, and aria-controls pointing 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 by aria-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-theme on the document root, the Value binding (mirrored onto the hidden input), and aria-selected on 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 on Label. An empty or untranslated Label leaves 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 :root alone).
  • 404 on theme href. Check the file is served from ThemesUrl and uses the configured Extension (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 StorageKey is set and that localStorage is available (not blocked by private mode).
  • OnAfterRenderAsync never fires. The component is rendered with a static (non-interactive) render mode. Set @rendermode="InteractiveServer" (or InteractiveAuto) 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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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.

Version Downloads Last Updated
0.2.0 98 9/16/2026
0.1.0 101 9/2/2026