Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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%,
Expand Down
1 change: 0 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

37 changes: 2 additions & 35 deletions components/hero/theme-switch-button.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Button
aria-label={`Change to ${resolvedTheme === "dark" ? "light" : "dark"} mode`}
className="rounded-full p-2"
onClick={toggleTheme}
size="sm"
type="button"
variant="ghost"
>
{resolvedTheme === "dark" ? (
<LightThemeIcon aria-label="Light theme icon" className="h-6 w-6" />
) : (
<DarkThemeIcon aria-label="Dark theme icon" className="h-6 w-6" />
)}
</Button>
<AnimatedThemeToggler aria-label="Toggle theme" className="cursor-pointer rounded-full p-2" />
);
}
99 changes: 99 additions & 0 deletions components/ui/animated-theme-toggler.tsx
Original file line number Diff line number Diff line change
@@ -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<void> };
};

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<HTMLButtonElement>(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 (
<button
ref={buttonRef}
{...props}
aria-label={props["aria-label"] ?? defaultLabel}
className={cn(
"flex items-center justify-center rounded-full p-2 text-gray-600 transition-colors hover:text-gray-900 dark:text-gray-300 dark:hover:text-white",
className
)}
onClick={toggleTheme}
type={props.type ?? "button"}
>
{isDark ? <Sun className="h-6 w-6" /> : <Moon className="h-6 w-6" />}
<span className="sr-only">Toggle theme</span>
</button>
);
};