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.
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
- Choosing an interface language — use Language Switcher.
- A binary on/off setting unrelated to theming — use Toggle Switch.
Do
- Show the current theme's icon on the cycle button and keep its
aria-labelin sync ("Theme: Dark"). - When no
defaultThemeis set, initialise fromprefers-color-scheme(andprefers-contrast), then let the user override. - Prefer the
menuvariant when all three options should be directly visible (e.g. settings pages); prefercyclein 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-labeland (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>witharia-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 withrole="menuitemradio"per theme (single choice);aria-label="Select theme"on the menu. - Active theme: the current theme carries
aria-current="true"(andaria-checked="true"on itsmenuitemradio) plus a visiblecheckicon — 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
defaultThemeis provided, honorprefers-color-schemeandprefers-contrastfor 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}underprefers-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