Theme switch
FreeA light and dark toggle that changes at once or reveals the new theme with a circle, seam or wipe.
pnpm dlx shadcn@latest add https://caliperui.site/r/theme-switch.jsonInstant
Circle
Seam
Wipe
Appearance is light.
When to use #
Use it for
- A header or settings control that flips light and dark.
- You want the change to feel like one deliberate transition.
Skip it when
- The app needs a three-way System/Light/Dark choice; use a select or segmented control.
Installation #
terminal
pnpm dlx shadcn@latest add https://caliperui.site/r/theme-switch.json
Adds its 3 files to components/ui/, each with its own stylesheet, and installs their packages. No account needed. The first part also adds Caliper UI’s tokens; import them once as in Installation.
Usage #
This is the code behind the preview above.
example.tsx
import { useEffect, useState } from "react" import { ThemeSwitch, type ThemeName, type ThemeReveal } from "@/components/ui/theme-switch" const reveals: { variant: ThemeReveal; caption: string }[] = [ { variant: "instant", caption: "Instant" }, { variant: "circle", caption: "Circle" }, { variant: "seam", caption: "Seam" }, { variant: "wipe", caption: "Wipe" }, ]; function applyTheme(next: ThemeName) { const root = document.documentElement; root.dataset.theme = next; root.dataset.themePreference = next; try { localStorage.setItem("caliper-theme", next); } catch { /* The appearance still changes for this visit. */ } window.dispatchEvent(new Event("caliper-theme")); } export function ThemeToggle() { const [theme, setTheme] = useState<ThemeName>("light"); useEffect(() => { const read = () => setTheme(document.documentElement.dataset.theme === "dark" ? "dark" : "light"); read(); window.addEventListener("caliper-theme", read); return () => window.removeEventListener("caliper-theme", read); }, []); return ( <div className="grid justify-items-center gap-4"> <div className="flex flex-wrap justify-center gap-6"> {reveals.map((item) => ( <div key={item.variant} className="flex flex-col items-center gap-2"> <ThemeSwitch theme={theme} variant={item.variant} onThemeChange={applyTheme} /> <span className="text-xs text-muted-foreground">{item.caption}</span> </div> ))} </div> <p role="status" className="text-sm text-muted-foreground">Appearance is {theme}.</p> </div> ); }
API reference #
ThemeSwitch
component
PropTypeDefault
theme *ThemeName—
variantThemeReveal"circle"
onThemeChange *(next: ThemeName) => void—
classNamestring—
Accessibility #
It is built on native elements and keeps aria-pressed and aria-label in step with state.
No custom key handling; Tab and the native activation keys do the work.
Motion #
It checks prefers-reduced-motion itself and settles immediately instead of travelling. Every state is still reachable.