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 (
+
+ );
+};