diff --git a/surfsense_web/components/theme/theme-toggle.tsx b/surfsense_web/components/theme/theme-toggle.tsx
index 112cc7b88..382d11087 100644
--- a/surfsense_web/components/theme/theme-toggle.tsx
+++ b/surfsense_web/components/theme/theme-toggle.tsx
@@ -1,71 +1,802 @@
"use client";
-import { MoonIcon, SunIcon } from "lucide-react";
import { motion } from "motion/react";
import { useTheme } from "next-themes";
-import { useEffect, useState } from "react";
-import { Button } from "@/components/ui/button";
+import { useCallback, useEffect, useId, useState } from "react";
-export function ThemeTogglerComponent() {
- const { theme, setTheme } = useTheme();
+import { cn } from "@/lib/utils";
- const [isClient, setIsClient] = useState(false);
+// ///////////////////////////////////////////////////////////////////////////
+// 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";
- useEffect(() => {
- setIsClient(true);
- }, []);
-
- return (
- isClient && (
-
- )
- );
+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
+ */