Buttons & Toggles

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.en

New project or installation trouble? Start here →

Theme 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.

220
Active edited usageDefault props
<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.json

Installing 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

PropTypeDefaultDescription
isDarkboolean—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
durationnumber220Duration 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
classNamestring—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 clipPath animation on ::view-transition-new(root); when triggered from the keyboard, the button's center is the origin
  • Controlled mode: the state update inside onToggle is applied synchronously with flushSync in the startViewTransition callback, ensuring the snapshot happens at the right moment
  • SSR safe: when uncontrolled it only reads html.dark after 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.