Theme Switcher

A liquid, spring-loaded toggle for light and dark mode switching.

Overview

A tactile toggle switch designed for theme switching with a morphing liquid background and smooth icon transitions. Integrates seamlessly with next-themes for persistent theme management.


Features

  • Liquid morphing background animation
  • Sun/Moon icon rotation and scale
  • Spring physics for tactile feel
  • Keyboard accessible
  • Next-themes integration

Preview

Live Preview

Installation

Install via the ZenBlocks CLI to automatically handle dependencies:

npx shadcn@latest add https://zenblocks-three.vercel.app/r/theme-switcher.json

Note: The CLI does not set up the global ThemeProvider. Please see the Configuration guide to set up dark mode support.

Method 2: Manual

  1. Install Dependencies

    npm install next-themes lucide-react framer-motion
  2. Setup Theme Provider

    This component uses next-themes. Ensure your app is wrapped in a ThemeProvider as described in the Global Installation guide.

  3. Copy the Source Code

    Copy the code below into components/zenblocks/theme-switcher.tsx.

Click to expand source
"use client";

import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { motion } from "framer-motion";
import { cn } from "@/lib/utils";

export function ThemeSwitcher({ className, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) {
    const [mounted, setMounted] = useState(false);
    const { theme, setTheme } = useTheme();

    useEffect(() => {
        setMounted(true);
    }, []);

    if (!mounted) {
        return (
            <div className={cn("w-12 h-12 rounded-full bg-zinc-200 dark:bg-zinc-800 animate-pulse", className)} />
        );
    }

    const isDark = theme === "dark";

    return (
        <button
            onClick={() => setTheme(isDark ? "light" : "dark")}
            className={cn(
                "relative flex h-14 w-14 items-center justify-center rounded-full shadow-lg transition-colors duration-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500",
                isDark ? "bg-slate-900" : "bg-amber-100",
                className
            )}
            aria-label="Toggle Theme"
            role="switch"
            aria-checked={isDark}
            {...props}
        >
            <div className="relative h-8 w-8 overflow-hidden">
                <motion.div
                    initial={false}
                    animate={{
                        rotate: isDark ? 180 : 0,
                        scale: isDark ? 0.95 : 1
                    }}
                    transition={{ type: "spring", stiffness: 250, damping: 20, mass: 0.8 }}
                    className="relative h-full w-full">
                    {/* Sun Core / Moon Body */}
                    <motion.div
                        className={cn(
                            "absolute inset-0 m-auto rounded-full transition-colors duration-500",
                            isDark ? "bg-slate-200" : "bg-amber-500"
                        )}
                        animate={{
                            width: isDark ? "75%" : "50%",
                            height: isDark ? "75%" : "50%",
                        }}
                        transition={{ type: "spring", stiffness: 200, damping: 18 }}
                    />

                    {/* Mask for the Crescent Effect - Creates moon crescent */}
                    <motion.div
                        className="absolute rounded-full bg-slate-900"
                        style={{
                            width: "75%",
                            height: "75%",
                            top: "50%",
                            left: "50%",
                        }}
                        animate={{
                            x: isDark ? "-35%" : "-150%",
                            y: isDark ? "-65%" : "-50%",
                            opacity: isDark ? 1 : 0,
                        }}
                        transition={{ type: "spring", stiffness: 200, damping: 18 }}
                    />

                    {/* Sun Rays (Only visible in light mode) */}
                    <motion.div
                        className="absolute inset-0 flex items-center justify-center text-amber-500"
                        animate={{
                            opacity: isDark ? 0 : 1,
                            rotate: isDark ? 180 : 0,
                            scale: isDark ? 0.8 : 1
                        }}
                        transition={{ duration: 0.4, ease: "easeInOut" }}
                    >
                        {[...Array(8)].map((_, i) => (
                            <div
                                key={i}
                                className="absolute w-1 h-1.5 rounded-full bg-current"
                                style={{
                                    top: "0",
                                    transform: `rotate(${i * 45}deg)`,
                                    transformOrigin: "center 16px"
                                }}
                            />
                        ))}
                    </motion.div>
                </motion.div>
            </div>

            {/* Glow Effect */}
            <div className={cn(
                "absolute inset-0 rounded-full blur-xl transition-opacity duration-500 opacity-40",
                isDark ? "bg-indigo-500" : "bg-amber-400"
            )} />

        </button>
    );
}

Usage

import { ThemeSwitcher } from "@/components/zenblocks/theme-switcher";

export function Navbar() {
  return (
    <nav className="flex justify-between p-4">
      <span>Logo</span>
      <ThemeSwitcher />
    </nav>
  );
}

Props

PropTypeDefaultDescription
classNamestring-Additional CSS classes

Accessibility

  • Uses role="switch" with aria-checked state
  • Includes aria-label="Toggle theme" for screen readers
  • Keyboard operable with Space and Enter keys
  • High contrast in active states
  • Respects prefers-reduced-motion for spring animations

Customization

Animation Physics

Modify spring config in source for different feel:

transition={{ type: "spring", stiffness: 200, damping: 15 }}

Color Palette

Update Tailwind classes to match your brand:

// Change bg-amber-100 to bg-blue-100 for custom colors

Size

<ThemeSwitcher className="scale-125" />

Motion Behavior

  • Knob: Translates X position based on theme state
  • Icons: Rotate -90deg to 0deg and scale in/out
  • Background: Uses Framer Motion layout projection for smooth resizing
  • Glow: Opacity transitions for ambient effect

Performance Notes

  • Relies on next-themes context (ensure ThemeProvider is in tree)
  • Hydration guard prevents server/client mismatch
  • Spring animations are GPU-accelerated
  • State updates don't cause parent re-renders

Examples

In Header

<header className="flex items-center justify-between">
  <Logo />
  <ThemeSwitcher />
</header>

Notes

  • Requires next-themes package
  • Ensure tailwind.config.ts has darkMode: "class" enabled
  • Component handles hydration automatically
  • Icons use Lucide React (Sun/Moon)