Theme Toggle
pnpm dlx shadcn@latest add …/r/theme-toggle.jsonAn accessible React dark mode toggle for Next.js that reveals the new theme from the button with a View Transition: a circle, ink, a wave or a page turn.
- Circle
- Shrink
- Ink
- Tide
- Page turn
- Fade
- Wipe right
- Wipe down
- Feather
- Curtain
- Diagonal
- Random
import { ThemeToggle } from "@/components/ui/theme-toggle"
// Inside next-themes' ThemeProvider (shadcn's dark mode setup)
export function SiteHeader() {
return (
<header>
<ThemeToggle reveal="ink" speed="medium" />
</header>
)
}"use client";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
type RevealOption,
type ThemeRevealSpeed,
ThemeToggle,
} from "@/components/ui/theme-toggle";
import { useId, useState } from "react";
const reveals: { reveal: RevealOption; label: string }[] = [
{ reveal: "circle", label: "Circle" },
{ reveal: "shrink", label: "Shrink" },
{ reveal: "ink", label: "Ink" },
{ reveal: "tide", label: "Tide" },
{ reveal: "page-turn", label: "Page turn" },
{ reveal: "fade", label: "Fade" },
{ reveal: "wipe-right", label: "Wipe right" },
{ reveal: "wipe-down", label: "Wipe down" },
{ reveal: "feather", label: "Feather" },
{ reveal: "curtain", label: "Curtain" },
{ reveal: "diagonal", label: "Diagonal" },
{ reveal: "random", label: "Random" },
];
const speeds: { speed: ThemeRevealSpeed; label: string }[] = [
{ speed: "quick", label: "Quick · 450ms" },
{ speed: "medium", label: "Medium · 700ms" },
{ speed: "slow", label: "Slow · 1000ms" },
];
/*
* Fills the preview: the speed setting in the top-right corner, the toggles in a compact grid in the middle.
* An empty row at the bottom, as tall as the setting's row, keeps the grid truly centred.
*/
export default function ThemeToggleReveals() {
const id = useId();
const [speed, setSpeed] = useState<ThemeRevealSpeed>("medium");
return (
<div className="flex w-full flex-col gap-6 self-stretch">
<div className="-mt-6 -mr-6 flex items-center justify-end gap-2">
<label htmlFor={`${id}-speed`} className="text-sm text-muted-foreground">
Speed
</label>
<Select value={speed} onValueChange={(value) => setSpeed(value as ThemeRevealSpeed)}>
<SelectTrigger id={`${id}-speed`} size="sm" className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
{speeds.map((option) => (
<SelectItem key={option.speed} value={option.speed}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-1 items-center justify-center">
<ul className="grid w-fit grid-cols-3 gap-x-3 gap-y-5 sm:grid-cols-4 lg:grid-cols-6">
{reveals.map(({ reveal, label }) => (
<li key={label} className="flex flex-col items-center gap-1.5">
<ThemeToggle reveal={reveal} speed={speed} aria-describedby={`${id}-${label}`} />
<span id={`${id}-${label}`} className="text-xs whitespace-nowrap text-muted-foreground">
{label}
</span>
</li>
))}
</ul>
</div>
<div aria-hidden className="-mb-6 h-(--size-control-sm)" />
</div>
);
}Installation
Install the dependencies.
$ pnpm add motion next-themesAdd the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.
:root { --size-control-sm: 2rem; --size-control-md: 2.25rem; --size-control-lg: 2.5rem; --size-icon-sm: 0.875rem; --size-icon-md: 1rem; --size-icon-lg: 1.25rem; --size-roll-fade: 0.25em; --size-roll-fade-x: 0.5em; --duration-instant: 80ms; --duration-fast: 150ms; --duration-base: 220ms; --duration-slow: 350ms; --duration-slower: 500ms; --z-overlay: 1200; --z-modal: 1300; --z-popover: 1400; --z-toast: 1500; --z-tooltip: 1600; --focus-ring-width: 2px; --focus-ring-offset: 2px; --opacity-disabled: 0.5; --ease-spring: linear(0, 0.157, 0.48, 0.77, 0.97, 1.07, 1.1, 1.09, 1.05, 1.02, 1, 0.995, 1); --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 40%, transparent); } .dark { --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 60%, transparent); } @theme inline { --animate-popover-in: popover-in var(--duration-fast) cubic-bezier(0.22, 1, 0.36, 1) both; --animate-popover-out: popover-out var(--duration-instant) cubic-bezier(0.55, 0, 1, 0.45) both; --animate-collapsible-open: collapsible-open var(--duration-slow) cubic-bezier(0.22, 1, 0.36, 1); --animate-collapsible-close: collapsible-close var(--duration-base) cubic-bezier(0.55, 0, 1, 0.45) both; --color-destructive-foreground: var(--destructive-foreground); --color-overlay: var(--overlay); @keyframes popover-in { from { opacity: 0; filter: blur(6px); transform: scale(0.96); } to { opacity: 1; filter: blur(0); transform: scale(1); } } @keyframes popover-out { from { opacity: 1; filter: blur(0); transform: scale(1); } to { opacity: 0; filter: blur(6px); transform: scale(0.96); } } @keyframes collapsible-open { from { height: 0; opacity: 0; filter: blur(6px); } to { height: var(--collapsible-height); opacity: 1; filter: blur(0); } } @keyframes collapsible-close { from { height: var(--collapsible-height); opacity: 1; filter: blur(0); } to { height: 0; opacity: 0; filter: blur(6px); } } } @media (prefers-reduced-motion: reduce) { :root { --duration-instant: 1ms; --duration-fast: 1ms; --duration-base: 1ms; --duration-slow: 1ms; --duration-slower: 1ms; } }Add the motion presets.
import type { Transition } from "motion/react"; /* * Motion tokens for JavaScript animations (motion/react). * Durations and easings mirror the CSS tokens in src/styles/tokens.css; * `pnpm check:tokens` fails if the two drift apart. */ /** Durations in seconds. Mirrors --duration-* in tokens.css. */ export const duration = { instant: 0.08, fast: 0.15, base: 0.22, slow: 0.35, slower: 0.5, } as const; /** Cubic-bezier easings. Mirrors --ease-* in tokens.css. */ export const ease = { out: [0.22, 1, 0.36, 1], in: [0.55, 0, 1, 0.45], inOut: [0.65, 0, 0.35, 1], } as const; /** * Named springs: the library's motion voice. * - snappy: quick, almost no overshoot. Toggles, tabs, small controls. * - smooth: slow and settled. Panels, sidebars, page sections. * - bouncy: playful overshoot. Rare, for moments of delight. */ export const spring = { snappy: { type: "spring", stiffness: 520, damping: 38, mass: 1 }, smooth: { type: "spring", stiffness: 120, damping: 20, mass: 1 }, bouncy: { type: "spring", stiffness: 300, damping: 15, mass: 1 }, } as const satisfies Record<string, Transition>; export type SpringName = keyof typeof spring; /** * The blur (owner, 2026-10-15): content that appears, leaves or swaps sharpens in from a 6px blur as it * fades, instead of only fading. Pair it with the movement the pattern already has. Not for fills, * highlights, hover or anything full-screen (those still fade), and with reduced motion it's opacity only. * The CSS keyframes in tokens.css use the same 6px (checked by `pnpm check:tokens`). */ export const blur = { hidden: "blur(6px)", shown: "blur(0px)" } as const; /** Swapping content in place (Loading Button, Copy Button): the new rises 6px from blurred; the old lifts away blurring */ export const swap = { initial: { opacity: 0, filter: blur.hidden, y: 6 }, animate: { opacity: 1, filter: blur.shown, y: 0 }, exit: { opacity: 0, filter: blur.hidden, y: -6 }, } as const; /** * The flow of a changing number (Number Ticker): a spring for the characters' moves, the width and the digits' * spin, sampled into CSS linear(), and a fade for characters coming and going. Web Animations timings, in * milliseconds. The curve is from number-flow, © 2024 Maxwell Barvian, MIT licence (its full notice is in * components/number-ticker/number-ticker.tsx). */ export const flow = { transform: { duration: 900, easing: "linear(0,.005,.019,.039,.066,.096,.129,.165,.202,.24,.278,.316,.354,.39,.426,.461,.494,.526,.557,.586,.614,.64,.665,.689,.711,.731,.751,.769,.786,.802,.817,.831,.844,.856,.867,.877,.887,.896,.904,.912,.919,.925,.931,.937,.942,.947,.951,.955,.959,.962,.965,.968,.971,.973,.976,.978,.98,.981,.983,.984,.986,.987,.988,.989,.99,.991,.992,.992,.993,.994,.994,.995,.995,.996,.996,.9963,.9967,.9969,.9972,.9975,.9977,.9979,.9981,.9982,.9984,.9985,.9987,.9988,.9989,1)", }, opacity: { duration: 450, easing: "ease-out" }, } as const satisfies Record<string, EffectTiming>;Add Button first.
Copy the source into your project.
"use client"; import { Button, type ButtonProps } from "@/components/ui/button"; import { ease, spring } from "@/lib/motion"; import { cn } from "@/lib/utils"; import { motion, type Transition, useReducedMotion } from "motion/react"; import { useTheme } from "next-themes"; import { useId, useSyncExternalStore } from "react"; import { flushSync } from "react-dom"; /* * The View Transitions API snapshots the page, the theme changes underneath, and the new theme is revealed * over the old one. Background, text and every component change together inside the moving edge. Each * reveal is a set of Web Animations keyframes for the two snapshots, built from the click point and the * screen size. Without browser support, or with reduced motion, the switch is instant. */ /** Every reveal, in the order the docs list them */ const themeReveals = [ "circle", "shrink", "ink", "tide", "page-turn", "fade", "wipe-right", "wipe-down", "feather", "curtain", "diagonal", ] as const; export type ThemeReveal = (typeof themeReveals)[number]; export type ThemeRevealSpeed = "quick" | "medium" | "slow"; /** Reveal lengths in milliseconds */ const revealSpeeds: Record<ThemeRevealSpeed, number> = { quick: 450, medium: 700, slow: 1000 }; type Point = { x: number; y: number }; /** The click point, the screen, and the radius from the click to the farthest corner */ type Stage = { origin: Point; width: number; height: number; radius: number }; /** Keyframes for the new and old snapshots; `oldOnTop` when the old one moves away to uncover the new */ type Reveal = { new?: Keyframe[]; old?: Keyframe[]; oldOnTop?: boolean }; const TAU = Math.PI * 2; const px = (value: number) => `${value.toFixed(1)}px`; const polygon = (points: Point[]) => `polygon(${points.map((p) => `${px(p.x)} ${px(p.y)}`).join(", ")})`; const circle = (r: number, o: Point) => `circle(${px(r)} at ${px(o.x)} ${px(o.y)})`; /** `count` evenly spaced keyframes, each made from its progress t (0 to 1) */ const frames = (count: number, make: (t: number) => Keyframe) => Array.from({ length: count }, (_, index) => make(index / (count - 1))); const reveals: Record<ThemeReveal, (stage: Stage) => Reveal> = { // A circle grows from the click until it passes the farthest corner circle: ({ origin, radius }) => ({ new: [{ clipPath: circle(0, origin) }, { clipPath: circle(radius, origin) }], }), // The old theme closes into the click, uncovering the new one behind it shrink: ({ origin, radius }) => ({ oldOnTop: true, old: [{ clipPath: circle(radius, origin) }, { clipPath: circle(0, origin) }], }), // A blob whose radius wobbles with two sine waves that drift as it grows. Its narrowest point is 80% of // the widest, so the end size is set for that point to pass the farthest corner. ink: ({ origin, radius }) => { const reach = (radius / 0.8) * 1.03; return { new: frames(20, (t) => ({ clipPath: polygon( Array.from({ length: 64 }, (_, index) => { const angle = (index / 64) * TAU; const phase = t * Math.PI * 1.6; const r = reach * t * (1 + 0.12 * Math.sin(3 * angle + phase) + 0.08 * Math.sin(5 * angle - phase * 1.3)); return { x: origin.x + r * Math.cos(angle), y: origin.y + r * Math.sin(angle) }; }), ), })), }; }, // A wave edge rises from below the screen to above it. The swell is largest half-way, and two harmonics // travel sideways at different speeds so it rolls like liquid rather than sliding. tide: ({ width, height }) => { const swell = height * 0.06; const margin = swell * 1.2; return { new: frames(28, (t) => { const amplitude = swell * (Math.sin(Math.PI * t) + 0.15); const level = height + margin - t * (height + 2 * margin); const phase = t * TAU * 1.2; const wave = Array.from({ length: 49 }, (_, index) => { const x = (index / 48) * width; const k = (x / width) * TAU; return { x, y: level + amplitude * (0.7 * Math.sin(1.5 * k + phase) + 0.3 * Math.sin(3.2 * k - phase * 1.4)), }; }); return { clipPath: polygon([...wave, { x: width, y: height * 2 }, { x: 0, y: height * 2 }]) }; }), }; }, // The old page turns on its left edge until it's side-on, fading over the last quarter "page-turn": ({ width }) => { const turn = (degrees: number) => `perspective(${px(width * 1.2)}) rotateY(${degrees}deg)`; return { oldOnTop: true, old: [ { transformOrigin: "left center", transform: turn(0), opacity: 1 }, { transformOrigin: "left center", transform: turn(68), opacity: 1, offset: 0.75 }, { transformOrigin: "left center", transform: turn(90), opacity: 0 }, ], }; }, fade: () => ({ new: [{ opacity: 0 }, { opacity: 1 }] }), "wipe-right": () => ({ new: [{ clipPath: "inset(0 100% 0 0)" }, { clipPath: "inset(0)" }] }), "wipe-down": () => ({ new: [{ clipPath: "inset(0 0 100% 0)" }, { clipPath: "inset(0)" }] }), // A mask 2.25 screens wide, opaque on its left 44% and clear on its right 44%, slides from right to left. // The soft band between is a quarter of the screen, and it crosses the whole screen. feather: () => { const mask = { maskImage: "linear-gradient(to right, black 44.4%, transparent 55.6%)", maskSize: "225% 100%", maskRepeat: "no-repeat", }; return { new: [ { ...mask, maskPosition: "100% 0" }, { ...mask, maskPosition: "0% 0" }, ], }; }, curtain: () => ({ new: [{ clipPath: "inset(0 50%)" }, { clipPath: "inset(0)" }] }), diagonal: ({ width, height }) => { const far = 2 * Math.max(width, height); return { new: [ { clipPath: polygon([ { x: 0, y: 0 }, { x: 0, y: 0 }, { x: 0, y: 0 }, ]), }, { clipPath: polygon([ { x: 0, y: 0 }, { x: far, y: 0 }, { x: 0, y: far }, ]), }, ], }; }, }; export type RevealOption = ThemeReveal | ThemeReveal[] | "random" | "none"; let lastRandom: ThemeReveal | undefined; /** One reveal from a name, "random" (any reveal) or a list to pick from; never the same random one twice */ function pickReveal(option: RevealOption): ThemeReveal | undefined { if (option === "none") return undefined; if (typeof option === "string" && option !== "random") return option; const pool: readonly ThemeReveal[] = option === "random" ? themeReveals : option; const fresh = pool.length > 1 ? pool.filter((name) => name !== lastRandom) : pool; const chosen = fresh[Math.floor(Math.random() * fresh.length)]; lastRandom = chosen; return chosen; } export type RevealThemeOptions = { /** The theme to switch to, as your theme provider names it */ theme: string; /** Applies the theme, for example `setTheme` from next-themes' useTheme() */ setTheme: (theme: string) => void; /** Where the reveal starts, in viewport pixels; the centre of the screen if left out */ origin?: Point; reveal?: RevealOption; speed?: ThemeRevealSpeed | number; }; /** * Switches the theme with a reveal. ThemeToggle uses it; call it yourself to switch from a menu item or a * shortcut. Returns the view transition, or nothing when the switch was instant. */ function revealTheme({ theme, setTheme, origin, reveal = "circle", speed = "medium", }: RevealThemeOptions): ViewTransition | undefined { // flushSync renders the theme provider's update straight away, so the new snapshot already has the theme const apply = () => flushSync(() => setTheme(theme)); const name = pickReveal(reveal); const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches; if (!name || reduce || typeof document.startViewTransition !== "function") { apply(); return undefined; } const root = document.documentElement; const width = window.innerWidth; const height = window.innerHeight; const from = origin ?? { x: width / 2, y: height / 2 }; const radius = Math.hypot(Math.max(from.x, width - from.x), Math.max(from.y, height - from.y)); const keyframes = reveals[name]({ origin: from, width, height, radius }); const timing: KeyframeAnimationOptions = { duration: typeof speed === "number" ? speed : revealSpeeds[speed], easing: `cubic-bezier(${ease.inOut.join(", ")})`, fill: "both", }; // While it runs: no default cross-fade, the old snapshot on top when it's the one that moves, and no CSS // transitions, so colours don't fade a second time inside the new snapshot const style = document.createElement("style"); style.textContent = [ "::view-transition-old(root),::view-transition-new(root){animation:none;mix-blend-mode:normal}", keyframes.oldOnTop ? "::view-transition-old(root){z-index:1}" : "", "*,*::before,*::after{transition:none!important}", ].join(""); document.head.appendChild(style); // The animations fill both ways so the first and last frames hold; they're cancelled once the transition // ends, or their final frame would still apply to the next switch's snapshots const running: Animation[] = []; const transition = document.startViewTransition(apply); transition.ready .then(() => { if (keyframes.new) { running.push( root.animate(keyframes.new, { ...timing, pseudoElement: "::view-transition-new(root)" }), ); } if (keyframes.old) { running.push( root.animate(keyframes.old, { ...timing, pseudoElement: "::view-transition-old(root)" }), ); } }) .catch(() => { // Skipped (another transition started); the theme has still changed }); transition.finished.finally(() => { for (const animation of running) animation.cancel(); style.remove(); }); return transition; } export type ThemeToggleProps = Omit<ButtonProps, "children" | "asChild"> & { /** How the new theme is revealed: one reveal, "random", a list to pick from at random, or "none" */ reveal?: RevealOption; /** How long the reveal takes: "quick" (450ms), "medium" (700ms), "slow" (1000ms) or milliseconds */ speed?: ThemeRevealSpeed | number; }; const noop = () => () => {}; /** Ray angles round the sun, and the two stars that twinkle in beside the moon (x, y, radius) */ const rayAngles = [0, 45, 90, 135, 180, 225, 270, 315]; const stars = [ { x: 17, y: 6.5, r: 2.4 }, { x: 21.5, y: 3.5, r: 1.4 }, ]; /** A four-point sparkle: each side curves in towards the centre */ const sparkle = ({ x, y, r }: { x: number; y: number; r: number }) => `M${x} ${y - r}Q${x} ${y} ${x + r} ${y}Q${x} ${y} ${x} ${y + r}Q${x} ${y} ${x - r} ${y}Q${x} ${y} ${x} ${y - r}Z`; /** * One drawing that morphs between a sun and a moon. Going to the moon, the rays shrink and spin away, the * sun swells on a bouncy spring to a radius of 9, and a shadow (a mask circle of radius 6.4) slides over its * top right to carve a full, centred crescent, as in Lucide's moon. Two stars then twinkle in: one in the * crescent's bite, one just outside the moon. Going back, the shadow slides off and the rays pop out one after another. * Only transforms and opacity move. It starts in its current state without animating (initial={false}), * so the server render already shows the finished drawing. */ function SunMoon({ moon, className }: { moon: boolean; className?: string }) { const reduce = useReducedMotion(); const maskId = `sun-moon-${useId().replace(/[^w-]/g, "")}`; const move = (transition: Transition): Transition => (reduce ? { duration: 0 } : transition); const stagger = (seconds: number) => (reduce ? 0 : seconds); return ( <motion.svg viewBox="0 0 24 24" fill="none" aria-hidden className={className} initial={false} animate={moon ? "moon" : "sun"} variants={{ sun: { rotate: 90 }, moon: { rotate: 0 } }} transition={move(spring.smooth)} > <mask id={maskId}> <rect width="24" height="24" fill="white" /> <motion.circle cx="12" cy="12" r="6.4" fill="black" variants={{ sun: { x: 14, y: -14 }, moon: { x: 4.5, y: -4.5 } }} transition={move(spring.snappy)} /> </mask> <motion.circle cx="12" cy="12" r="5" fill="currentColor" mask={`url(#${maskId})`} variants={{ sun: { scale: 1 }, moon: { scale: 1.8 } }} transition={move(spring.bouncy)} /> <motion.g stroke="currentColor" strokeWidth="2" strokeLinecap="round" variants={{ sun: { transition: { staggerChildren: stagger(0.035), delayChildren: stagger(0.1) } }, moon: { transition: { staggerChildren: 0 } }, }} > {rayAngles.map((angle) => ( <g key={angle} transform={`rotate(${angle} 12 12)`}> <motion.line x1="12" y1="1.75" x2="12" y2="3.75" variants={{ sun: { scale: 1, opacity: 1 }, moon: { scale: 0, opacity: 0 } }} transition={move(spring.snappy)} /> </g> ))} </motion.g> <motion.g fill="currentColor" variants={{ sun: { transition: { staggerChildren: 0 } }, moon: { transition: { staggerChildren: stagger(0.08), delayChildren: stagger(0.22) } }, }} > {stars.map((star) => ( <motion.path key={star.x} d={sparkle(star)} variants={{ sun: { scale: 0, opacity: 0, rotate: -90 }, moon: { scale: 1, opacity: 1, rotate: 0 }, }} transition={move(spring.bouncy)} /> ))} </motion.g> </motion.svg> ); } /** * A button that switches between light and dark, revealing the new theme from where it was pressed. * It uses next-themes (wrap your app in its ThemeProvider) and shows the theme you'd switch to: a moon on * light, a sun on dark, morphing between the two. Until the theme is known in the browser, CSS picks the * drawing and the label is neutral. */ function ThemeToggle({ reveal = "circle", speed = "medium", variant = "outline", size = "icon", onClick, ...props }: ThemeToggleProps) { const { resolvedTheme, setTheme } = useTheme(); const mounted = useSyncExternalStore( noop, () => true, () => false, ); const isDark = resolvedTheme === "dark"; const icon = "size-(--size-icon-md)"; return ( <Button data-slot="theme-toggle" variant={variant} size={size} aria-label={mounted ? `Switch to ${isDark ? "light" : "dark"} theme` : "Switch theme"} onClick={(event) => { onClick?.(event); if (event.defaultPrevented) return; // From the pointer, or from the button's centre when it was pressed with the keyboard const box = event.currentTarget.getBoundingClientRect(); const origin = event.detail > 0 ? { x: event.clientX, y: event.clientY } : { x: box.left + box.width / 2, y: box.top + box.height / 2 }; revealTheme({ theme: isDark ? "light" : "dark", setTheme, origin, reveal, speed }); }} {...props} > <span className="grid place-items-center"> {mounted ? ( <SunMoon moon={!isDark} className={icon} /> ) : ( <> <SunMoon moon={false} className={cn(icon, "hidden dark:block")} /> <SunMoon moon className={cn(icon, "dark:hidden")} /> </> )} </span> </Button> ); } export { revealTheme, ThemeToggle, themeReveals };Update the import paths to match your project.
Examples
With a switch
revealTheme works with any control. Here a Switch turns dark mode on and off, and the new theme grows out of it.
"use client";
import { Switch } from "@/components/ui/switch";
import { revealTheme } from "@/components/ui/theme-toggle";
import { cn } from "@/lib/utils";
import { MoonIcon, SunIcon } from "lucide-react";
import { useTheme } from "next-themes";
import { useId, useRef, useSyncExternalStore } from "react";
const noop = () => () => {};
export default function ThemeToggleSwitch() {
const id = useId();
const control = useRef<HTMLButtonElement>(null);
const { resolvedTheme, setTheme } = useTheme();
// The theme is only known in the browser; until then the switch shows off
const mounted = useSyncExternalStore(
noop,
() => true,
() => false,
);
const dark = mounted && resolvedTheme === "dark";
return (
<div className="flex items-center gap-3">
<label htmlFor={id} className="mr-2 text-sm font-medium">
Dark mode
</label>
<SunIcon
aria-hidden
className={cn(
"size-(--size-icon-md) transition-colors duration-(--duration-base)",
dark ? "text-muted-foreground" : "text-foreground",
)}
/>
<Switch
id={id}
ref={control}
checked={dark}
onCheckedChange={(checked) => {
// The new theme grows out of the switch
const box = control.current?.getBoundingClientRect();
revealTheme({
theme: checked ? "dark" : "light",
setTheme,
origin: box ? { x: box.left + box.width / 2, y: box.top + box.height / 2 } : undefined,
reveal: "circle",
});
}}
/>
<MoonIcon
aria-hidden
className={cn(
"size-(--size-icon-md) transition-colors duration-(--duration-base)",
dark ? "text-foreground" : "text-muted-foreground",
)}
/>
</div>
);
}API reference
ThemeToggle
Switches between light and dark with next-themes, so wrap your app in its ThemeProvider. Its icon morphs between a sun and a moon: the rays spin away, a shadow carves a crescent and two stars twinkle in. Also accepts every Button prop. Without View Transitions support, or with reduced motion turned on, the switch is instant.
| Prop | Type | Default | Description |
|---|---|---|---|
reveal? | ThemeReveal | ThemeReveal[] | "random" | "none" | "circle" | How the new theme appears: "circle", "shrink", "ink", "tide", "page-turn", "fade", "wipe-right", "wipe-down", "feather", "curtain" or "diagonal". "random" picks a different one each time; a list picks from that list. |
speed? | "quick" | "medium" | "slow" | number | "medium" | How long the reveal takes: 450ms, 700ms, 1000ms, or a number of milliseconds. |
variant? | Button variant | "outline" | The button style. |
size? | Button size | "icon" | The button size. |
revealTheme
The switch on its own, for a menu item or a keyboard shortcut. Returns the view transition, or undefined when the switch was instant.
| Prop | Type | Default | Description |
|---|---|---|---|
theme? | string | – | The theme to switch to, for example "dark". |
setTheme? | (theme: string) => void | – | setTheme from next-themes. |
origin? | { x: number; y: number } | – | Where the reveal starts, in viewport pixels. Defaults to the centre of the screen. |
reveal? | ThemeReveal | ThemeReveal[] | "random" | "none" | "circle" | As on ThemeToggle. |
speed? | "quick" | "medium" | "slow" | number | "medium" | As on ThemeToggle. |
Related components
An accessible React button for Next.js and Tailwind CSS with six variants, three sizes, icons and a soft tint on hover and press, built on a native button.
An accessible React switch for on/off settings, with a spring-animated thumb, built on Radix Switch.
- Ghost
- Outline
- With text
An accessible React copy to clipboard button for Next.js: the copy icon turns into a check, and screen readers hear "Copied".