"use client"; import { motion } from "motion/react"; import { useTheme } from "next-themes"; import { useCallback, useEffect, useId, useState } from "react"; import { cn } from "@/lib/utils"; // /////////////////////////////////////////////////////////////////////////// // Types export type AnimationVariant = "circle" | "rectangle" | "gif" | "polygon" | "circle-blur"; export type AnimationStart = | "top-left" | "top-right" | "bottom-left" | "bottom-right" | "center" | "top-center" | "bottom-center" | "bottom-up" | "top-down" | "left-right" | "right-left"; interface Animation { name: string; css: string; } // /////////////////////////////////////////////////////////////////////////// // Helper functions const getPositionCoords = (position: AnimationStart) => { switch (position) { case "top-left": return { cx: "0", cy: "0" }; case "top-right": return { cx: "40", cy: "0" }; case "bottom-left": return { cx: "0", cy: "40" }; case "bottom-right": return { cx: "40", cy: "40" }; case "top-center": return { cx: "20", cy: "0" }; case "bottom-center": return { cx: "20", cy: "40" }; case "bottom-up": case "top-down": case "left-right": case "right-left": return { cx: "20", cy: "20" }; } }; const generateSVG = (variant: AnimationVariant, start: AnimationStart) => { if (variant === "circle-blur") { if (start === "center") { return `data:image/svg+xml,`; } const positionCoords = getPositionCoords(start); if (!positionCoords) { throw new Error(`Invalid start position: ${start}`); } const { cx, cy } = positionCoords; return `data:image/svg+xml,`; } if (start === "center") return; if (variant === "rectangle") return ""; const positionCoords = getPositionCoords(start); if (!positionCoords) { throw new Error(`Invalid start position: ${start}`); } const { cx, cy } = positionCoords; if (variant === "circle") { return `data:image/svg+xml,`; } return ""; }; const getTransformOrigin = (start: AnimationStart) => { switch (start) { case "top-left": return "top left"; case "top-right": return "top right"; case "bottom-left": return "bottom left"; case "bottom-right": return "bottom right"; case "top-center": return "top center"; case "bottom-center": return "bottom center"; case "bottom-up": case "top-down": case "left-right": case "right-left": return "center"; } }; export const createAnimation = ( variant: AnimationVariant, start: AnimationStart = "center", blur = false, url?: string ): Animation => { const svg = generateSVG(variant, start); const transformOrigin = getTransformOrigin(start); if (variant === "rectangle") { const getClipPath = (direction: AnimationStart) => { switch (direction) { case "bottom-up": return { from: "polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)", to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", }; case "top-down": return { from: "polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)", to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", }; case "left-right": return { from: "polygon(0% 0%, 0% 0%, 0% 100%, 0% 100%)", to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", }; case "right-left": return { from: "polygon(100% 0%, 100% 0%, 100% 100%, 100% 100%)", to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", }; case "top-left": return { from: "polygon(0% 0%, 0% 0%, 0% 0%, 0% 0%)", to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", }; case "top-right": return { from: "polygon(100% 0%, 100% 0%, 100% 0%, 100% 0%)", to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", }; case "bottom-left": return { from: "polygon(0% 100%, 0% 100%, 0% 100%, 0% 100%)", to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", }; case "bottom-right": return { from: "polygon(100% 100%, 100% 100%, 100% 100%, 100% 100%)", to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", }; default: return { from: "polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)", to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", }; } }; const clipPath = getClipPath(start); return { name: `${variant}-${start}${blur ? "-blur" : ""}`, css: ` ::view-transition-group(root) { animation-duration: 0.7s; animation-timing-function: var(--expo-out); } ::view-transition-new(root) { animation-name: reveal-light-${start}${blur ? "-blur" : ""}; ${blur ? "filter: blur(2px);" : ""} } ::view-transition-old(root), .dark::view-transition-old(root) { animation: none; z-index: -1; } .dark::view-transition-new(root) { animation-name: reveal-dark-${start}${blur ? "-blur" : ""}; ${blur ? "filter: blur(2px);" : ""} } @keyframes reveal-dark-${start}${blur ? "-blur" : ""} { from { clip-path: ${clipPath.from}; ${blur ? "filter: blur(8px);" : ""} } ${blur ? "50% { filter: blur(4px); }" : ""} to { clip-path: ${clipPath.to}; ${blur ? "filter: blur(0px);" : ""} } } @keyframes reveal-light-${start}${blur ? "-blur" : ""} { from { clip-path: ${clipPath.from}; ${blur ? "filter: blur(8px);" : ""} } ${blur ? "50% { filter: blur(4px); }" : ""} to { clip-path: ${clipPath.to}; ${blur ? "filter: blur(0px);" : ""} } } `, }; } if (variant === "circle" && start === "center") { return { name: `${variant}-${start}${blur ? "-blur" : ""}`, css: ` ::view-transition-group(root) { animation-duration: 0.7s; animation-timing-function: var(--expo-out); } ::view-transition-new(root) { animation-name: reveal-light${blur ? "-blur" : ""}; ${blur ? "filter: blur(2px);" : ""} } ::view-transition-old(root), .dark::view-transition-old(root) { animation: none; z-index: -1; } .dark::view-transition-new(root) { animation-name: reveal-dark${blur ? "-blur" : ""}; ${blur ? "filter: blur(2px);" : ""} } @keyframes reveal-dark${blur ? "-blur" : ""} { from { clip-path: circle(0% at 50% 50%); ${blur ? "filter: blur(8px);" : ""} } ${blur ? "50% { filter: blur(4px); }" : ""} to { clip-path: circle(100.0% at 50% 50%); ${blur ? "filter: blur(0px);" : ""} } } @keyframes reveal-light${blur ? "-blur" : ""} { from { clip-path: circle(0% at 50% 50%); ${blur ? "filter: blur(8px);" : ""} } ${blur ? "50% { filter: blur(4px); }" : ""} to { clip-path: circle(100.0% at 50% 50%); ${blur ? "filter: blur(0px);" : ""} } } `, }; } if (variant === "gif") { return { name: `${variant}-${start}`, css: ` ::view-transition-group(root) { animation-timing-function: var(--expo-in); } ::view-transition-new(root) { mask: url('${url}') center / 0 no-repeat; animation: scale 3s; } ::view-transition-old(root), .dark::view-transition-old(root) { animation: scale 3s; } @keyframes scale { 0% { mask-size: 0; } 10% { mask-size: 50vmax; } 90% { mask-size: 50vmax; } 100% { mask-size: 2000vmax; } }`, }; } if (variant === "circle-blur") { if (start === "center") { return { name: `${variant}-${start}`, css: ` ::view-transition-group(root) { animation-timing-function: var(--expo-out); } ::view-transition-new(root) { mask: url('${svg}') center / 0 no-repeat; mask-origin: content-box; animation: scale 1s; transform-origin: center; } ::view-transition-old(root), .dark::view-transition-old(root) { animation: scale 1s; transform-origin: center; z-index: -1; } @keyframes scale { to { mask-size: 350vmax; } } `, }; } return { name: `${variant}-${start}`, css: ` ::view-transition-group(root) { animation-timing-function: var(--expo-out); } ::view-transition-new(root) { mask: url('${svg}') ${start.replace("-", " ")} / 0 no-repeat; mask-origin: content-box; animation: scale 1s; transform-origin: ${transformOrigin}; } ::view-transition-old(root), .dark::view-transition-old(root) { animation: scale 1s; transform-origin: ${transformOrigin}; z-index: -1; } @keyframes scale { to { mask-size: 350vmax; } } `, }; } if (variant === "polygon") { const getPolygonClipPaths = (position: AnimationStart) => { switch (position) { case "top-left": return { darkFrom: "polygon(50% -71%, -50% 71%, -50% 71%, 50% -71%)", darkTo: "polygon(50% -71%, -50% 71%, 50% 171%, 171% 50%)", lightFrom: "polygon(171% 50%, 50% 171%, 50% 171%, 171% 50%)", lightTo: "polygon(171% 50%, 50% 171%, -50% 71%, 50% -71%)", }; case "top-right": return { darkFrom: "polygon(150% -71%, 250% 71%, 250% 71%, 150% -71%)", darkTo: "polygon(150% -71%, 250% 71%, 50% 171%, -71% 50%)", lightFrom: "polygon(-71% 50%, 50% 171%, 50% 171%, -71% 50%)", lightTo: "polygon(-71% 50%, 50% 171%, 250% 71%, 150% -71%)", }; default: return { darkFrom: "polygon(50% -71%, -50% 71%, -50% 71%, 50% -71%)", darkTo: "polygon(50% -71%, -50% 71%, 50% 171%, 171% 50%)", lightFrom: "polygon(171% 50%, 50% 171%, 50% 171%, 171% 50%)", lightTo: "polygon(171% 50%, 50% 171%, -50% 71%, 50% -71%)", }; } }; const clipPaths = getPolygonClipPaths(start); return { name: `${variant}-${start}${blur ? "-blur" : ""}`, css: ` ::view-transition-group(root) { animation-duration: 0.7s; animation-timing-function: var(--expo-out); } ::view-transition-new(root) { animation-name: reveal-light-${start}${blur ? "-blur" : ""}; ${blur ? "filter: blur(2px);" : ""} } ::view-transition-old(root), .dark::view-transition-old(root) { animation: none; z-index: -1; } .dark::view-transition-new(root) { animation-name: reveal-dark-${start}${blur ? "-blur" : ""}; ${blur ? "filter: blur(2px);" : ""} } @keyframes reveal-dark-${start}${blur ? "-blur" : ""} { from { clip-path: ${clipPaths.darkFrom}; ${blur ? "filter: blur(8px);" : ""} } ${blur ? "50% { filter: blur(4px); }" : ""} to { clip-path: ${clipPaths.darkTo}; ${blur ? "filter: blur(0px);" : ""} } } @keyframes reveal-light-${start}${blur ? "-blur" : ""} { from { clip-path: ${clipPaths.lightFrom}; ${blur ? "filter: blur(8px);" : ""} } ${blur ? "50% { filter: blur(4px); }" : ""} to { clip-path: ${clipPaths.lightTo}; ${blur ? "filter: blur(0px);" : ""} } } `, }; } // Handle circle variants with start positions using clip-path if (variant === "circle" && start !== "center") { const getClipPathPosition = (position: AnimationStart) => { switch (position) { case "top-left": return "0% 0%"; case "top-right": return "100% 0%"; case "bottom-left": return "0% 100%"; case "bottom-right": return "100% 100%"; case "top-center": return "50% 0%"; case "bottom-center": return "50% 100%"; default: return "50% 50%"; } }; const clipPosition = getClipPathPosition(start); return { name: `${variant}-${start}${blur ? "-blur" : ""}`, css: ` ::view-transition-group(root) { animation-duration: 1s; animation-timing-function: var(--expo-out); } ::view-transition-new(root) { animation-name: reveal-light-${start}${blur ? "-blur" : ""}; ${blur ? "filter: blur(2px);" : ""} } ::view-transition-old(root), .dark::view-transition-old(root) { animation: none; z-index: -1; } .dark::view-transition-new(root) { animation-name: reveal-dark-${start}${blur ? "-blur" : ""}; ${blur ? "filter: blur(2px);" : ""} } @keyframes reveal-dark-${start}${blur ? "-blur" : ""} { from { clip-path: circle(0% at ${clipPosition}); ${blur ? "filter: blur(8px);" : ""} } ${blur ? "50% { filter: blur(4px); }" : ""} to { clip-path: circle(150.0% at ${clipPosition}); ${blur ? "filter: blur(0px);" : ""} } } @keyframes reveal-light-${start}${blur ? "-blur" : ""} { from { clip-path: circle(0% at ${clipPosition}); ${blur ? "filter: blur(8px);" : ""} } ${blur ? "50% { filter: blur(4px); }" : ""} to { clip-path: circle(150.0% at ${clipPosition}); ${blur ? "filter: blur(0px);" : ""} } } `, }; } return { name: `${variant}-${start}${blur ? "-blur" : ""}`, css: ` ::view-transition-group(root) { animation-timing-function: var(--expo-in); } ::view-transition-new(root) { mask: url('${svg}') ${start.replace("-", " ")} / 0 no-repeat; mask-origin: content-box; animation: scale-${start}${blur ? "-blur" : ""} 1s; transform-origin: ${transformOrigin}; ${blur ? "filter: blur(2px);" : ""} } ::view-transition-old(root), .dark::view-transition-old(root) { animation: scale-${start}${blur ? "-blur" : ""} 1s; transform-origin: ${transformOrigin}; z-index: -1; } @keyframes scale-${start}${blur ? "-blur" : ""} { from { ${blur ? "filter: blur(8px);" : ""} } ${blur ? "50% { filter: blur(4px); }" : ""} to { mask-size: 2000vmax; ${blur ? "filter: blur(0px);" : ""} } } `, }; }; // /////////////////////////////////////////////////////////////////////////// // Custom hook for theme toggle functionality export const useThemeToggle = ({ variant = "circle", start = "center", blur = false, gifUrl = "", }: { variant?: AnimationVariant; start?: AnimationStart; blur?: boolean; gifUrl?: string; } = {}) => { const { theme, setTheme, resolvedTheme } = useTheme(); const [isDark, setIsDark] = useState(false); // Sync isDark state with resolved theme after hydration useEffect(() => { setIsDark(resolvedTheme === "dark"); }, [resolvedTheme]); const styleId = "theme-transition-styles"; const updateStyles = useCallback((css: string) => { if (typeof window === "undefined") return; let styleElement = document.getElementById(styleId) as HTMLStyleElement; if (!styleElement) { styleElement = document.createElement("style"); styleElement.id = styleId; document.head.appendChild(styleElement); } styleElement.textContent = css; }, []); const toggleTheme = useCallback(() => { setIsDark(!isDark); const animation = createAnimation(variant, start, blur, gifUrl); updateStyles(animation.css); if (typeof window === "undefined") return; const switchTheme = () => { setTheme(theme === "light" ? "dark" : "light"); }; if (!document.startViewTransition) { switchTheme(); return; } document.startViewTransition(switchTheme); }, [theme, setTheme, variant, start, blur, gifUrl, updateStyles, isDark]); const setCrazyLightTheme = useCallback(() => { setIsDark(false); const animation = createAnimation(variant, start, blur, gifUrl); updateStyles(animation.css); if (typeof window === "undefined") return; const switchTheme = () => { setTheme("light"); }; if (!document.startViewTransition) { switchTheme(); return; } document.startViewTransition(switchTheme); }, [setTheme, variant, start, blur, gifUrl, updateStyles]); const setCrazyDarkTheme = useCallback(() => { setIsDark(true); const animation = createAnimation(variant, start, blur, gifUrl); updateStyles(animation.css); if (typeof window === "undefined") return; const switchTheme = () => { setTheme("dark"); }; if (!document.startViewTransition) { switchTheme(); return; } document.startViewTransition(switchTheme); }, [setTheme, variant, start, blur, gifUrl, updateStyles]); const setCrazySystemTheme = useCallback(() => { if (typeof window === "undefined") return; const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; setIsDark(prefersDark); const animation = createAnimation(variant, start, blur, gifUrl); updateStyles(animation.css); const switchTheme = () => { setTheme("system"); }; if (!document.startViewTransition) { switchTheme(); return; } document.startViewTransition(switchTheme); }, [setTheme, variant, start, blur, gifUrl, updateStyles]); return { isDark, setIsDark, toggleTheme, setCrazyLightTheme, setCrazyDarkTheme, setCrazySystemTheme, }; }; // /////////////////////////////////////////////////////////////////////////// // Theme Toggle Button Component (Sun/Moon Style) export const ThemeToggleButton = ({ className = "", variant = "circle", start = "center", blur = false, gifUrl = "", }: { className?: string; variant?: AnimationVariant; start?: AnimationStart; blur?: boolean; gifUrl?: string; }) => { const { isDark, toggleTheme } = useThemeToggle({ variant, start, blur, gifUrl, }); const clipId = useId(); const clipPathId = `theme-toggle-clip-${clipId}`; return ( ); }; // /////////////////////////////////////////////////////////////////////////// // Backwards compatible export (alias for ThemeToggleButton with default settings) export function ThemeTogglerComponent() { return ; } /** * Skiper 26 Theme_buttons_002 — React + CSS + transition view api https://developer.chrome.com/docs/web-platform/view-transitions/ * Original concept from rudrodip * Inspired by from https://github.com/rudrodip/theme-toggle-effect * We respect the original creators. This is an inspired rebuild with our own taste and does not claim any ownership. * These animations aren't associated with the rudrodip . They're independent recreations meant to study interaction design * * License & Usage: * - Free to use and modify in both personal and commercial projects. * - Attribution to Skiper UI is required when using the free version. * - No attribution required with Skiper UI Pro. * * Feedback and contributions are welcome. * * Author: @gurvinder-singh02 * Website: https://gxuri.in * Twitter: https://x.com/Gur__vi */