Theme Toggle

A single-button theme switcher. Cycles through light, dark, and (optionally) system with a soft icon swap driven by Motion. Persists to localStorage and applies the choice to <html> as both a .dark class (for Tailwind v4's dark: variant) and data-theme="dark" (for the craft-bits token system).

Customize
Style
md

Installation

npx shadcn@latest add https://craftbits.dev/r/theme-toggle.json

Usage

Uncontrolled — the button owns its own state and persists to localStorage["theme"]:

import { ThemeToggle } from "@craft-bits/core";
 
<ThemeToggle defaultTheme="light" />

Include the system mode in the cycle:

<ThemeToggle
  defaultTheme="system"
  modes={["light", "dark", "system"]}
/>

Controlled — pair theme with onThemeChange and your store owns persistence:

const [theme, setTheme] = useState<"light" | "dark">("light");
 
<ThemeToggle theme={theme} onThemeChange={setTheme} />

Understanding the component

  1. Hydration-safe mount guard. The button reads nothing from the DOM or localStorage during render. A mounted boolean flips inside useEffect, and only then is the persisted value read and the icon's aria-label switched to the resolved theme. The pre-mount markup uses defaultTheme, so the SSR HTML matches the first client paint exactly.
  2. Cycle order is a prop. modes defaults to ["light", "dark"] — the safest two-state toggle. Pass ["light", "dark", "system"] (or any subset, in any order) to opt the user into a longer cycle.
  3. Two style hooks at once. When the toggle resolves the theme, it toggles a .dark class on <html> and writes data-theme="dark" (or "light"). The class hook drives Tailwind v4's dark: variant; the attribute hook drives our [data-theme="dark"] token overrides. Consumers can use either — both stay in sync.
  4. System mode is live. When currentTheme === "system", the button subscribes to (prefers-color-scheme: dark) via matchMedia and re-applies the resolved theme whenever the OS preference flips. The listener is torn down in the effect's cleanup.
  5. Icon swap, not path tween. A sun ray-burst, a crescent moon, and a monitor outline have wildly different topologies. We render one motion.path and swap its d string with a key change — Motion's per-key initial/animate scales the new glyph in from 0.6 and fades it from 0. Same SPRINGS.snap as the button's tap response, so the icon flip and the press share a tempo.

Variants

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

Three-mode cycle:

<ThemeToggle
  defaultTheme="system"
  modes={["light", "dark", "system"]}
/>

Disabled — drops the tap gesture and the click handler:

<ThemeToggle disabled />

Localized labels:

<ThemeToggle
  labels={{
    light: "Aktivera mörkt läge",
    dark: "Aktivera ljust läge",
  }}
/>

Props

PropTypeDefaultDescription
theme'light' | 'dark' | 'system'Controlled theme. Pair with onThemeChange.
defaultTheme'light' | 'dark' | 'system'first of modesUncontrolled initial theme — overridden by localStorage[storageKey] on mount if present.
modesThemeMode[]['light','dark']Cycle order. Click advances to the next entry.
onThemeChange(next: ThemeMode) => voidFired with the new mode whenever the user clicks.
storageKeystring'theme'localStorage key used in uncontrolled mode.
size'sm' | 'md' | 'lg''md'Touch-target diameter (32 / 40 / 48 px).
labelsPartial<Record<ThemeMode, string>>Override the accessible label per mode.
disabledbooleanfalseDisables the button and drops the tap gesture.
classNamestringMerged onto the rendered <button> via cn().
...restHTMLMotionProps<'button'>Any other motion.button prop.

Accessibility

  • Renders a real <button type="button"> with an aria-label that describes the next mode ("Switch to dark theme") — screen readers announce the affordance, not the current state.
  • Keyboard activation: Enter and Space cycle the theme, same as any native <button>.
  • Focus is visible via a focus-visible: ring keyed to --cb-accent.
  • Minimum hit area: every size meets Fitts ≥ 32×32 (sm = 32, md = 40, lg = 48).
  • The icon swap respects prefers-reduced-motion: Motion's spring transition collapses to an instant fade.
  • Pre-mount the label is the neutral "Toggle theme" so screen readers don't announce a phantom state before hydration finishes.
  • data-state reflects the active mode ("light" | "dark" | "system") for CSS and tests.

Credits

  • Extracted from: terminal-dreams (src/components/retro/ThemeToggle.tsx). The original was a single-purpose dark/light toggle with a hard-coded storage key and a MutationObserver reading a project-specific blocking script. craft-bits generalizes it into a CVA-driven primitive: three sizes, a configurable cycle that opts into system, Radix-style controlled / uncontrolled APIs, a localStorage key prop, and hydration-safe SSR.