From b8538a699661cbdae8af204104e1080cd724d80d Mon Sep 17 00:00:00 2001
From: funnytime <162818986+funnytime75@users.noreply.github.com>
Date: Fri, 6 Feb 2026 22:14:29 +0800
Subject: [PATCH] feat: circular reveal animation for theme switching (#905)
---
src/components/mode-toggle.tsx | 6 ++--
src/components/settings/ThemeSettings.tsx | 8 ++---
src/components/theme-provider.tsx | 28 ++++++++++++++--
src/index.css | 39 +++++++++++++++++++++++
4 files changed, 71 insertions(+), 10 deletions(-)
diff --git a/src/components/mode-toggle.tsx b/src/components/mode-toggle.tsx
index 20d579e5a..6c1bef5c6 100644
--- a/src/components/mode-toggle.tsx
+++ b/src/components/mode-toggle.tsx
@@ -7,13 +7,13 @@ export function ModeToggle() {
const { theme, setTheme } = useTheme();
const { t } = useTranslation();
- const toggleTheme = () => {
+ const toggleTheme = (event: React.MouseEvent) => {
// 如果当前是 dark 或 system(且系统是暗色),切换到 light
// 否则切换到 dark
if (theme === "dark") {
- setTheme("light");
+ setTheme("light", event);
} else {
- setTheme("dark");
+ setTheme("dark", event);
}
};
diff --git a/src/components/settings/ThemeSettings.tsx b/src/components/settings/ThemeSettings.tsx
index 7dd3f3952..f22c51d69 100644
--- a/src/components/settings/ThemeSettings.tsx
+++ b/src/components/settings/ThemeSettings.tsx
@@ -19,21 +19,21 @@ export function ThemeSettings() {
setTheme("light")}
+ onClick={(e) => setTheme("light", e)}
icon={Sun}
>
{t("settings.themeLight")}
setTheme("dark")}
+ onClick={(e) => setTheme("dark", e)}
icon={Moon}
>
{t("settings.themeDark")}
setTheme("system")}
+ onClick={(e) => setTheme("system", e)}
icon={Monitor}
>
{t("settings.themeSystem")}
@@ -45,7 +45,7 @@ export function ThemeSettings() {
interface ThemeButtonProps {
active: boolean;
- onClick: () => void;
+ onClick: (event: React.MouseEvent) => void;
icon: React.ComponentType<{ className?: string }>;
children: React.ReactNode;
}
diff --git a/src/components/theme-provider.tsx b/src/components/theme-provider.tsx
index 0a1fec2ae..3a32f46ad 100644
--- a/src/components/theme-provider.tsx
+++ b/src/components/theme-provider.tsx
@@ -17,7 +17,7 @@ interface ThemeProviderProps {
interface ThemeContextValue {
theme: Theme;
- setTheme: (theme: Theme) => void;
+ setTheme: (theme: Theme, event?: React.MouseEvent) => void;
}
const ThemeProviderContext = createContext(
@@ -146,8 +146,30 @@ export function ThemeProvider({
const value = useMemo(
() => ({
theme,
- setTheme: (nextTheme: Theme) => {
- setThemeState(nextTheme);
+ setTheme: (nextTheme: Theme, event?: React.MouseEvent) => {
+ // Skip if same theme
+ if (nextTheme === theme) return;
+
+ // Set transition origin coordinates from click event
+ const x = event?.clientX ?? window.innerWidth / 2;
+ const y = event?.clientY ?? window.innerHeight / 2;
+ document.documentElement.style.setProperty(
+ "--theme-transition-x",
+ `${x}px`,
+ );
+ document.documentElement.style.setProperty(
+ "--theme-transition-y",
+ `${y}px`,
+ );
+
+ // Use View Transitions API if available, otherwise fall back to instant change
+ if (document.startViewTransition) {
+ document.startViewTransition(() => {
+ setThemeState(nextTheme);
+ });
+ } else {
+ setThemeState(nextTheme);
+ }
},
}),
[theme],
diff --git a/src/index.css b/src/index.css
index a2a405d45..c51cc7300 100644
--- a/src/index.css
+++ b/src/index.css
@@ -215,3 +215,42 @@ input[type="password"]::-ms-reveal,
input[type="password"]::-ms-clear {
display: none;
}
+
+/* Theme transition animation using View Transitions API */
+::view-transition-old(root),
+::view-transition-new(root) {
+ animation: none;
+ mix-blend-mode: normal;
+}
+
+/* Old snapshot stays behind, new snapshot animates on top */
+::view-transition-old(root) {
+ z-index: 1;
+}
+
+::view-transition-new(root) {
+ z-index: 9999;
+}
+
+/* Circular expand animation from click position */
+@keyframes theme-circle-expand {
+ from {
+ clip-path: circle(0% at var(--theme-transition-x, 50%) var(--theme-transition-y, 50%));
+ }
+
+ to {
+ clip-path: circle(150% at var(--theme-transition-x, 50%) var(--theme-transition-y, 50%));
+ }
+}
+
+/* Apply animation to new snapshot - works for both light and dark transitions */
+::view-transition-new(root) {
+ animation: theme-circle-expand 0.4s ease-out;
+}
+
+/* Respect user preference for reduced motion */
+@media (prefers-reduced-motion: reduce) {
+ ::view-transition-new(root) {
+ animation: none;
+ }
+}
\ No newline at end of file