}
+ viewTransitionName="results-drawer"
>
, ariaLabel: "Dark Theme" },
]}
value={theme}
- onChange={setTheme}
+ onChange={toggleTheme}
className="frosted-glass-inset"
/>
diff --git a/frontend/src/lib/hooks/use-theme-toggle.ts b/frontend/src/lib/hooks/use-theme-toggle.ts
new file mode 100644
index 000000000..658e8c65d
--- /dev/null
+++ b/frontend/src/lib/hooks/use-theme-toggle.ts
@@ -0,0 +1,30 @@
+import { useTheme } from "next-themes";
+
+export function useThemeToggle() {
+ const { theme, resolvedTheme, setTheme } = useTheme();
+
+ const toggleTheme = (newTheme: string) => {
+ let targetTheme = newTheme;
+
+ if (newTheme === "system") {
+ targetTheme = window.matchMedia("(prefers-color-scheme: dark)").matches
+ ? "dark"
+ : "light";
+ }
+
+ if (targetTheme === resolvedTheme) {
+ setTheme(newTheme);
+ return;
+ }
+
+ if (!document.startViewTransition) {
+ setTheme(newTheme);
+ } else {
+ document.startViewTransition(() => {
+ setTheme(newTheme);
+ });
+ }
+ };
+
+ return { theme, toggleTheme };
+}
diff --git a/frontend/src/lib/hooks/use-view-transition.ts b/frontend/src/lib/hooks/use-view-transition.ts
new file mode 100644
index 000000000..2d2f06ae1
--- /dev/null
+++ b/frontend/src/lib/hooks/use-view-transition.ts
@@ -0,0 +1,66 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+
+export function useViewTransition() {
+ const router = useRouter();
+
+ const doViewTransition = (
+ href: string,
+ targetSelector?: string,
+ timeout: number = 3000,
+ ) => {
+ if (!document.startViewTransition) {
+ router.push(href);
+ return;
+ }
+
+ const currentPath = window.location.pathname;
+ const previousTarget = targetSelector
+ ? document.querySelector(targetSelector)
+ : null;
+
+ const transition = document.startViewTransition(() => {
+ return new Promise((resolve, reject) => {
+ const observer = new MutationObserver(() => {
+ // Don't resolve if the page hasn't changed yet
+ if (window.location.pathname === currentPath) return;
+
+ if (targetSelector) {
+ // Only resolve if the target element has mounted, bypassing loading.tsx
+ const target = document.querySelector(targetSelector);
+ if (target && target !== previousTarget) {
+ observer.disconnect();
+ clearTimeout(timer);
+ resolve();
+ }
+ } else {
+ // Resolve on any mutation if no selector is provided
+ observer.disconnect();
+ clearTimeout(timer);
+ resolve();
+ }
+ });
+
+ observer.observe(document.body, {
+ childList: true,
+ subtree: true,
+ });
+
+ router.push(href);
+
+ const timer = setTimeout(() => {
+ observer.disconnect();
+ // This aborts the view transition, showing the loading skeleton instead
+ reject();
+ }, timeout);
+ });
+ });
+
+ // Catch the rejection promises
+ transition.ready.catch(() => {});
+ transition.finished.catch(() => {});
+ };
+
+ return doViewTransition;
+}
diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css
index a6754d77c..a2f3d8e4d 100644
--- a/frontend/src/styles/globals.css
+++ b/frontend/src/styles/globals.css
@@ -9,6 +9,7 @@
@import "./radix.css";
@import "./scrollbar.css";
@import "./header.css";
+@import "./view-transition.css";
/* GLOBAL STYLES */
diff --git a/frontend/src/styles/view-transition.css b/frontend/src/styles/view-transition.css
new file mode 100644
index 000000000..97d5ed9f2
--- /dev/null
+++ b/frontend/src/styles/view-transition.css
@@ -0,0 +1,18 @@
+:root {
+ --vt-duration: 0.35s;
+ --vt-timing-function: ease-in-out;
+}
+
+::view-transition-group(*) {
+ animation-duration: var(--vt-duration);
+ animation-timing-function: var(--vt-timing-function);
+}
+
+::view-transition-group(grid) {
+ z-index: 1;
+}
+
+::view-transition-group(results-drawer),
+::view-transition-group(painting-island) {
+ z-index: 100;
+}