Theme Toggle
Light/dark switch where the new theme grows in a circle from the click, with a sun/moon morph.
- View Transitions API
- clip-path
- next-themes
- reduced motion
The new theme grows from where you click. With reduced motion, it just switches. Uses next-themes with attribute="class", the shadcn/ui default.
Install
npx shadcn@latest add https://tcosta.dev/r/theme-toggle.jsonOr copy the files below into components/theme-toggle/. They use the shadcn/ui theme tokens and cn from @/lib/utils, and need next-themes.
Source
theme-toggle.tsx
'use client';
import type { ComponentProps, MouseEvent } from 'react';
import { cn } from '@/lib/utils';
import { MoonIcon, SunIcon } from './icons';
import { useThemeTransition } from './use-theme-transition';
type ThemeToggleProps = Omit<ComponentProps<'button'>, 'children'>;
export function ThemeToggle({ className, onClick, ...props }: ThemeToggleProps) {
const toggleTheme = useThemeTransition();
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
onClick?.(event);
if (event.defaultPrevented) return;
// Keyboard clicks have no pointer position: grow from the button's center instead.
const rect = event.currentTarget.getBoundingClientRect();
const fromPointer = event.clientX !== 0 || event.clientY !== 0;
toggleTheme({
x: fromPointer ? event.clientX : rect.left + rect.width / 2,
y: fromPointer ? event.clientY : rect.top + rect.height / 2,
});
};
return (
<button
type="button"
aria-label="Toggle theme"
onClick={handleClick}
className={cn(
'relative inline-flex size-8 items-center justify-center rounded-full text-muted-foreground transition-colors outline-none',
'hover:bg-accent hover:text-accent-foreground',
'focus-visible:ring-[3px] focus-visible:ring-ring/50',
className,
)}
{...props}
>
<SunIcon />
<MoonIcon />
</button>
);
}
use-theme-transition.ts
import { useTheme } from 'next-themes';
type Origin = { x: number; y: number };
// Radius that covers the whole viewport from the origin, so the circle ends past every corner.
function coverRadius({ x, y }: Origin) {
return Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y));
}
/** Switches light/dark, revealing the new theme in a circle that grows from `origin`. */
export function useThemeTransition() {
const { resolvedTheme, setTheme } = useTheme();
return async (origin: Origin) => {
const next = resolvedTheme === 'dark' ? 'light' : 'dark';
const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!document.startViewTransition || reduceMotion) {
setTheme(next);
return;
}
// Apply the class ourselves so the new snapshot is ready immediately; next-themes persists it.
const transition = document.startViewTransition(() => {
document.documentElement.classList.toggle('dark', next === 'dark');
setTheme(next);
});
await transition.ready;
const { x, y } = origin;
document.documentElement.animate(
{
clipPath: [
`circle(0px at ${x}px ${y}px)`,
`circle(${coverRadius(origin)}px at ${x}px ${y}px)`,
],
},
{
duration: 500,
easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
pseudoElement: '::view-transition-new(root)',
},
);
};
}
icons.tsx
import type { ComponentProps } from 'react';
import { cn } from '@/lib/utils';
function Icon({ className, children }: ComponentProps<'svg'>) {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
className={cn(
'transition-[scale,rotate,opacity] duration-300 motion-reduce:transition-none',
className,
)}
>
{children}
</svg>
);
}
// Both icons are always rendered and swapped with `dark:` classes: no hydration mismatch.
export function SunIcon() {
return (
<Icon className="scale-100 rotate-0 dark:scale-50 dark:-rotate-90 dark:opacity-0">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
</Icon>
);
}
export function MoonIcon() {
return (
<Icon className="absolute scale-50 rotate-90 opacity-0 dark:scale-100 dark:rotate-0 dark:opacity-100">
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
</Icon>
);
}
index.ts
export { ThemeToggle } from './theme-toggle';