diff --git a/app/globals.css b/app/globals.css index d112e931..e05a418e 100644 --- a/app/globals.css +++ b/app/globals.css @@ -204,6 +204,12 @@ pre[class*="language-"] { } } +::view-transition-old(root), +::view-transition-new(root) { + animation: none; + mix-blend-mode: normal; +} + /* Hero blob floating animation - GPU accelerated */ @keyframes float { 0%, diff --git a/bun.lock b/bun.lock index e15f6a08..4cd230ae 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "eternalcodev3", diff --git a/components/hero/theme-switch-button.tsx b/components/hero/theme-switch-button.tsx index 38ae5316..8b2103e6 100644 --- a/components/hero/theme-switch-button.tsx +++ b/components/hero/theme-switch-button.tsx @@ -1,42 +1,9 @@ "use client"; -import { useTheme } from "next-themes"; -import { useCallback, useEffect, useState } from "react"; - -import DarkThemeIcon from "@/components/icons/dark-theme"; -import LightThemeIcon from "@/components/icons/light-theme"; -import { Button } from "@/components/ui/button"; +import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler"; export default function ThemeChanger() { - const { setTheme, resolvedTheme } = useTheme(); - const [mounted, setMounted] = useState(false); - - useEffect(() => { - setMounted(true); - }, []); - - const toggleTheme = useCallback(() => { - setTheme(resolvedTheme === "dark" ? "light" : "dark"); - }, [resolvedTheme, setTheme]); - - if (!mounted) { - return null; - } - return ( - + ); } diff --git a/components/ui/animated-theme-toggler.tsx b/components/ui/animated-theme-toggler.tsx new file mode 100644 index 00000000..764cd286 --- /dev/null +++ b/components/ui/animated-theme-toggler.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { Moon, Sun } from "lucide-react"; +import { useTheme } from "next-themes"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { flushSync } from "react-dom"; + +import { cn } from "@/lib/utils"; + +type ViewTransitionDocument = Document & { + startViewTransition?: (update: () => void) => { ready: Promise }; +}; + +interface AnimatedThemeTogglerProps extends React.ComponentPropsWithoutRef<"button"> { + duration?: number; +} + +export const AnimatedThemeToggler = ({ + className, + duration = 400, + ...props +}: AnimatedThemeTogglerProps) => { + const { resolvedTheme, setTheme } = useTheme(); + const [mounted, setMounted] = useState(false); + const buttonRef = useRef(null); + + useEffect(() => { + setMounted(true); + }, []); + + const isDark = resolvedTheme === "dark"; + + const defaultLabel = useMemo(() => `Change to ${isDark ? "light" : "dark"} mode`, [isDark]); + + const toggleTheme = useCallback(async () => { + if (!buttonRef.current) { + return; + } + + const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + + const nextTheme = isDark ? "light" : "dark"; + const root = document.documentElement; + const transitionDocument = document as ViewTransitionDocument; + + if (!transitionDocument.startViewTransition || prefersReducedMotion) { + setTheme(nextTheme); + root.classList.toggle("dark", nextTheme === "dark"); + return; + } + + await transitionDocument.startViewTransition(() => { + flushSync(() => { + setTheme(nextTheme); + root.classList.toggle("dark", nextTheme === "dark"); + }); + }).ready; + + const { top, left, width, height } = buttonRef.current.getBoundingClientRect(); + const x = left + width / 2; + const y = top + height / 2; + const maxRadius = Math.hypot( + Math.max(left, window.innerWidth - left), + Math.max(top, window.innerHeight - top) + ); + + root.animate( + { + clipPath: [`circle(0px at ${x}px ${y}px)`, `circle(${maxRadius}px at ${x}px ${y}px)`], + }, + { + duration, + easing: "ease-in-out", + pseudoElement: "::view-transition-new(root)", + } + ); + }, [duration, isDark, setTheme]); + + if (!mounted) { + return null; + } + + return ( + + ); +};