Theme Controller

Styled theme preference controls with application-owned resolution and persistence

Theme Controller

Theme Controller styles preference controls. Core does not select a theme, read storage, listen for system changes, or mutate data-theme; those are application responsibilities.

Keep three values distinct:

  1. Preference β€” what the user chose, including system.
  2. Resolved theme β€” the concrete theme applied to data-theme, such as sunshine or moonlight.
  3. Controller state β€” which radio or button is visually active.

Selecting a styled radio does not apply a theme unless application code handles the change.

@import "@duskmoon-dev/core/components/theme-controller";

Segmented controller

Use a unique radio name and IDs for every controller instance to avoid coupling otherwise independent groups.

Segmented Theme Controller

Theme preference

Size modifiers are .theme-controller-sm and .theme-controller-lg. .theme-controller-icon reduces label padding for icon-only options; keep an accessible label on every option.

The dropdown presentation uses native details/summary state. Its radio selection remains application-owned.

Dropdown Theme Controller

Appearance
Theme preference

Use .theme-controller-dropdown-sm or .theme-controller-dropdown-lg for sizing, .theme-controller-dropdown-icon for an icon trigger, and .theme-controller-dropdown-end for end alignment.

System, light, and dark preference

The example below preserves system as the stored preference and resolves it separately. Storage failures leave the control usable, system listeners are cleaned up, and browser globals are accessed only after the environment check.

Working Preference Controller

Theme preference

Light/dark control composed with Swap

Swap supplies only the changing icon. The button and application still own theme state and aria-pressed.

Light and Dark Swap

Import Button and Swap as well when using individual component imports:

@import "@duskmoon-dev/core/components/button";
@import "@duskmoon-dev/core/components/swap";
@import "@duskmoon-dev/core/components/theme-controller";

Initialize before first paint

To avoid a flash of the wrong theme, resolve the stored preference in a small inline script in the document <head>. Keep the fallback usable when storage is unavailable.

<script>
  (() => {
    try {
      const preference = localStorage.getItem('theme-preference') || 'system';
      const resolved = preference === 'system'
        ? (matchMedia('(prefers-color-scheme: dark)').matches ? 'moonlight' : 'sunshine')
        : preference;
      document.documentElement.dataset.theme = resolved;
    } catch {
      document.documentElement.dataset.theme = 'sunshine';
    }
  })();
</script>

After hydration, initialize controller state from the preference, not from the resolved data-theme. Server-rendered applications may instead emit the resolved attribute and preference through their own request or cookie pipeline; Core does not prescribe persistence.

API

Class Purpose
.theme-controller Inline segmented radio group
.theme-controller-item Visually hidden native radio
.theme-controller-label Visible option label
.theme-controller-sm, .theme-controller-lg Segmented sizes
.theme-controller-icon Icon-only segmented spacing
.theme-controller-dropdown Native details dropdown root
.theme-controller-trigger Summary trigger
.theme-controller-menu Dropdown radio group surface
.theme-controller-dropdown-sm, .theme-controller-dropdown-lg Dropdown sizes
.theme-controller-dropdown-icon Icon-only trigger spacing
.theme-controller-dropdown-end End-aligned dropdown menu

The current built-in concrete themes are sunshine, moonlight, ocean, and forest. β€œSystem” is a preference, not a theme token set; map it to an appropriate light/dark pair such as sunshine and moonlight.