Zetta v1.6

Components · App chrome

Theme switcher

The control for choosing Light, Dark or Accessibility. Ships in two shapes: a compact 32px cycle button for the app header, and an explicit selection menu when the choice should be visible. New in v1.5. It is UI only — applying the tokens is the app's theming layer's job, and no theme-transition animation is specified. This site's own header carries the live production instance.

Preview

Light

Build with the skill

No package to install — hand this to your AI to generate or migrate the component in your stack.

Prompt for your AI
Apply the Zetta "Theme switcher" component to this project using the zetta-design-md skill.

WHEN TO USE
- Migrating or fixing existing code (the common case): audit the current implementation against the `theme-switcher` spec (variants: cycle-button, menu, item, item-active) and CORRECT every deviation — replace off-spec values, don't only add new ones.
- New component: build it to the `theme-switcher` spec in this project's stack.

AUDIT & FIX (when the component already exists)
Compare it to the skill's spec and fix, don't just append: hardcoded colors/hex, off-scale radius, wrong fonts, dark/navy fills, shadows used in place of borders (or missing borders), brand lime used as a CTA or interaction (must be primary indigo), and any missing or wrong hover / focus / active / disabled / invalid state or missing 2px focus ring. Replace each with the correct Zetta token.

ALWAYS
- Pull the authoritative spec and tokens from the skill — never guess or hardcode values.
- Use this project's own component conventions and framework idioms.
- Honor the Zetta guardrails (brand = identity only, primary = interaction, shadows for overlays) and verify in Light, Dark, and Accessibility.

Anatomy & rules

Cycle button (in header), current = Light:
  ☀            ← theme-switcher-button (aria-label "Theme: Light")

Selection menu:
  ╭─────────────────────────╮
  │ ✓ ☀  Light              │  ← theme-switcher-item-active (aria-current="true")
  │   🌙  Dark              │  ← theme-switcher-item
  │   ◐  Accessibility      │
  ╰─────────────────────────╯   ← theme-switcher-menu
State Visual
Rest (cycle button) Current theme's icon, {colors.muted}
Button hover {colors.primary-bg} fill, {colors.primary} icon
Button active {colors.surface-pressed} fill
Button / item focus Focus ring ({colors.border-focus})
Menu open theme-switcher-menu visible, three themes listed
Item hover {colors.primary-bg} fill on hovered theme
Active theme {colors.primary-bg} fill, check icon prefix, aria-current="true"

When to use

  • Letting users switch between Light, Dark, and the high-contrast Accessibility theme from the header.

When not to use

Do

  • Show the current theme's icon on the cycle button and keep its aria-label in sync ("Theme: Dark").
  • When no defaultTheme is set, initialise from prefers-color-scheme (and prefers-contrast), then let the user override.
  • Prefer the menu variant when all three options should be directly visible (e.g. settings pages); prefer cycle in a dense header.
  • Persist the user's explicit choice so it survives reloads.

Don't

  • Don't convey the current theme by icon color alone — pair with the aria-label and (in menu variant) a text label.
  • Don't animate the theme swap in a way that ignores prefers-reduced-motion.
  • Theme labels (menu variant): "Light", "Dark", "Accessibility".
  • Cycle button aria-label: "Theme: [current theme]" (e.g. "Theme: Accessibility").
  • Menu aria-label: "Select theme".

Keyboard

Key Behavior
Tab Move focus to the theme-switcher control
Enter / Space Cycle variant: advance to the next theme. Menu variant: open the menu
Up / Down Arrow Menu variant: move between themes
Home / End Menu variant: jump to the first / last theme
Enter Menu variant: select the focused theme and close the menu
Escape Menu variant: close without changing the theme; focus returns to the trigger

Accessibility

  • Cycle button: a single <button> with aria-label="Theme: [current theme]"; the label updates on each change so screen readers announce the new theme. The icon is decorative (aria-hidden="true").
  • Menu semantics: role="menu" on the panel with role="menuitemradio" per theme (single choice); aria-label="Select theme" on the menu.
  • Active theme: the current theme carries aria-current="true" (and aria-checked="true" on its menuitemradio) plus a visible check icon — never color alone.
  • Announcement: on change, announce the new theme via a visually hidden aria-live="polite" region ("Theme set to Dark").
  • Initial value: when no defaultTheme is provided, honor prefers-color-scheme and prefers-contrast for the first render, then respect the user's explicit choice.
  • Focus management (menu variant): on open, focus moves to the active theme; on close, focus returns to the trigger.
  • Reduced motion: any transition when applying a theme must collapse to {motion.duration-instant} under prefers-reduced-motion: reduce.

Spec

Values and token references resolved straight from the Zetta spec.

theme-switcher-button
display
inline-flex
alignItems
center
justifyContent
center
height
32px
width
32px
borderRadius
{rounded.base}
backgroundColor
transparent
borderColor
transparent
borderWidth
1px
iconSize
{icons.sizeMap.md}
iconColor
{colors.muted}
cursor
pointer
theme-switcher-button-hover
backgroundColor
{colors.primary-bg}
iconColor
{colors.primary}
theme-switcher-button-active
backgroundColor
{colors.surface-pressed}
theme-switcher-button-focus
outline
2px solid {colors.border-focus}
outlineOffset
2px
theme-switcher-menu
backgroundColor
{colors.popover}
borderColor
{colors.hairline}
borderWidth
1px
borderRadius
{rounded.base}
shadow
{elevation.shadow.overlay}
padding
{spacing.xs}
minWidth
180px
zIndex
{elevation.z-index.popup}
theme-switcher-item
display
flex
alignItems
center
gap
{spacing.sm}
paddingX
{spacing.sm}
paddingY
{spacing.xs}
borderRadius
{rounded.sm}
fontFamily
Geist
fontSize
14px
fontWeight
400
lineHeight
1.40
textColor
{colors.ink}
iconColor
{colors.muted}
iconSize
{icons.sizeMap.md}
cursor
pointer
minHeight
32px
theme-switcher-item-hover
backgroundColor
{colors.primary-bg}
textColor
{colors.primary}
theme-switcher-item-focus
outline
2px solid {colors.border-focus}
outlineOffset
-2px
theme-switcher-item-active
backgroundColor
{colors.primary-bg}
textColor
{colors.primary}
iconColor
{colors.primary}
fontWeight
500