Theme Switcher — Svelte

Theme Switcher component — Svelte.

Theme Switcher component

Accessible theme dropdown with Preference (System), Dark, and Light groups, a unique icon per theme, and a preview panel. Same behavior as the Astro ThemeSwitcher: persists in localStorage (key theme), sets data-theme on <html>.

Live example

Live Example

Try switching themes using the theme switcher below (Svelte component):

Features

  • Preference (System) — Option to follow OS light/dark; resolves to a concrete theme when applied. When System is selected, both System and the resolved theme show the active state in the UI.
  • Preview panel — When the menu is open (viewports >480px), a preview panel is always visible. It shows a fixed “Preview” label; the theme name, swatch, and accent bar show the current theme by default and the hovered theme on hover/focus. Full-height divider between list and preview. Hidden on small viewports.
  • Groups: Preference, Dark themes, Light themes; each theme has a unique icon (from themes.ts).
  • Trigger shows the active theme name and icon.
  • Persists selection in localStorage (theme: theme id or system).
  • Full keyboard navigation (Enter/Space to open/close, Arrow keys, Home/End, Escape, Tab).

Key BEM classes and data attributes

  • theme-switcher — root; data-theme-switcher (for script or shared behavior).
  • theme-switcher__trigger — button; aria-expanded, aria-haspopup, aria-controls = menu id.
  • theme-switcher__label-wrapper — wraps label + optional icon; data-theme-label-wrapper.
  • theme-switcher__label — current theme name; data-theme-label.
  • theme-switcher__icon — chevron (open/close).
  • theme-switcher__menu — dropdown; role="menu", aria-labelledby, aria-label, tabindex="-1". Add theme-switcher__menu--open when open.
  • theme-switcher__menu-options — scrollable list of options; has a full-height border on the right (divider) on larger screens.
  • theme-switcher__group — section; role="group", aria-label (e.g. “Preference”, “Dark themes”, “Light themes”).
  • theme-switcher__group-label — section heading; role="presentation".
  • theme-switcher__option — one theme option; role="menuitemradio", aria-checked, tabindex="-1". Add theme-switcher__option--active when selected. data-theme-value (theme id or system), data-theme-type (system / dark / light), data-theme-bg, data-theme-accent, data-theme-label for preview and styling.
  • theme-switcher__option-icon — icon inside option.
  • theme-switcher__preview — preview panel; data-theme-preview. On open, set aria-hidden="false" when viewport >480px.
  • theme-switcher__preview-title — fixed “Preview” label.
  • theme-switcher__preview-header — theme name; data-theme-preview-label.
  • theme-switcher__preview-swatch-wrap, theme-switcher__preview-swatch — swatch; data-theme-preview-swatch. Set background (e.g. from data-theme-bg) for the hovered/current theme.
  • theme-switcher__preview-accent — accent bar; data-theme-preview-accent. Set background from data-theme-accent.

Theme utilities

Use these (from src/utils/theme.ts or your port) to apply and display themes:

  • applyTheme(value) — Sets data-theme on html and saves to localStorage (theme). Use a theme id (e.g. github-dark-classic) or system. Dispatches rizzo-theme-change.
  • getThemeLabel(value) / getThemeInfo(value) — Display name for a theme id or system.
  • getStoredTheme() — Returns current value in localStorage (theme id or system).
  • resolveSystemTheme() — Resolves OS preference to a concrete theme id (dark → default dark, light → default light).
  • Constants: THEME_SYSTEM, DEFAULT_THEME_DARK, DEFAULT_THEME_LIGHT.

Theme IDs: run npx rizzo-css theme or see Theming – Available themes. Listen for rizzo-theme-change to sync other UI when the theme changes.

ThemeIcon

To show the same icon as the switcher elsewhere (e.g. theme pages, cards), use <ThemeIcon themeId="github-dark-classic" size=24 />. Props: themeId, optional size (default 24), optional class.

Example: theme icons

GitHub Dark Classic · Sunflower · Shades of Purple

Structure example (simplified)

svelte svelte
<div class="theme-switcher" data-theme-switcher>
  <button
    type="button"
    class="theme-switcher__trigger"
    aria-expanded={open}
    aria-haspopup="true"
    aria-controls="theme-menu"
    aria-label="Select theme"
    onclick={() => (open = !open)}
  >
    <span class="theme-switcher__label-wrapper" data-theme-label-wrapper>
      <span class="theme-switcher__label" data-theme-label>{currentLabel}</span>
    </span>
    <ChevronDown class="theme-switcher__icon" width={16} height={16} />
  </button>
  <div
    class="theme-switcher__menu"
    class:theme-switcher__menu--open={open}
    id="theme-menu"
    role="menu"
    aria-label="Theme selection"
    aria-hidden={!open}
    tabindex="-1"
  >
    <div class="theme-switcher__menu-options">
      <div class="theme-switcher__group" role="group" aria-label="Preference">
        <div class="theme-switcher__group-label" role="presentation">Preference</div>
        <div
          class="theme-switcher__option"
          class:theme-switcher__option--active={storedTheme === 'system'}
          role="menuitemradio"
          aria-checked={storedTheme === 'system'}
          data-theme-value="system"
          data-theme-type="system"
          onclick={() => selectTheme('system')}
        >System</div>
      </div>
      <div class="theme-switcher__group" role="group" aria-label="Dark themes">
        <div class="theme-switcher__group-label" role="presentation">Dark</div>
        {#each darkThemes as theme}
          <div
            class="theme-switcher__option"
            class:theme-switcher__option--active={storedTheme === theme.value}
            role="menuitemradio"
            aria-checked={storedTheme === theme.value}
            data-theme-value={theme.value}
            data-theme-bg={theme.bg}
            data-theme-accent={theme.accent}
            data-theme-label={theme.label}
            onclick={() => selectTheme(theme.value)}
            onmouseenter={() => (previewTheme = theme.value)}
          >{theme.label}</div>
        {/each}
      </div>
      <!-- Light group similar -->
    </div>
    <div class="theme-switcher__preview" data-theme-preview aria-hidden={!open}>
      <div class="theme-switcher__preview-title">Preview</div>
      <div class="theme-switcher__preview-header" data-theme-preview-label>{previewLabel}</div>
      <div class="theme-switcher__preview-swatch-wrap">
        <div class="theme-switcher__preview-swatch" data-theme-preview-swatch style="background: {previewBg}"></div>
      </div>
      <div class="theme-switcher__preview-accent" data-theme-preview-accent style="background: {previewAccent}"></div>
    </div>
  </div>
</div>

Keyboard navigation

  • Enter or Space — Open or close menu
  • ArrowDown — Open menu and focus first option
  • ArrowUp — Open menu and focus last option
  • ArrowDown / ArrowUp — Move between options
  • Home / End — First or last option
  • Enter or Space — Select focused theme
  • Escape — Close menu
  • Tab — Close menu and tab to next element

Implementing in Svelte

  • Open state: e.g. let open = $state(false). Toggle theme-switcher__menu--open on the menu and set aria-expanded and aria-hidden accordingly.
  • Current theme: Read from getStoredTheme() or your store; set currentLabel via getThemeLabel(storedTheme) (use resolved theme when stored value is system). Mark the matching option with theme-switcher__option--active.
  • Select theme: On option click/keyboard select, call applyTheme(value) (or set data-theme on document.documentElement and localStorage.setItem('theme', value)). Close menu and restore focus to trigger.
  • Preview on hover: Track previewTheme (theme id or null). On option mouseenter/focus, set it to that option’s data-theme-value; on mouseleave/blur from the list, set to null (show current). Update preview label, swatch background, and accent from the hovered option’s data-theme-* or from theme config. When previewTheme is null, show the current (resolved) theme in the preview.
  • System preference: Listen for prefers-color-scheme and, when stored value is system, call applyTheme('system') again to re-resolve and update the document.
  • Focus trap: When open, focus first option (or keep focus on trigger); trap Tab inside menu; on close, focus trigger.

Full Astro Theme Switcher documentation — implementation details and keyboard behavior.

← Back to Svelte components