主题切换按钮扩散动画代码分享 -- React 版本

主题切换按钮的扩散动画实现, 基于 React + Next.js + Shadcn UI

概要

本文仅作为代码片段分享,如有疑问请咨询 AI。

本功能基于 React + Next.js + Shadcn UI 参考 vue-vben-admin 实现。

核心 Hook:use-spread-transition.tsx

实现效果:

  • light -> dark: 从点击按钮位置向全屏扩散。
  • dark -> light: 从全屏收缩到按钮位置。

源代码

theme-context.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import * as React from "react"

type Theme = "dark" | "light" | "system"

export type ThemeProviderState = {
    theme: Theme
    setTheme: (theme: Theme) => void
}

const initialState: ThemeProviderState = {
    theme: "system",
    setTheme: () => null,
}

export const ThemeProviderContext = React.createContext<ThemeProviderState>(initialState)

use-theme.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import * as React from "react"
import { ThemeProviderContext } from "@/contexts/theme-context"

export const useTheme = () => {
  const context = React.useContext(ThemeProviderContext)

  if (context === undefined)
    throw new Error("useTheme must be used within a ThemeProvider")

  return context
}

theme-provider.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
"use client"

import * as React from "react"
import { ThemeProviderContext } from "@/contexts/theme-context"

type Theme = "dark" | "light" | "system"

type ThemeProviderProps = {
  children: React.ReactNode
  defaultTheme?: Theme
  storageKey?: string
}

export function ThemeProvider({
  children,
  defaultTheme = "system",
  storageKey = "tofuwine-ui-theme",
  ...props
}: ThemeProviderProps) {
  const [theme, setTheme] = React.useState<Theme>(
    () => (typeof window !== "undefined" && localStorage.getItem(storageKey) as Theme) || defaultTheme
  )

  React.useEffect(() => {
    if (typeof window === "undefined") return

    const root = window.document.documentElement

    root.classList.remove("light", "dark")

    if (theme === "system") {
      const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
        .matches
        ? "dark"
        : "light"

      root.classList.add(systemTheme)
      return
    }

    root.classList.add(theme)
  }, [theme])

  const value = {
    theme,
    setTheme: (newTheme: Theme) => {
      if (typeof window !== "undefined") {
        localStorage.setItem(storageKey, newTheme)
      }
      setTheme(newTheme)
    },
  }

  return (
    <ThemeProviderContext.Provider {...props} value={value}>
      {children}
    </ThemeProviderContext.Provider>
  )
}

use-spread-transition.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
"use client"

import { useRef, useCallback } from "react"
import { useTheme } from "@/hooks/use-theme"

interface SpreadTransitionHook {
  startTransition: (coords: { x: number; y: number }, nextTheme: string, callback: () => void) => void
  toggleTheme: (event: React.MouseEvent) => void
  isTransitioning: () => boolean
}

export function useSpreadTransition(): SpreadTransitionHook {
  const { theme, setTheme } = useTheme()
  const isTransitioningRef = useRef(false)

  const startTransition = useCallback((coords: { x: number; y: number }, nextTheme: string, callback: () => void) => {
    if (isTransitioningRef.current) return

    isTransitioningRef.current = true

    const x = coords.x
    const y = coords.y
    const endRadius = Math.hypot(
      Math.max(x, innerWidth - x),
      Math.max(y, innerHeight - y)
    )

    if ('startViewTransition' in document) {
      const transition = (document as Document & { startViewTransition: (callback: () => void) => { finished: Promise<void> } }).startViewTransition(() => {
        callback()
      })
      transition.ready.then(() => {
          const clipPath = [
            `circle(0px at ${x}px ${y}px)`,
            `circle(${endRadius}px at ${x}px ${y}px)`,
          ];

          const animate = document.documentElement.animate(
            {
              clipPath: nextTheme === "dark" ? clipPath : [...clipPath].reverse(),
            },
            {
              duration: 450,
              easing: 'ease-in',
              pseudoElement: nextTheme === "dark"
                ? '::view-transition-new(root)'
                : '::view-transition-old(root)',
            },
          );
          animate.onfinish = () => {
            transition.skipTransition();
          };
        });

      transition.finished.finally(() => {
        isTransitioningRef.current = false
      })
    } else {
      callback()
      setTimeout(() => {
        isTransitioningRef.current = false
      }, 400)
    }
  }, [])

  const toggleTheme = useCallback((event: React.MouseEvent) => {
    // Get precise click coordinates - use clientX/clientY directly like tweakcn
    const coords = {
      x: event.clientX,
      y: event.clientY
    }

    const nextTheme = theme === "dark" ? "light" : "dark"

    startTransition(coords, nextTheme, () => {
      setTheme(nextTheme)
    })
  }, [theme, setTheme, startTransition])

  const isTransitioning = useCallback(() => {
    return isTransitioningRef.current
  }, [])

  return {
    startTransition,
    toggleTheme,
    isTransitioning
  }
}

mode-toggle.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
"use client"

import * as React from "react"
import { Moon, Sun } from "lucide-react"

import { Button } from "@/components/ui/button"
import { useTheme } from "@/hooks/use-theme"
import { useSpreadTransition } from "@/hooks/use-spread-transition"
import "./theme-customizer/spread-transition.css"

interface ModeToggleProps {
  variant?: "outline" | "ghost" | "default"
}

export function ModeToggle({ variant = "outline" }: ModeToggleProps) {
  const { theme } = useTheme()
  const { toggleTheme } = useSpreadTransition()

  const [isDarkMode, setIsDarkMode] = React.useState(false)

  React.useEffect(() => {
    const updateMode = () => {
      if (theme === "dark") {
        setIsDarkMode(true)
      } else if (theme === "light") {
        setIsDarkMode(false)
      } else {
        setIsDarkMode(typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches)
      }
    }

    updateMode()

    const mediaQuery = typeof window !== "undefined" ? window.matchMedia("(prefers-color-scheme: dark)") : null
    if (mediaQuery) {
      mediaQuery.addEventListener("change", updateMode)
    }

    return () => {
      if (mediaQuery) {
        mediaQuery.removeEventListener("change", updateMode)
      }
    }
  }, [theme])

  const handleToggle = (event: React.MouseEvent<HTMLButtonElement>) => {
    toggleTheme(event)
  }

  return (
    <Button
      variant={variant}
      size="icon"
      onClick={handleToggle}
      className="cursor-pointer mode-toggle-button relative overflow-hidden"
    >
      {isDarkMode ? (
        <Sun className="h-[1.2rem] w-[1.2rem] transition-transform duration-300 rotate-0 scale-100" />
      ) : (
        <Moon className="h-[1.2rem] w-[1.2rem] transition-transform duration-300 rotate-0 scale-100" />
      )}
    </Button>
  )
}

spread-transition.css

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
::view-transition-old(root),
::view-transition-new(root) {
    animation: none;
    mix-blend-mode: normal;
}

/* Control stacking order */
::view-transition-old(root) {
    z-index: 1;
}

::view-transition-new(root) {
    z-index: 2147483646;
}

html.light::view-transition-old(root) {
    z-index: 2147483646;
}

html.light::view-transition-new(root) {
    z-index: 1;
}

/* Button styling for mode toggles */
.mode-toggle-button {
    position: relative;
    overflow: hidden;
    transition: all 0.2s ease-in-out;
}

.mode-toggle-button:disabled {
    opacity: 0.6;
    cursor: not-allowed;
}

.mode-toggle-button svg {
    transition: transform 0.3s ease-in-out;
}

/* Enhanced mode toggle button with ripple effect */
.mode-toggle-button {
    position: relative;
    overflow: hidden;
    transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}

.mode-toggle-button::before {
    content: '';
    position: absolute;
    top: 50%;
    left: 50%;
    width: 0;
    height: 0;
    border-radius: 50%;
    background: currentColor;
    opacity: 0.1;
    transform: translate(-50%, -50%);
    transition: all 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}

.mode-toggle-button.animating::before {
    width: 200px;
    height: 200px;
    opacity: 0;
}

/* Improved focus and accessibility */
.mode-toggle-button:focus-visible {
    outline: 2px solid var(--ring);
    outline-offset: 2px;
}

参数补充说明:--ring: oklch(0.556 0 0);

更多样式参考

如果本文对您有所帮助,欢迎打赏支持作者!

Licensed under CC BY-NC-SA 4.0
最后更新于 2026-03-13 15:57
使用 Hugo 构建
主题 StackJimmy 设计