Components

Animated Theme Toggle

A beautifully animated theme toggle button that transitions between sun and moon icons with smooth path animations.

Last updated on

Edit on GitHub

Basic Usage

import { AnimatedThemeToggle } from "@/components/ui/animated-theme-toggle"
 
export function AnimatedThemeToggleDemo() {
  return (
    <div className="flex items-center justify-center p-8">
      <AnimatedThemeToggle />
    </div>
  )
}

Different Sizes

import { AnimatedThemeToggle } from "@/components/ui/animated-theme-toggle"
 
export function AnimatedThemeToggleSizesDemo() {
  return (
    <div className="flex items-center justify-center gap-4 p-8">
      <AnimatedThemeToggle className="h-8 w-8" />
      <AnimatedThemeToggle className="h-10 w-10" />
      <AnimatedThemeToggle className="h-12 w-12" />
    </div>
  )
}

In Navbar

import { AnimatedThemeToggle } from "@/components/ui/animated-theme-toggle"
 
export function AnimatedThemeToggleNavbarDemo() {
  return (
    <div className="flex w-full max-w-2xl items-center justify-between rounded-lg border bg-card p-4">
      <div className="flex items-center gap-2">
        <div className="h-8 w-8 rounded-full bg-primary" />
        <span className="font-semibold text-foreground">MyApp</span>
      </div>
      <nav className="hidden items-center gap-6 md:flex">
        <a href="#" className="text-sm text-muted-foreground hover:text-foreground">
          Home
        </a>
        <a href="#" className="text-sm text-muted-foreground hover:text-foreground">
          Features
        </a>
        <a href="#" className="text-sm text-muted-foreground hover:text-foreground">
          Pricing
        </a>
        <a href="#" className="text-sm text-muted-foreground hover:text-foreground">
          About
        </a>
      </nav>
      <AnimatedThemeToggle />
    </div>
  )
}

Installation

CLI

npx shadcn@latest add "https://jolyui.dev/r/animated-theme-toggle"

Manual

Install the following dependencies:

npm install motion next-themes

Copy and paste the following code into your project. component/ui/animated-theme-toggle.tsx

"use client";
 
import { cn } from "@/lib/utils";
import { motion, useMotionValue, useTransform } from "motion/react";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { Button } from "./button";
 
export const AnimatedThemeToggle = ({
  className,
}: {
  className?: string;
}) => {
  const { theme, setTheme, resolvedTheme } = useTheme();
  const [mounted, setMounted] = useState(false);
 
  // Avoid hydration mismatch
  useEffect(() => {
    setMounted(true);
  }, []);
 
  const isDark = mounted ? resolvedTheme === "dark" : false;
 
  const toggleTheme = () => {
    setTheme(isDark ? "light" : "dark");
  };
 
  // Show a placeholder during SSR to avoid hydration mismatch
  if (!mounted) {
    return (
      <Button className={cn("px-2.5", className)} variant="outline" disabled>
        <div className="h-5 w-5" />
      </Button>
    );
  }
 
  return (
    <Button
      onClick={toggleTheme}
      className={cn("px-2.5", className)}
      variant="outline"
      aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
    >
      <SolarSwitch isDark={isDark} />
    </Button>
  );
};
 
const SolarSwitch = ({ isDark }: { isDark: boolean }) => {
  const duration = 0.7;
 
  const moonVariants = {
    checked: {
      scale: 1,
    },
    unchecked: {
      scale: 0,
    },
  };
 
  const sunVariants = {
    checked: {
      scale: 0,
    },
    unchecked: {
      scale: 1,
    },
  };
  const scaleMoon = useMotionValue(isDark ? 1 : 0);
  const scaleSun = useMotionValue(isDark ? 0 : 1);
  const pathLengthMoon = useTransform(scaleMoon, [0.6, 1], [0, 1]);
  const pathLengthSun = useTransform(scaleSun, [0.6, 1], [0, 1]);
 
  return (
    <motion.div animate={isDark ? "checked" : "unchecked"}>
      <motion.svg
        width="20"
        height="20"
        viewBox="0 0 25 25"
        fill="none"
        xmlns="http://www.w3.org/2000/svg"
      >
        <motion.path
          d="M12.4058 17.7625C15.1672 17.7625 17.4058 15.5239 17.4058 12.7625C17.4058 10.0011 15.1672 7.76251 12.4058 7.76251C9.64434 7.76251 7.40576 10.0011 7.40576 12.7625C7.40576 15.5239 9.64434 17.7625 12.4058 17.7625Z"
          stroke="currentColor"
          strokeWidth="2"
          strokeLinecap="round"
          strokeLinejoin="round"
          variants={sunVariants}
          custom={isDark}
          transition={{ duration }}
          style={{
            pathLength: pathLengthSun,
            scale: scaleSun,
          }}
        />
        <motion.path
          d="M12.4058 1.76251V3.76251"
          stroke="currentColor"
          strokeWidth="2"
          strokeLinecap="round"
          strokeLinejoin="round"
          variants={sunVariants}
          custom={isDark}
          transition={{ duration }}
          style={{
            pathLength: pathLengthSun,
            scale: scaleSun,
          }}
        />
        <motion.path
          d="M12.4058 21.7625V23.7625"
          stroke="currentColor"
          strokeWidth="2"
          strokeLinecap="round"
          strokeLinejoin="round"
          variants={sunVariants}
          custom={isDark}
          transition={{ duration }}
          style={{
            pathLength: pathLengthSun,
            scale: scaleSun,
          }}
        />
        <motion.path
          d="M4.62598 4.98248L6.04598 6.40248"
          stroke="currentColor"
          strokeWidth="2"
          strokeLinecap="round"
          strokeLinejoin="round"
          variants={sunVariants}
          custom={isDark}
          transition={{ duration }}
          style={{
            pathLength: pathLengthSun,
            scale: scaleSun,
          }}
        />
        <motion.path
          d="M18.7656 19.1225L20.1856 20.5425"
          stroke="currentColor"
          strokeWidth="2"
          strokeLinecap="round"
          strokeLinejoin="round"
          variants={sunVariants}
          custom={isDark}
          transition={{ duration }}
          style={{
            pathLength: pathLengthSun,
            scale: scaleSun,
          }}
        />
        <motion.path
          d="M1.40576 12.7625H3.40576"
          stroke="currentColor"
          strokeWidth="2"
          strokeLinecap="round"
          strokeLinejoin="round"
          variants={sunVariants}
          custom={isDark}
          transition={{ duration }}
          style={{
            pathLength: pathLengthSun,
            scale: scaleSun,
          }}
        />
        <motion.path
          d="M21.4058 12.7625H23.4058"
          stroke="currentColor"
          strokeWidth="2"
          strokeLinecap="round"
          strokeLinejoin="round"
          variants={sunVariants}
          custom={isDark}
          transition={{ duration }}
          style={{
            pathLength: pathLengthSun,
            scale: scaleSun,
          }}
        />
        <motion.path
          d="M4.62598 20.5425L6.04598 19.1225"
          stroke="currentColor"
          strokeWidth="2"
          strokeLinecap="round"
          strokeLinejoin="round"
          variants={sunVariants}
          custom={isDark}
          transition={{ duration }}
          style={{
            pathLength: pathLengthSun,
            scale: scaleSun,
          }}
        />
        <motion.path
          d="M18.7656 6.40248L20.1856 4.98248"
          stroke="currentColor"
          strokeWidth="2"
          strokeLinecap="round"
          strokeLinejoin="round"
          variants={sunVariants}
          custom={isDark}
          transition={{ duration }}
          style={{
            pathLength: pathLengthSun,
            scale: scaleSun,
          }}
        />
        <motion.path
          d="M21.1918 13.2013C21.0345 14.9035 20.3957 16.5257 19.35 17.8781C18.3044 19.2305 16.8953 20.2571 15.2875 20.8379C13.6797 21.4186 11.9398 21.5294 10.2713 21.1574C8.60281 20.7854 7.07479 19.9459 5.86602 18.7371C4.65725 17.5283 3.81774 16.0003 3.4457 14.3318C3.07367 12.6633 3.18451 10.9234 3.76526 9.31561C4.346 7.70783 5.37263 6.29868 6.72501 5.25307C8.07739 4.20746 9.69959 3.56862 11.4018 3.41132C10.4052 4.75958 9.92564 6.42077 10.0503 8.09273C10.175 9.76469 10.8957 11.3364 12.0812 12.5219C13.2667 13.7075 14.8384 14.4281 16.5104 14.5528C18.1823 14.6775 19.8435 14.1979 21.1918 13.2013Z"
          stroke="currentColor"
          strokeWidth="2"
          strokeLinecap="round"
          strokeLinejoin="round"
          transition={{ duration }}
          variants={moonVariants}
          custom={isDark}
          style={{
            pathLength: pathLengthMoon,
            scale: scaleMoon,
          }}
        />
      </motion.svg>
    </motion.div>
  );
};

Make sure you have the ThemeProvider set up in your app. In your root layout:

import { ThemeProvider } from "next-themes"

export default function RootLayout({ children }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body>
        <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
          {children}
        </ThemeProvider>
      </body>
    </html>
  )
}

Usage

import { AnimatedThemeToggle } from "@/components/ui/animated-theme-toggle"

export default function MyComponent() {
  return <AnimatedThemeToggle />
}

API Reference

AnimatedThemeToggle

Prop

Type

Notes

  • Uses Framer Motion for smooth SVG path animations
  • Sun-to-moon transition with synchronized path length animations
  • Built on top of the Button component for consistent styling
  • Includes hover and focus states out of the box
  • The toggle uses internal state by default - integrate with your theme provider for actual theme switching
  • SVG paths animate with coordinated timing for a polished effect

How is this guide?

On this page