logo

Nurav UI

Community
Components

Theme Toggler

A sleek theme toggler component with smooth spring animations.Copy Markdown

Preview

Choose from three distinct animation styles to fit your application's design.

Variant V1 (Animated Pill)

The classic pill-shaped toggle with a sliding active background.

Variant V2 (Rotate & Flip)

A minimalist box variant that rotates icons on theme switch.

Variant V3 (Minimal Switch)

A sleek, modern switch-style toggle with smooth sliding animation.

Variant V4 (Select/System)

A full-featured selection menu that includes Light, Dark, and System theme options.

Features

Audible Feedback

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.

Installation

Automatic (CLI)

The fastest way to get started is using the shadcn CLI.

Before using the @nurav-ui alias, add this to your components.json:

components.json
{
  "registries": {
    "@nurav-ui": "https://nurav-ui.vercel.app/r"
  }
}

Or skip alias setup entirely and use the direct URL below.

Terminal
npx shadcn@latest add @nurav-ui/theme-toggler

Alternatively, install via direct URL (no components.json changes needed):

Terminal
npx shadcn@latest add https://nurav-ui.vercel.app/r/theme-toggler.json

Manual Setup

If you prefer manual configuration, follow these steps:

Install Dependencies

Install the necessary animation, icon, and theme libraries:

Terminal
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:

theme-provider.tsx
"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:

app/layout.tsx
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:

ThemeToggler.tsx
"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>
  );
}

Props

PropTypeDefaultDescription
variant
'v1' | 'v2' | 'v3' | 'v4''v1'The visual style of the toggler.
disableSound
booleanfalseWhen true, disables clicking sound feedback for this instance.
className
stringundefinedAdditional CSS classes for the container.

Built by Varun

Last Updated:February 25, 2026

On this page