Photon Design System

Theme Toggle

A three-way segment for appearance — Device, Light, and Dark. Device follows prefers-color-scheme; Light / Dark force the scheme by flipping data-mode so Photon’s light-dark() tokens resolve correctly.

Import

import { ThemeToggle } from "@photon-ai/pho-ui/components/theme-toggle";

Basic

Icon-only segments in a hairline track. The selected option is the inverse chip (same language as Tabs); unselected segments carry no fill — hover only raises the icon ink. Default preference is device.

<ThemeToggle />

Sizes

Four sizes — xs, sm, md (default), lg. Use xs in dense menus.

<ThemeToggle size="xs" />
<ThemeToggle size="sm" />
<ThemeToggle />
<ThemeToggle size="lg" />

Controlled

Drive mode / onModeChange when the parent owns appearance state. Pass a target to scope the resolved scheme to a subtree — useful in previews.

Appearance

Preference: device. Scoped to this preview card.

const [mode, setMode] = useState<"device" | "light" | "dark">("device");

<ThemeToggle mode={mode} onModeChange={setMode} />;

Avoiding a flash

Persist under localStorage (pho-mode by default). Add a blocking script in <head> so the first paint already matches the stored choice:

<script>
  const mode = localStorage.getItem("pho-mode");
  const prefersDark = matchMedia("(prefers-color-scheme: dark)").matches;
  if (mode === "dark" || ((mode === "device" || mode == null) && prefersDark))
    document.documentElement.dataset.mode = "dark";
</script>

Props

  • mode — controlled "device" · "light" · "dark"
  • defaultMode — uncontrolled initial preference (default device)
  • onModeChange — (mode) => void, fired when a segment is chosen
  • storageKey — localStorage key, or null to skip persistence (default "pho-mode")
  • target — element (or getter) that receives data-mode (default <html>)
  • size — xs · sm · md · lg (default md)
  • aria-label — name for the radiogroup (default "Theme")
  • className — merged onto the track

Also exports getThemePreference, getThemeMode, setThemeMode, resolveThemeMode, parseThemeMode, and THEME_STORAGE_KEY.

Accessibility

  • Root is a radiogroup; each segment is a radio with an aria-label (Device / Light / Dark) and aria-checked for the selection.
  • Roving tabindex — only the selected segment is tabbable; arrow keys move across the group (Home / End jump to the ends).
  • Focus uses the brand ring (focus-visible only).

Keyboard:

  • Tab — focus the selected segment
  • ← / → (or ↑ / ↓) — move selection
  • Home / End — first / last option
  • Space / Enter — activate the focused segment