caliper uidocs

Theme switch

Free

A light and dark toggle that changes at once or reveals the new theme with a circle, seam or wipe.

Installs components/ui/theme-switch.tsxDepends on @radix-ui/react-slot, lucide-reactMotion honours reduced motion
pnpm dlx shadcn@latest add https://caliperui.site/r/theme-switch.json
Instant
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.