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
Installation
Method 1: CLI (Recommended)
Install via the ZenBlocks CLI to automatically handle dependencies:
npx shadcn@latest add https://zenblocks-three.vercel.app/r/theme-switcher.jsonNote: The CLI does not set up the global
ThemeProvider. Please see the Configuration guide to set up dark mode support.
Method 2: Manual
-
Install Dependencies
npm install next-themes lucide-react framer-motion -
Setup Theme Provider
This component uses
next-themes. Ensure your app is wrapped in aThemeProvideras described in the Global Installation guide. -
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
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | - | Additional CSS classes |
Accessibility
- Uses
role="switch"witharia-checkedstate - Includes
aria-label="Toggle theme"for screen readers - Keyboard operable with Space and Enter keys
- High contrast in active states
- Respects
prefers-reduced-motionfor 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 colorsSize
<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-themescontext (ensureThemeProvideris 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-themespackage - Ensure
tailwind.config.tshasdarkMode: "class"enabled - Component handles hydration automatically
- Icons use Lucide React (Sun/Moon)