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.jsonUsage
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
- Hydration-safe mount guard. The button reads nothing from the DOM or
localStorageduring render. Amountedboolean flips insideuseEffect, and only then is the persisted value read and the icon'saria-labelswitched to the resolved theme. The pre-mount markup usesdefaultTheme, so the SSR HTML matches the first client paint exactly. - Cycle order is a prop.
modesdefaults 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. - Two style hooks at once. When the toggle resolves the theme, it toggles a
.darkclass on<html>and writesdata-theme="dark"(or"light"). The class hook drives Tailwind v4'sdark:variant; the attribute hook drives our[data-theme="dark"]token overrides. Consumers can use either — both stay in sync. - System mode is live. When
currentTheme === "system", the button subscribes to(prefers-color-scheme: dark)viamatchMediaand re-applies the resolved theme whenever the OS preference flips. The listener is torn down in the effect's cleanup. - Icon swap, not path tween. A sun ray-burst, a crescent moon, and a monitor outline have wildly different topologies. We render one
motion.pathand swap itsdstring with akeychange — Motion's per-keyinitial/animatescales the new glyph in from0.6and fades it from0. SameSPRINGS.snapas 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
| Prop | Type | Default | Description |
|---|---|---|---|
theme | 'light' | 'dark' | 'system' | — | Controlled theme. Pair with onThemeChange. |
defaultTheme | 'light' | 'dark' | 'system' | first of modes | Uncontrolled initial theme — overridden by localStorage[storageKey] on mount if present. |
modes | ThemeMode[] | ['light','dark'] | Cycle order. Click advances to the next entry. |
onThemeChange | (next: ThemeMode) => void | — | Fired with the new mode whenever the user clicks. |
storageKey | string | 'theme' | localStorage key used in uncontrolled mode. |
size | 'sm' | 'md' | 'lg' | 'md' | Touch-target diameter (32 / 40 / 48 px). |
labels | Partial<Record<ThemeMode, string>> | — | Override the accessible label per mode. |
disabled | boolean | false | Disables the button and drops the tap gesture. |
className | string | — | Merged onto the rendered <button> via cn(). |
...rest | HTMLMotionProps<'button'> | — | Any other motion.button prop. |
Accessibility
- Renders a real
<button type="button">with anaria-labelthat describes the next mode ("Switch to dark theme") — screen readers announce the affordance, not the current state. - Keyboard activation:
EnterandSpacecycle 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-statereflects 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 aMutationObserverreading a project-specific blocking script. craft-bits generalizes it into a CVA-driven primitive: three sizes, a configurable cycle that opts intosystem, Radix-style controlled / uncontrolled APIs, alocalStoragekey prop, and hydration-safe SSR.