npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@lilydesignsystem/angular-theme-picker

v0.2.0

Published

Lily Design System Angular 20 theme picker: an icon button opening an APG listbox that swaps a theme stylesheet and sets data-theme. Headless, SSR-safe, no CSS.

Readme

ThemePicker (Angular helper)

A reusable, headless Angular 20 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 comprehensive user guide. For topic deep-dives see docs/ and for working code see examples/.

Table of contents

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 control via the theme-picker class hooks — including positioning the list, which the package deliberately ships no CSS for.

The control is an icon button that opens a WAI-ARIA APG listbox, not a native <select>. That buys a compact, fully styleable trigger and costs the platform behaviour a native control gets for free; the tradeoffs are stated plainly in docs/accessibility.md.

The result is a small reusable widget that works in any Angular 20 host (Analog v1, Angular CLI app, Storybook) 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

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 dependencies are @angular/core and @angular/common (Angular 20+).

import { ThemePicker } from "./lily-design-system-angular-theme-picker";

The barrel also re-exports the optional ThemePickerIcon marker directive, the nextThemePickerId id generator, the pure helpers normaliseThemesUrl, themeHref, themeName, and matchSystemTheme, and the ChildArgs type.

Quick start

  1. Drop theme CSS files into a directory served by your app, e.g. src/assets/themes/light.css, src/assets/themes/dark.css. Each theme scopes its tokens to :root[data-theme="<slug>"] (the convention every Lily theme uses).
  2. Import the standalone ThemePicker component and render it.
import { Component, signal } from "@angular/core";
import { ThemePicker } from "./lily-design-system-angular-theme-picker";

@Component({
  selector: "app-settings",
  standalone: true,
  imports: [ThemePicker],
  template: `
    <lily-theme-picker
      #themePicker
      label="Theme"
      themesUrl="/assets/themes/"
      [themes]="themes"
      [(value)]="theme"
      storageKey="lily-theme"
    />

    <p class="theme-picker-status" aria-live="polite">
      Active theme: {{ themePicker.labelFor(theme()) }}
    </p>
  `,
})
export class Settings {
  themes = ["light", "dark", "abyss"];
  theme = signal("");
}

The status line is part of the pattern, not decoration. The closed button shows only an icon — it never names the active theme, visually or in the accessibility tree — so on its own it never tells anyone which theme is in effect. The theme-picker-status element restores that, in visible text, for sighted and screen-reader users alike; aria-live="polite" announces only changes, so it is silent on first paint and speaks once per switch. Keep it unless you have a specific reason not to, and prefer visually hiding it over deleting it. Full rationale and the opt-out: docs/accessibility.md.

You also need a little CSS before this looks right: the listbox is unpositioned by default and will push page content around when it opens. The minimum recipe is in docs/styling.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"],
  • updates the value signal,
  • emits themeChange("dark").

How it works

On every theme change the select performs four steps, in order:

  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 the resolved target element (defaults to document.documentElement). Theme CSS files match this attribute via their :root[data-theme="…"] selector.
  4. Persist + notify: if storageKey is set, write to localStorage (silently swallowing private-mode errors); then emit themeChange with the slug.

All four steps are SSR-safe — the component only mutates the DOM inside an effect() guarded by typeof document !== "undefined".

Default theme

The default theme is "light" whenever "light" appears in your themes array. The full resolution order on first effect run is:

  1. value input (if non-empty)
  2. localStorage[storageKey] (if storageKey is set and readable)
  3. matchSystemTheme(themes) — only if detectFromSystem is true
  4. defaultValue input
  5. "light" (if present in themes)
  6. themes[0]
  7. "" — nothing is applied; the select waits for user interaction

Step 3 is the mirror of locale-picker's detectFromNavigator, in the same slot: value > storage > detection > defaultValue > fallback. Set detectFromSystem and the select reads matchMedia("(prefers-color-scheme: dark)") and resolves it to "dark" or "light" — but only if that slug is in your themes, and only on a first visit, since storage sits above it. It is a default, not an override. matchSystemTheme is exported if you want the same resolution server-side or in a test.

The select never displays the word "default". Option labels default to each hyphen-separated word of the slug title-cased (e.g. "light" → "Light", "high-contrast" → "High Contrast"); override with themeLabels. That rule is the exported themeName, the mirror of locale-picker's localeName — import it rather than re-deriving it when you build a sibling affordance.

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>
  <div class="theme-picker-tooltip" role="tooltip" id="{tooltipId}" hidden>{label}</div>

  <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"
      data-active
    >
      Light
    </li>
    <li
      class="theme-picker-option"
      id="theme-picker-1-option-1"
      role="option"
      aria-selected="false"
    >
      Dark
    </li>
  </ul>
</div>

Notes on the pieces:

  • The root is a <div>, not a form control. className lands here, after theme-picker.
  • The hidden input carries the value so the control still participates in a surrounding <form>. Its name also discriminates the managed <link>, so two selects on one page need two distinct name values.
  • The button is icon-only. Its icon — a bundled outline SVG (not a Unicode glyph; reversed 2026-09-16) — is aria-hidden, which makes aria-label the button's entire accessible name. Pass a good one.
  • The listbox carries hidden while closed, and aria-activedescendant only while open.
  • Options carry two different states: aria-selected="true" is the theme in effect; data-active is where the keyboard is pointing. They are usually different options mid-navigation, and should be styled differently.
  • Ids come from an incrementing module counter (nextThemePickerId()), so they are stable, unique per instance, and identical on the server and the client — which is what keeps hydration and the ARIA wiring intact.

Read the active theme from [(value)] or the themeChange output. See docs/styling.md for the positioning recipe, and docs/accessibility.md for the tradeoffs and how to surface the active theme name.

Inputs

The complete table is in spec/index.md §4.1. Highlights:

| Input | Type | Required | Notes | | -------------- | ------------------------ | -------- | ------------------------------------------------------------------------------------- | | label | string | yes | aria-label on both the button and the listbox. The button's entire accessible name. | | themesUrl | string | yes | Trailing / is auto-added. | | themes | string[] | yes | Available slugs. | | value | string (model) | no | Two-way bind for the current slug. | | defaultValue | string | no | Initial when nothing else applies. | | storageKey | string | no | localStorage persistence. | | name | string | no | Hidden-input name and 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; also what the typeahead matches. | | className | string | no | Extra class on the root <div>. |

See docs/props-reference.md for a field-by-field reference.

Outputs

| Output | Payload | When | | ------------- | -------- | ------------------------------------------------------------ | | valueChange | string | Implicit on the value model signal — drives [(value)]. | | themeChange | string | After the select applies a new theme (post-DOM-write). |

Custom rendering

There is one rendering escape hatch: a projected <ng-template> that replaces the icon inside the trigger button.

<lily-theme-picker label="Theme" [themesUrl]="url" [themes]="themes">
  <ng-template let-args>{{ args.labelFor(args.value) }}</ng-template>
</lily-theme-picker>

The template receives the ChildArgs context — { value, open, labelFor } — as both $implicit and named properties. The component queries it with contentChild(TemplateRef), so any projected <ng-template> matches. The optional exported ThemePickerIcon marker directive (<ng-template lilyThemePickerIcon let-args>) gives typed let- variables under strictTemplates; it changes nothing at runtime.

The template does not render options. The listbox, its options, and the whole ARIA and keyboard contract stay component-owned — that is the point of the helper. The most common use is swapping the bundled default icon for a different inline SVG you control.

If you need a different control shape entirely (swatch grid, segmented control), build it against the pure helpers and the behavioural contract in spec/index.md §5 rather than fighting the template.

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/analog-cookie/ recipe.

Accessibility

  • The control is a custom WAI-ARIA APG listbox. Every role, state, focus move, and keystroke is implemented by the component — nothing is inherited from a native control.
  • The button carries aria-haspopup="listbox", aria-expanded, and aria-controls; the <ul> carries role="listbox" and, while open, aria-activedescendant; each <li> carries role="option" and aria-selected.
  • The active state is exposed in four independent channels: aria-selected, the hidden input's value, data-theme on the target, and the value model signal. 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 the <ul>, since the list holds focus while open.

Three tradeoffs are worth knowing before you adopt this, all covered honestly in docs/accessibility.md:

  1. The button is icon-only, so aria-label is its entire accessible name — a poor label makes the control unusable to screen-reader and voice-control users, and no automated check will catch it.
  2. A custom listbox has weaker and less consistent assistive-tech support than a native <select>, which gets platform-native behaviour (including the mobile picker) for free. This is a real regression, not a neutral difference.
  3. The default glyph may render at an odd weight, render as tofu, or be missing entirely depending on platform font coverage.

The closed button also never names the active theme, which is why the status-region pattern in the quick start is the default rather than an optional extra.

Topic guide: docs/accessibility.md.

Keyboard

On the button:

| Key | Action | | ------------------- | ------------------------------------------------------------------- | | Tab / Shift+Tab | Move focus to / away from the button. | | Enter / Space | Open the listbox with the selected option active (index 0 if none). | | Arrow Down | Same as Enter / Space. | | Arrow Up | Open the listbox with the last option active. |

Opening moves focus to the <ul>; the active option is conveyed by aria-activedescendant, never by focusing an <li>.

On the listbox:

| Key | Action | | ----------------- | ---------------------------------------------------------------------- | | Arrow Down | Active option down one. Clamps at the last — no wrap. | | Arrow Up | Active option up one. Clamps at the first — no wrap. | | Home / End | First / last option becomes active. | | Enter / Space | Select the active option, apply it, close, return focus to the button. | | Escape | Close and return focus to the button; the value is not changed. | | 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 chars | Typeahead over the display labels; 500 ms buffer. A single character advances to the next match and repeats cycle; differing characters refine. |

Pointer and focus: clicking an option selects and applies it; clicking outside the root closes the listbox; focus leaving the root closes it.

SSR and hydration

The select compiles cleanly under Angular SSR (Analog v1 + Nitro, Angular CLI's @angular/ssr). On the server no effect() writes DOM (guarded by typeof document !== "undefined"), so the markup renders using whatever value the consumer supplies via the input.

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/analog-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 in index.html:

<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.component.ts.

Multiple pickers in one app

Pass a distinct name input 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}"), so two selects sharing a name will fight over the same stylesheet link.

Example: examples/multiple-pickers.component.ts.

Recipes

Quick cookbook in docs/recipes.md:

  • Following the OS colour scheme via prefers-color-scheme.
  • Reading a theme cookie in Analog before render.
  • Migrating from a localStorage-only select to a cookie-backed one.
  • Loading themes from a CDN.
  • Cache-busting via extension.
  • Synchronising theme across multiple tabs.

Troubleshooting

See docs/troubleshooting.md. Common pitfalls:

  • The list pushes the page around when it opens. The package ships no positioning CSS. Add position: relative on .theme-picker and position: absolute on .theme-picker-list — see docs/styling.md.
  • The list never closes. An unconditional display rule on .theme-picker-list overrides the hidden attribute. Scope it with :not([hidden]).
  • CSS does not switch. Check that each theme file scopes its rules to :root[data-theme="<slug>"] (not :root alone). Otherwise the first-loaded theme leaks across.
  • 404 on theme href. Check the file is served from themesUrl and uses the configured extension (defaults to .css).
  • SSR mismatch warning. 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).
  • [(value)] = … doesn't compile. The model name is value, not the legacy value decorator name. Make sure the consumer field is a WritableSignal<string> (not a plain string).

Testing

pnpm test under a vitest + jsdom + @angular/core/testing TestBed setup exercises every numbered acceptance criterion in spec/index.md §7 (§7.1–§7.24), including the full APG keyboard contract and the accessibility-hardening clauses. Each test name starts with the clause it covers.

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. | | theme-picker.component.ts | The component implementation. | | theme-picker.component.spec.ts | vitest suite covering every spec §7 item. | | index.ts | Re-export barrel. | | index.md | This file. | | docs/ | Deep-dive topic guides. | | examples/ | Runnable Angular component files. | | CHANGELOG.md | Version history. |

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.