Components
Theme Toggler A sleek theme toggler component with smooth spring animations. Copy Markdown
Choose from three distinct animation styles to fit your application's design.
The classic pill-shaped toggle with a sliding active background.
A minimalist box variant that rotates icons on theme switch.
A sleek, modern switch-style toggle with smooth sliding animation.
A full-featured selection menu that includes Light, Dark, and System theme options.
By default, the toggler provides subtle audible feedback on click. You can control this behavior globally using the Sound Toggle component or disable it per-instance via the disableSound prop.
Next Themes Integrated : Uses useTheme hook from next-themes for reliable theme management.
Spring Animations : Uses Framer Motion for smooth transitions between states.
Micro-interactions : Subtle hover effects and icon animations.
Sound Effects : Integrated useSound hook for audible feedback with persistent mute support.
Glassmorphism : Elegant semi-transparent background and border styles.
The fastest way to get started is using the shadcn CLI.
Before using the @nurav-ui alias , add this to your components.json:
{
"registries" : {
"@nurav-ui" : "https://nurav-ui.vercel.app/r"
}
} Or skip alias setup entirely and use the direct URL below.
npx shadcn@latest add @nurav-ui/theme-toggler
Alternatively , install via direct URL (no components.json changes needed):
npx shadcn@latest add https://nurav-ui.vercel.app/r/theme-toggler.json
If you prefer manual configuration, follow these steps:
Install Dependencies Install the necessary animation, icon, and theme libraries:
npm install next-themes motion lucide-react clsx tailwind-merge Create Theme Provider Create a ThemeProvider component (e.g., components/theme-provider.tsx) to manage your application's theme:
"use client"
import * as React from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"
export function ThemeProvider ({
children ,
... props
} : React . ComponentProps < typeof NextThemesProvider>) {
return < NextThemesProvider { ... props}>{children}</ NextThemesProvider >
} Update Layout Wrap your root layout with the ThemeProvider:
import { ThemeProvider } from "@/components/theme-provider"
export default function RootLayout ({ children }) {
return (
< html lang = "en" suppressHydrationWarning >
< body >
< ThemeProvider
attribute = "class"
defaultTheme = "system"
enableSystem
disableTransitionOnChange
>
{children}
</ ThemeProvider >
</ body >
</ html >
)
} Create/Customize Component Copy the source code into your project (e.g., components/nurav-ui/ThemeToggler.tsx). You can modify this code to create your own custom toggler designs:
"use client" ;
import * as React from "react" ;
import { motion, AnimatePresence } from "motion/react" ;
import { Moon, Sun, Monitor, Check, ChevronDown } from "lucide-react" ;
import { useEffect, useState } from "react" ;
import { useTheme } from "next-themes" ;
import { cn } from "@/lib/utils" ;
import { useSound } from "@/registry/hooks/use-sound" ;
interface ThemeTogglerProps extends React . HTMLAttributes < HTMLDivElement > {
variant ?: "v1" | "v2" | "v3" | "v4" ;
disableSound ?: boolean ;
}
export function ThemeToggler ({
variant = "v1" ,
className ,
disableSound = false ,
... props
} : ThemeTogglerProps ) {
const { theme , setTheme } = useTheme ();
const { clickOn , clickOff } = useSound ();
const [ mounted , setMounted ] = useState ( false );
const [ isOpen , setIsOpen ] = useState ( false );
useEffect (() => {
setMounted ( true );
}, []);
if ( ! mounted) {
return (
< div
className = { cn (
variant === "v1"
? "w-[70px] h-[34px]"
: variant === "v2"
? "w-10 h-10"
: variant === "v3"
? "w-12 h-6"
: "w-32 h-10" ,
"rounded-full bg-neutral-100 dark:bg-neutral-800 animate-pulse" ,
className,
)}
/>
);
}
const toggleTheme = () => {
if (theme === "dark" ) {
setTheme ( "light" );
if ( ! disableSound) clickOn ();
} else {
setTheme ( "dark" );
if ( ! disableSound) clickOff ();
}
};
if (variant === "v2" ) {
return (
< button
type = "button"
onClick = {toggleTheme}
className = { cn (
"relative w-10 h-10 rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 flex items-center justify-center hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors overflow-hidden group" ,
className,
)}
>
< AnimatePresence mode = "wait" initial = { false }>
< motion.div
key = {theme}
initial = {{ y: 20 , opacity: 0 , rotate: - 45 }}
animate = {{ y: 0 , opacity: 1 , rotate: 0 }}
exit = {{ y: - 20 , opacity: 0 , rotate: 45 }}
transition = {{ duration: 0.2 , ease: "easeInOut" }}
className = "text-foreground"
>
{theme === "light" ? (
< Sun size = { 20 } className = "text-amber-500" />
) : (
< Moon size = { 20 } className = "text-indigo-400" />
)}
</ motion.div >
</ AnimatePresence >
</ button >
);
}
if (variant === "v3" ) {
return (
< button
type = "button"
onClick = {toggleTheme}
className = { cn (
"relative w-14 h-7 rounded-full bg-neutral-100 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 p-1 flex items-center transition-colors shadow-inner" ,
className,
)}
>
< motion.div
animate = {{ x: theme === "dark" ? 28 : 0 }}
transition = {{ type: "spring" , stiffness: 500 , damping: 30 }}
className = "relative w-5 h-5 rounded-full bg-white dark:bg-neutral-900 shadow-md flex items-center justify-center border border-neutral-200 dark:border-neutral-600 overflow-hidden"
>
< motion.div
animate = {{ rotate: theme === "dark" ? 0 : 90 }}
className = "absolute inset-0 flex items-center justify-center"
>
{theme === "dark" ? (
< Moon size = { 12 } className = "text-indigo-400" />
) : (
< Sun size = { 12 } className = "text-amber-500" />
)}
</ motion.div >
</ motion.div >
</ button >
);
}
if (variant === "v4" ) {
const themes = [
{ name: "Light" , value: "light" , icon: Sun },
{ name: "Dark" , value: "dark" , icon: Moon },
{ name: "System" , value: "system" , icon: Monitor },
];
return (
< div className = { cn ( "relative" , className)} { ... props}>
< button
type = "button"
onClick = {() => setIsOpen ( ! isOpen)}
className = "flex items-center gap-2 px-3 py-2 text-sm font-medium border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900 hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-all w-32 justify-between group"
>
< div className = "flex items-center gap-2 capitalize" >
{theme === "light" && < Sun size = { 14 } className = "text-amber-500" />}
{theme === "dark" && < Moon size = { 14 } className = "text-indigo-400" />}
{theme === "system" && (
< Monitor size = { 14 } className = "text-neutral-500" />
)}
< span >{theme}</ span >
</ div >
< ChevronDown
size = { 14 }
className = { cn (
"transition-transform duration-200" ,
isOpen && "rotate-180" ,
)}
/>
</ button >
< AnimatePresence >
{isOpen && (
<>
< div
className = "fixed inset-0 z-10"
onClick = {() => setIsOpen ( false )}
/>
< motion.div
initial = {{ opacity: 0 , y: 10 , scale: 0.95 }}
animate = {{ opacity: 1 , y: 5 , scale: 1 }}
exit = {{ opacity: 0 , y: 10 , scale: 0.95 }}
className = "absolute top-full left-0 z-20 w-32 p-1 mt-1 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900 shadow-xl overflow-hidden"
>
{themes. map (( t ) => (
< button
key = {t.value}
onClick = {() => {
if ( ! disableSound) {
if (t.value === "light" ) clickOn ();
else clickOff ();
}
setTheme (t.value);
setIsOpen ( false );
}}
className = { cn (
"flex items-center justify-between w-full px-2 py-1.5 text-xs rounded-md transition-colors" ,
theme === t.value
? "bg-neutral-100 dark:bg-neutral-800 text-foreground"
: "text-neutral-500 hover:bg-neutral-50 dark:hover:bg-neutral-800/50 hover:text-foreground" ,
)}
>
< div className = "flex items-center gap-2" >
< t.icon
size = { 12 }
className = { cn (
t.value === "light" &&
theme === "light" &&
"text-amber-500" ,
t.value === "dark" &&
theme === "dark" &&
"text-indigo-400" ,
)}
/>
< span >{t.name}</ span >
</ div >
{theme === t.value && (
< Check size = { 12 } className = "text-primary" />
)}
</ button >
))}
</ motion.div >
</>
)}
</ AnimatePresence >
</ div >
);
}
return (
< div
className = { cn (
"flex items-center gap-1 p-1 border border-neutral-200 dark:border-neutral-800 rounded-full w-max bg-white dark:bg-neutral-900" ,
className,
)}
{ ... props}
>
{ /* Light Mode Button */ }
< button
type = "button"
onClick = {() => {
setTheme ( "light" );
if ( ! disableSound) clickOn ();
}}
className = { cn (
"relative p-1 rounded-full flex items-center justify-center transition-colors" ,
theme === "light"
? "text-amber-600"
: "text-neutral-400 hover:text-neutral-600" ,
)}
aria-label = "Switch to light theme"
>
{theme === "light" && (
< motion.span
layoutId = "theme-toggler-registry"
className = "absolute inset-0 bg-amber-100 dark:bg-amber-900/30 rounded-full"
transition = {{ type: "spring" , bounce: 0.2 , duration: 0.6 }}
/>
)}
< Sun size = { 12 } className = "relative z-10" />
</ button >
{ /* Dark Mode Button */ }
< button
type = "button"
onClick = {() => {
setTheme ( "dark" );
if ( ! disableSound) clickOff ();
}}
className = { cn (
"relative p-1 rounded-full flex items-center justify-center transition-colors" ,
theme === "dark"
? "text-indigo-600"
: "text-neutral-400 hover:text-neutral-600" ,
)}
aria-label = "Switch to dark theme"
>
{theme === "dark" && (
< motion.span
layoutId = "theme-toggler-registry"
className = "absolute inset-0 bg-indigo-100 dark:bg-indigo-900/30 rounded-full"
transition = {{ type: "spring" , bounce: 0.2 , duration: 0.6 }}
/>
)}
< Moon size = { 12 } className = "relative z-10" />
</ button >
</ div >
);
}
Last Updated: February 25, 2026