返回 AiToEarn
index.tsx
1 /**
2 * MorphingIcon - 循环切换图标动画组件
3 * 图标切换时有描边绘制动画(pathLength),整体带呼吸闪烁效果
4 */
5
6 'use client'
7
8 import type { Variants } from 'framer-motion'
9 import { AnimatePresence, motion } from 'framer-motion'
10 import { memo, useEffect, useId, useState } from 'react'
11
12 import { cn } from '@/utils/className'
13
14 // 图标 SVG 路径数据(从 Lucide 提取)
15 const ICONS = [
16 {
17 name: 'sparkles',
18 paths: [
19 'M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z',
20 'M20 2v4',
21 'M22 4h-4',
22 ],
23 circles: [{ cx: 4, cy: 20, r: 2, isSmall: true }],
24 },
25 {
26 name: 'brain',
27 paths: [
28 'M12 5v13',
29 'M15 13a4.17 4.17 0 0 1-3-4 4.17 4.17 0 0 1-3 4',
30 'M17.598 6.5A3 3 0 1 0 12 5a3 3 0 1 0-5.598 1.5',
31 'M17.997 5.125a4 4 0 0 1 2.526 5.77',
32 'M18 18a4 4 0 0 0 2-7.464',
33 'M19.967 17.483A4 4 0 1 1 12 18a4 4 0 1 1-7.967-.517',
34 'M6 18a4 4 0 0 1-2-7.464',
35 'M6.003 5.125a4 4 0 0 0-2.526 5.77',
36 ],
37 },
38 {
39 name: 'rocket',
40 paths: [
41 'M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z',
42 'm12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z',
43 'M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0',
44 'M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5',
45 ],
46 circles: [{ cx: 10, cy: 14, r: 2, isSmall: true }],
47 },
48 {
49 name: 'target',
50 circles: [
51 { cx: 12, cy: 12, r: 10 },
52 { cx: 12, cy: 12, r: 6 },
53 { cx: 12, cy: 12, r: 2 },
54 ],
55 },
56 ]
57
58 // 描边绘制动画变体(带交错延迟)
59 function createDrawVariants(index: number): Variants {
60 return {
61 hidden: {
62 pathLength: 0,
63 opacity: 1, // 立即可见,避免闪烁
64 },
65 visible: {
66 pathLength: 1,
67 opacity: 1,
68 transition: {
69 pathLength: { duration: 0.25, ease: 'easeOut', delay: index * 0.03 },
70 },
71 },
72 exit: {
73 opacity: 0,
74 transition: { delay: 0.1, duration: 0.1 }, // 延迟淡出,避免白色闪烁
75 },
76 }
77 }
78
79 // 小圆点 scale 动画变体
80 const smallCircleVariants: Variants = {
81 hidden: { scale: 0, opacity: 1 },
82 visible: {
83 scale: 1,
84 opacity: 1,
85 transition: { type: 'spring', stiffness: 400, damping: 20, delay: 0.05 },
86 },
87 exit: { opacity: 0, transition: { delay: 0.1, duration: 0.1 } }, // 延迟淡出
88 }
89
90 // 大圆(如 target 图标)使用 pathLength 动画
91 function createCircleDrawVariants(index: number): Variants {
92 return {
93 hidden: {
94 pathLength: 0,
95 opacity: 1,
96 },
97 visible: {
98 pathLength: 1,
99 opacity: 1,
100 transition: {
101 pathLength: { duration: 0.25, ease: 'easeOut', delay: index * 0.05 },
102 },
103 },
104 exit: {
105 opacity: 0,
106 transition: { delay: 0.1, duration: 0.1 }, // 延迟淡出
107 },
108 }
109 }
110
111 interface MorphingIconProps {
112 size?: number
113 color?: string
114 className?: string
115 }
116
117 // 切换间隔:1.5 秒
118 const SWITCH_INTERVAL = 1500
119
120 export const MorphingIcon = memo(({ size = 16, color, className }: MorphingIconProps) => {
121 const rawGradientId = useId()
122 const gradientId = `morphing-icon-gradient-${rawGradientId.replace(/:/g, '')}`
123 const iconPaint = color || `url(#${gradientId})`
124 const [currentIndex, setCurrentIndex] = useState(0)
125
126 useEffect(() => {
127 const timer = setInterval(() => {
128 setCurrentIndex(prev => (prev + 1) % ICONS.length)
129 }, SWITCH_INTERVAL)
130 return () => clearInterval(timer)
131 }, [])
132
133 const currentIcon = ICONS[currentIndex]
134
135 return (
136 <div
137 className={cn('relative inline-flex items-center justify-center', className)}
138 style={{ width: size, height: size }}
139 >
140 {/* 呼吸闪烁动画容器 */}
141 <motion.div
142 className="absolute inset-0"
143 animate={{
144 opacity: [0.5, 1, 0.5],
145 }}
146 transition={{
147 opacity: {
148 duration: 2,
149 repeat: Infinity,
150 ease: 'easeInOut',
151 },
152 }}
153 >
154 <motion.svg
155 viewBox="0 0 24 24"
156 fill="none"
157 stroke={iconPaint}
158 strokeWidth={2}
159 strokeLinecap="round"
160 strokeLinejoin="round"
161 className="absolute inset-0"
162 style={{ width: size, height: size }}
163 >
164 {!color && (
165 <defs>
166 <linearGradient id={gradientId} x1="2" y1="12" x2="22" y2="12" gradientUnits="userSpaceOnUse">
167 <stop stopColor="var(--brand-purple)" />
168 <stop offset="1" stopColor="var(--brand-cyan)" />
169 </linearGradient>
170 </defs>
171 )}
172
173 {/* 不使用 mode="wait",新旧图标重叠过渡避免闪烁 */}
174 <AnimatePresence>
175 <motion.g key={currentIcon.name} initial="hidden" animate="visible" exit="exit">
176 {/* 路径元素 - 描边绘制动画 */}
177 {currentIcon.paths?.map((d, i) => (
178 <motion.path
179 key={`path-${i}`}
180 d={d}
181 fill="none"
182 variants={createDrawVariants(i)}
183 />
184 ))}
185
186 {/* 圆形元素 */}
187 {currentIcon.circles?.map((c, i) => {
188 const isSmall = 'isSmall' in c && c.isSmall
189 return isSmall ? (
190 // 小圆点使用 scale 动画
191 <motion.circle
192 key={`circle-${i}`}
193 cx={c.cx}
194 cy={c.cy}
195 r={c.r}
196 fill={iconPaint}
197 stroke="none"
198 variants={smallCircleVariants}
199 />
200 ) : (
201 // 大圆使用 pathLength 描边动画
202 <motion.circle
203 key={`circle-${i}`}
204 cx={c.cx}
205 cy={c.cy}
206 r={c.r}
207 fill="none"
208 variants={createCircleDrawVariants(i)}
209 />
210 )
211 })}
212 </motion.g>
213 </AnimatePresence>
214 </motion.svg>
215 </motion.div>
216 </div>
217 )
218 })
219
220 MorphingIcon.displayName = 'MorphingIcon'
221
221 lines Plain Text