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
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 orsystem). - 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". Addtheme-switcher__menu--openwhen 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". Addtheme-switcher__option--activewhen selected.data-theme-value(theme id orsystem),data-theme-type(system/dark/light),data-theme-bg,data-theme-accent,data-theme-labelfor preview and styling.theme-switcher__option-icon— icon inside option.theme-switcher__preview— preview panel;data-theme-preview. On open, setaria-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. fromdata-theme-bg) for the hovered/current theme.theme-switcher__preview-accent— accent bar;data-theme-preview-accent. Set background fromdata-theme-accent.
Theme utilities
Use these (from src/utils/theme.ts or your port) to apply and display themes:
applyTheme(value)— Setsdata-themeonhtmland saves tolocalStorage(theme). Use a theme id (e.g.github-dark-classic) orsystem. Dispatchesrizzo-theme-change.getThemeLabel(value)/getThemeInfo(value)— Display name for a theme id orsystem.getStoredTheme()— Returns current value inlocalStorage(themeid orsystem).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.
GitHub Dark Classic · Sunflower · Shades of Purple
Structure example (simplified)
<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). Toggletheme-switcher__menu--openon the menu and setaria-expandedandaria-hiddenaccordingly. - Current theme: Read from
getStoredTheme()or your store; setcurrentLabelviagetThemeLabel(storedTheme)(use resolved theme when stored value issystem). Mark the matching option withtheme-switcher__option--active. - Select theme: On option click/keyboard select, call
applyTheme(value)(or setdata-themeondocument.documentElementandlocalStorage.setItem('theme', value)). Close menu and restore focus to trigger. - Preview on hover: Track
previewTheme(theme id or null). On optionmouseenter/focus, set it to that option’sdata-theme-value; onmouseleave/blur from the list, set tonull(show current). Update preview label, swatch background, and accent from the hovered option’sdata-theme-*or from theme config. WhenpreviewThemeis null, show the current (resolved) theme in the preview. - System preference: Listen for
prefers-color-schemeand, when stored value issystem, callapplyTheme('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.