FewTone UI
ComponentsDocs
  • Home

Getting started

  • Introduction
  • Installation
  • MCP Server
  • Smooth UI Skill
  • Roadmap

Components46

  • Accordion
  • Alert Dialog
  • Avatar
  • Badge
  • Breadcrumb
  • Button
  • Calendar
  • Card
  • Checkbox
  • Combobox
  • Command
  • Context Menu
  • Copy Button
  • Date Picker
  • Dialog
  • Dropdown Menu
  • Hover Card
  • Input
  • Input OTP
  • Loading Button
  • Marquee
  • Navigation Menu
  • Number Input
  • Number Ticker
  • Pagination
  • Popover
  • Progress
  • Radio Group
  • Scroll Area
  • Select
  • Sheet
  • Sidebar
  • Skeleton
  • Slider
  • Spinner
  • Stepper
  • Switch
  • Switcher
  • Table
  • Tabs
  • Text Effects
  • Textarea
  • Theme Toggle
  • Toast
  • Toggle Group
  • Tooltip
© 2026 FewTone · MIT licenceGitHub
  1. Components
  2. Theme Toggle

Theme Toggle

pnpm dlx shadcn@latest add …/r/theme-toggle.json

An 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>
  )
}
reveals.tsx
"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

  1. 1

    Install the dependencies.

    Package manager
    $ pnpm add motion next-themes
  2. 2

    Add the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.

    app/globals.css
    :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;
      }
    }
  3. 3

    Add the motion presets.

    lib/motion.ts
    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>;
  4. 4

    Add Button first.

  5. 5

    Copy the source into your project.

    components/ui/theme-toggle.tsx
    "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 };
  6. 6

    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.

switch.tsx
"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.

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

PropTypeDefaultDescription
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

  • Button

    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.

  • Switch

    An accessible React switch for on/off settings, with a spring-animated thumb, built on Radix Switch.

    • Ghost
    • Outline
    • With text
    Copy Button

    An accessible React copy to clipboard button for Next.js: the copy icon turns into a check, and screen readers hear "Copied".