Theme Toggle
A light/dark theme toggle button: View Transitions expand a circle out from the click point, and the Sun/Moon icons rotate through the transition.
Bring this into your project
Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.
npx webberui@latest add theme-toggle.enTheme Toggle is a React dark mode toggle button built with Motion (motion/react) and Tailwind CSS. In browsers with the View Transitions API, the new theme spreads out in a circle from the click point while the Sun and Moon icons rotate. It can run on its own, toggling the dark class on html, or plug into an existing theme setup such as next-themes.
Playground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<ThemeToggle/>
The complete example includes the preview layout and data. Install the component first, then paste this file into your project.
When to use
- The light and dark switch in the top-right of a site or docs navbar
- A user menu or settings page in a SaaS dashboard
- Personal sites such as blogs or portfolios where the switch can have some character
- Not a good fit: for a light / dark / system three-way setting, since this button only flips between two themes; use a menu or a segmented control such as Segmented Morph Toggle
Installation
npx webberui@latest add https://webberui.com/r/theme-toggle.en.jsonInstalling also writes the --wb-duration-fast and --wb-ease-out CSS variables into your global stylesheet.
Usage
import { ThemeToggle } from "@/components/ui/theme-toggle";
// Uncontrolled: toggles the html dark class automatically and writes localStorage("theme")
<ThemeToggle />
// Controlled: pair it with next-themes or another theme solution
<ThemeToggle
isDark={resolvedTheme === "dark"}
onToggle={(next) => setTheme(next ? "dark" : "light")}
/>Examples
Next.js App Router without a flash on load
Uncontrolled mode writes the choice to the theme key in localStorage, but does not read it back on load. Add an inline script to the root layout that applies the dark class before paint, so a reload does not flash the light theme first.
// app/layout.tsx
const themeScript = `
try {
if (localStorage.getItem("theme") === "dark") {
document.documentElement.classList.add("dark");
}
} catch {}
`;
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
</head>
<body>{children}</body>
</html>
);
}suppressHydrationWarning is needed because the script changes the html class before hydration. The button follows that class with a MutationObserver, so nothing else is required.
With next-themes
In controlled mode next-themes owns the theme and its storage; set attribute="class" on ThemeProvider.
"use client";
import { useTheme } from "next-themes";
import { ThemeToggle } from "@/components/ui/theme-toggle";
export function HeaderThemeToggle() {
const { resolvedTheme, setTheme } = useTheme();
return (
<ThemeToggle
isDark={resolvedTheme === "dark"}
onToggle={(next) => setTheme(next ? "dark" : "light")}
/>
);
}Tune the duration and button style
duration defaults to 220 ms; lengthen it for a more visible spread, at the cost of blurrier text during the transition. className merges onto the button, so you can change its size or drop the border.
<ThemeToggle duration={400} className="size-10 border-transparent shadow-none" />Props
| Prop | Type | Default | Description |
|---|---|---|---|
isDark | boolean | — | Controlled mode: whether the theme is currently dark. When omitted, the component manages html.dark and localStorage itself |
onToggle | (next: boolean) => void | — | Fires on toggle, returning whether it is dark after the toggle. In controlled mode, update your theme state here |
duration | number | 220 | Duration of the circular expansion (milliseconds). During the transition the whole page is a bitmap snapshot and text looks blurry, so it is deliberately kept short |
className | string | — | Extra styles |
How it works
- View Transitions support: Chrome / Edge 111+ and Safari 18+ support
document.startViewTransition; Firefox does not yet. Unsupported browsers simply switch the theme (with no expansion animation), and functionality is completely unaffected - Circular expansion: taking the click coordinates as the center and the distance to the farthest viewport corner as the radius, it runs a
clipPathanimation on::view-transition-new(root); when triggered from the keyboard, the button's center is the origin - Controlled mode: the state update inside
onToggleis applied synchronously withflushSyncin thestartViewTransitioncallback, ensuring the snapshot happens at the right moment - SSR safe: when uncontrolled it only reads
html.darkafter mounting, rendering a neutral icon before that to avoid a hydration mismatch - When the user has "reduce motion" enabled at the system level, it degrades to a direct switch and the icon transition becomes a fade in / fade out
FAQ
Does it work with the Next.js App Router?
Yes. The file already starts with "use client", so import it in a layout or Server Component page as usual. In uncontrolled mode it shows a neutral icon until mounted, avoiding a hydration mismatch.
Tailwind dark: styles do not change after toggling?
The component toggles the dark class on html, so Tailwind must use the class strategy for dark mode. In Tailwind v4 add @custom-variant dark (&:where(.dark, .dark *)); to your CSS; in v3 set darkMode: "class" in the config.
No circular spread in Firefox?
The spread relies on document.startViewTransition; browsers without it switch the theme instantly with no loss of function. With the OS "reduce motion" setting on it also switches instantly, and the icon simply fades.
Do I need to add global CSS?
No. The ::view-transition-old(root) / ::view-transition-new(root) rules that disable the browser's default cross-fade are injected by a <style> inside the component, whether you install with the CLI or copy the source.
What are the dependencies?
motion, lucide-react, clsx and tailwind-merge.
Magnetic Button
A magnetic button that is pulled towards the cursor as it approaches, with a parallax on the inner text and a spring back once the cursor leaves.
Play/Pause Morph
A graphical button that morphs smoothly between a play triangle and pause bars, with controlled and uncontrolled modes and an optional progress ring.