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