Skip to content
WANCRAFT

A.95Buttons & Controls

Theme Toggle

A light / dark toggle with a sun-to-moon morph and an optional circular reveal using the View Transitions API.

NewReactMotionSVGTailwindintermediate

Overview

Theme Toggle switches between light and dark with a small piece of choreography: the sun's rays retract and spin away while a masking circle slides in to carve the moon. Turn on the reveal and the new theme spreads out from the button as a growing circle, driven by the View Transitions API where the browser supports it and switching instantly where it does not (or when reduced motion is requested). It can be a round icon button or a sliding switch, and an optional third “system” option follows prefers-color-scheme. The component is deliberately controlled: it reports the chosen theme through onThemeChange and leaves applying and persisting it — a class, a data-theme attribute, localStorage, a cookie — to your app.

01Installation

Method 01 · Registry

CLI verified

Uses the shadcn CLI in a project initialised with shadcn init. Installs the file to components/ui/theme-toggle.tsx and adds its dependencies.

npx shadcn@latest add https://wancraft.my/r/theme-toggle.json

Method 02 · Copy and paste

  1. 1. Install the dependencies below.
  2. 2. Copy the source from the Code tab into components/ui/theme-toggle.tsx.
  3. 3. Import it and adjust props.
npm install motion
Dependencies
motion
Peer
react ≥ 18, tailwindcss ≥ 4
Added
13 Oct 2026
Updated
—

02Usage

The default configuration. Change properties above to generate a tailored example in the Usage tab.

example.tsx
import { ThemeToggle, type Theme } from "@/components/ui/theme-toggle";
import { useState } from "react";

export function ThemeToggleExample() {
  // Apply and persist the theme in your app; the toggle only reports changes.
  const [theme, setTheme] = useState<Theme>("light");
  const applyTheme = (next: Theme) => {
    setTheme(next);
    document.documentElement.dataset.theme = next;
  };

  return (
    <ThemeToggle theme={theme} onThemeChange={applyTheme} />
  );
}

03Props

PropTypeDefaultDescription
theme"light" | "dark" | "system"—Current theme (controlled). Apply it to your app yourself.
defaultThemeTheme"light"Initial theme when uncontrolled.
onThemeChange(theme: Theme) => void—Called with the picked theme. Set your class / data-theme / storage here.
includeSystembooleanfalseAdd a “system” option that follows prefers-color-scheme (cycles on the icon button, a third stop on the switch).
variant"icon" | "switch""icon"Round icon button or sliding switch.
size"sm" | "md" | "lg""md"32 / 40 / 48 px button, or matching track sizes.
revealbooleantrueCircular reveal from the button using document.startViewTransition, when available.
revealDurationnumber500Length of the reveal in ms.
spring{ stiffness: number; damping: number; mass: number }{ stiffness: 260, damping: 22, mass: 0.8 }Spring for the sun ↔ moon morph and the knob.
accentstring"#FF7448"Sun colour and focus ring.
labelstring"Dark mode" / "Theme"Accessible name of the switch (two states) or of the group / cycling button (three states).
disabledbooleanfalseDisable the toggle.
useResolvedTheme(theme)(theme: Theme) => "light" | "dark"—Exported hook that resolves “system” through prefers-color-scheme (SSR renders light).

04Features

  • 01Sun ↔ moon morph: SVG mask carves the crescent while the rays retract and rotate
  • 02Optional circular reveal from the button via the View Transitions API, feature-detected
  • 03Icon button or sliding switch, three sizes
  • 04Optional third “system” state that follows prefers-color-scheme
  • 05Controlled or uncontrolled; never touches your theme storage
  • 06Exported useResolvedTheme hook for resolving “system” in your app
motion · springinput · clickinput · keyboardinput · focus

05Accessibility

  • Two states: a button with role="switch" and aria-checked (checked = dark), named “Dark mode” by default.
  • Three states, switch variant: role="radiogroup" with three named role="radio" stops, roving tabindex and arrow keys.
  • Three states, icon variant: a cycling button whose accessible name states the current theme and the next one.
  • Glyphs are aria-hidden; the visible focus outline uses the accent colour.
  • With prefers-reduced-motion the morph is instant and the circular reveal is skipped.

06Limitations

  • The circular reveal needs View Transitions (Chromium, Safari 18+, Firefox 144+); elsewhere the theme switches instantly.
  • The reveal animates the whole page snapshot, so the page is briefly non-interactive while it plays.
  • The component does not read or write localStorage, cookies or the document class; that is your app's job (and the place to avoid a flash of the wrong theme).