@elyri/motion 0.0.0-stage → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +70 -2
  3. package/dist/chunk-A7JCTGZO.js +230 -0
  4. package/dist/chunk-A7JCTGZO.js.map +1 -0
  5. package/dist/core.cjs +238 -0
  6. package/dist/core.cjs.map +1 -0
  7. package/dist/core.d.cts +77 -0
  8. package/dist/core.d.ts +77 -0
  9. package/dist/core.js +3 -0
  10. package/dist/core.js.map +1 -0
  11. package/dist/index.cjs +4288 -0
  12. package/dist/index.cjs.map +1 -0
  13. package/dist/index.css +1118 -0
  14. package/dist/index.css.map +1 -0
  15. package/dist/index.d.cts +532 -0
  16. package/dist/index.d.ts +532 -0
  17. package/dist/index.js +4029 -0
  18. package/dist/index.js.map +1 -0
  19. package/package.json +60 -3
  20. package/src/components/animations/CountUp/CountUp.css +4 -0
  21. package/src/components/animations/CountUp/CountUp.tsx +100 -0
  22. package/src/components/animations/CountUp/index.ts +2 -0
  23. package/src/components/animations/FadeIn/FadeIn.css +43 -0
  24. package/src/components/animations/FadeIn/FadeIn.tsx +72 -0
  25. package/src/components/animations/FadeIn/index.ts +2 -0
  26. package/src/components/animations/Magnetic/Magnetic.css +11 -0
  27. package/src/components/animations/Magnetic/Magnetic.tsx +107 -0
  28. package/src/components/animations/Magnetic/index.ts +2 -0
  29. package/src/components/animations/ScaleIn/ScaleIn.css +24 -0
  30. package/src/components/animations/ScaleIn/ScaleIn.tsx +65 -0
  31. package/src/components/animations/ScaleIn/index.ts +2 -0
  32. package/src/components/animations/Spotlight/Spotlight.css +93 -0
  33. package/src/components/animations/Spotlight/Spotlight.tsx +100 -0
  34. package/src/components/animations/Spotlight/index.ts +2 -0
  35. package/src/components/animations/Tilt/Tilt.css +35 -0
  36. package/src/components/animations/Tilt/Tilt.tsx +123 -0
  37. package/src/components/animations/Tilt/index.ts +2 -0
  38. package/src/components/animations/index.ts +12 -0
  39. package/src/components/backgrounds/Aurora/Aurora.css +33 -0
  40. package/src/components/backgrounds/Aurora/Aurora.tsx +147 -0
  41. package/src/components/backgrounds/Aurora/index.ts +2 -0
  42. package/src/components/backgrounds/Caustics/Caustics.css +34 -0
  43. package/src/components/backgrounds/Caustics/Caustics.tsx +142 -0
  44. package/src/components/backgrounds/Caustics/index.ts +2 -0
  45. package/src/components/backgrounds/Contours/Contours.css +42 -0
  46. package/src/components/backgrounds/Contours/Contours.tsx +147 -0
  47. package/src/components/backgrounds/Contours/index.ts +2 -0
  48. package/src/components/backgrounds/DotField/DotField.css +44 -0
  49. package/src/components/backgrounds/DotField/DotField.tsx +135 -0
  50. package/src/components/backgrounds/DotField/index.ts +2 -0
  51. package/src/components/backgrounds/DragonScales/DragonScales.css +44 -0
  52. package/src/components/backgrounds/DragonScales/DragonScales.tsx +250 -0
  53. package/src/components/backgrounds/DragonScales/index.ts +2 -0
  54. package/src/components/backgrounds/Filaments/Filaments.css +38 -0
  55. package/src/components/backgrounds/Filaments/Filaments.tsx +138 -0
  56. package/src/components/backgrounds/Filaments/index.ts +2 -0
  57. package/src/components/backgrounds/FlipTiles/FlipTiles.css +44 -0
  58. package/src/components/backgrounds/FlipTiles/FlipTiles.tsx +368 -0
  59. package/src/components/backgrounds/FlipTiles/index.ts +2 -0
  60. package/src/components/backgrounds/Halo/Halo.css +43 -0
  61. package/src/components/backgrounds/Halo/Halo.tsx +122 -0
  62. package/src/components/backgrounds/Halo/index.ts +2 -0
  63. package/src/components/backgrounds/MeshGradient/MeshGradient.css +43 -0
  64. package/src/components/backgrounds/MeshGradient/MeshGradient.tsx +195 -0
  65. package/src/components/backgrounds/MeshGradient/index.ts +2 -0
  66. package/src/components/backgrounds/PixelVortex/PixelVortex.css +44 -0
  67. package/src/components/backgrounds/PixelVortex/PixelVortex.tsx +278 -0
  68. package/src/components/backgrounds/PixelVortex/index.ts +2 -0
  69. package/src/components/backgrounds/Planet/Planet.css +39 -0
  70. package/src/components/backgrounds/Planet/Planet.tsx +736 -0
  71. package/src/components/backgrounds/Planet/index.ts +2 -0
  72. package/src/components/backgrounds/SilkWaves/SilkWaves.css +44 -0
  73. package/src/components/backgrounds/SilkWaves/SilkWaves.tsx +144 -0
  74. package/src/components/backgrounds/SilkWaves/index.ts +2 -0
  75. package/src/components/backgrounds/Velvet/Velvet.css +35 -0
  76. package/src/components/backgrounds/Velvet/Velvet.tsx +246 -0
  77. package/src/components/backgrounds/Velvet/index.ts +2 -0
  78. package/src/components/backgrounds/index.ts +26 -0
  79. package/src/components/index.ts +4 -0
  80. package/src/components/media/AsciiImage/AsciiImage.css +16 -0
  81. package/src/components/media/AsciiImage/AsciiImage.tsx +366 -0
  82. package/src/components/media/AsciiImage/index.ts +2 -0
  83. package/src/components/media/index.ts +2 -0
  84. package/src/components/text/GradientText/GradientText.css +32 -0
  85. package/src/components/text/GradientText/GradientText.tsx +27 -0
  86. package/src/components/text/GradientText/index.ts +2 -0
  87. package/src/components/text/Marquee/Marquee.css +91 -0
  88. package/src/components/text/Marquee/Marquee.tsx +130 -0
  89. package/src/components/text/Marquee/index.ts +2 -0
  90. package/src/components/text/ParticleText/ParticleText.css +16 -0
  91. package/src/components/text/ParticleText/ParticleText.tsx +279 -0
  92. package/src/components/text/ParticleText/index.ts +2 -0
  93. package/src/components/text/RotatingText/RotatingText.css +133 -0
  94. package/src/components/text/RotatingText/RotatingText.tsx +112 -0
  95. package/src/components/text/RotatingText/index.ts +2 -0
  96. package/src/components/text/ScrambleText/ScrambleText.css +48 -0
  97. package/src/components/text/ScrambleText/ScrambleText.tsx +141 -0
  98. package/src/components/text/ScrambleText/index.ts +2 -0
  99. package/src/components/text/ScrollMarquee/ScrollMarquee.css +33 -0
  100. package/src/components/text/ScrollMarquee/ScrollMarquee.tsx +110 -0
  101. package/src/components/text/ScrollMarquee/index.ts +2 -0
  102. package/src/components/text/ScrollReveal/ScrollReveal.css +51 -0
  103. package/src/components/text/ScrollReveal/ScrollReveal.tsx +186 -0
  104. package/src/components/text/ScrollReveal/index.ts +2 -0
  105. package/src/components/text/SplitReveal/SplitReveal.css +52 -0
  106. package/src/components/text/SplitReveal/SplitReveal.tsx +127 -0
  107. package/src/components/text/SplitReveal/index.ts +2 -0
  108. package/src/components/text/Typewriter/Typewriter.css +37 -0
  109. package/src/components/text/Typewriter/Typewriter.tsx +80 -0
  110. package/src/components/text/Typewriter/index.ts +2 -0
  111. package/src/components/text/WaveText/WaveText.css +28 -0
  112. package/src/components/text/WaveText/WaveText.tsx +52 -0
  113. package/src/components/text/WaveText/index.ts +2 -0
  114. package/src/components/text/index.ts +20 -0
@@ -0,0 +1,279 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import type { CSSProperties } from 'react';
3
+
4
+ import { cn, useColorScheme, usePrefersReducedMotion } from '../../../core';
5
+
6
+ import './ParticleText.css';
7
+
8
+ export interface ParticleTextProps {
9
+ /** 由粒子组成的文本,仅支持纯字符串,可用 \n 换行 */
10
+ children: string;
11
+ className?: string;
12
+ /** 粒子颜色,默认继承文字颜色;也可用 CSS 变量 --elyri-particle-color 覆盖 */
13
+ color?: string;
14
+ /** 单个粒子边长(CSS 像素) */
15
+ particleSize?: number;
16
+ /** 采样间距(CSS 像素),越小粒子越密 */
17
+ gap?: number;
18
+ /** 指针排斥半径(CSS 像素),0 为关闭交互 */
19
+ radius?: number;
20
+ /** 排斥力度倍率 */
21
+ force?: number;
22
+ /** 首次进入视口时粒子是否从四周飞入聚合成字 */
23
+ assemble?: boolean;
24
+ }
25
+
26
+ interface Particle {
27
+ x: number;
28
+ y: number;
29
+ vx: number;
30
+ vy: number;
31
+ tx: number;
32
+ ty: number;
33
+ /** 回弹刚度,每颗略有差异,聚合时先后到位更自然 */
34
+ k: number;
35
+ }
36
+
37
+ type Context2D = CanvasRenderingContext2D & { letterSpacing?: string };
38
+
39
+ const FRICTION = 0.86;
40
+
41
+ export function ParticleText({
42
+ children,
43
+ className,
44
+ color,
45
+ particleSize = 2,
46
+ gap = 3,
47
+ radius = 80,
48
+ force = 1,
49
+ assemble = true,
50
+ }: ParticleTextProps) {
51
+ const rootRef = useRef<HTMLSpanElement>(null);
52
+ const canvasRef = useRef<HTMLCanvasElement>(null);
53
+ const prefersReducedMotion = usePrefersReducedMotion();
54
+ const scheme = useColorScheme();
55
+ const [ready, setReady] = useState(false);
56
+
57
+ // 跨重建保留粒子,字体加载、尺寸或文本变化时从当前位置流向新字形,而不是重新播放聚合
58
+ const particlesRef = useRef<Particle[]>([]);
59
+ const padRef = useRef(0);
60
+ const optionsRef = useRef({ particleSize, force });
61
+ const wakeRef = useRef<(() => void) | null>(null);
62
+
63
+ useEffect(() => {
64
+ optionsRef.current = { particleSize, force };
65
+ wakeRef.current?.();
66
+ }, [particleSize, force, color, scheme]);
67
+
68
+ useEffect(() => {
69
+ const root = rootRef.current;
70
+ const canvas = canvasRef.current;
71
+ const ctx = canvas?.getContext('2d');
72
+ if (!root || !canvas || !ctx) return;
73
+
74
+ const style = getComputedStyle(root);
75
+ const interactive = radius > 0 && !prefersReducedMotion;
76
+ const pointer = { x: 0, y: 0, active: false };
77
+ let width = 0;
78
+ let height = 0;
79
+ let dpr = 1;
80
+ let visible = false;
81
+ let running = false;
82
+ let frame = 0;
83
+ let buildFrame = 0;
84
+ let last = 0;
85
+ let lastColor = '';
86
+
87
+ const build = () => {
88
+ const w = root.offsetWidth;
89
+ const h = root.offsetHeight;
90
+ if (!w || !h) return;
91
+
92
+ // 画布四周留出排斥半径,被推开的粒子不会被裁掉
93
+ const pad = Math.ceil(Math.max(radius, 0) + 16);
94
+ dpr = Math.min(window.devicePixelRatio || 1, 2);
95
+ width = w + pad * 2;
96
+ height = h + pad * 2;
97
+ canvas.width = Math.round(width * dpr);
98
+ canvas.height = Math.round(height * dpr);
99
+ canvas.style.width = `${width}px`;
100
+ canvas.style.height = `${height}px`;
101
+ canvas.style.left = canvas.style.top = `${-pad}px`;
102
+
103
+ // 在离屏画布上按元素的实际字体绘制文字,再按间距采样不透明像素作为粒子目标点
104
+ const sampler = document.createElement('canvas');
105
+ sampler.width = Math.ceil(w);
106
+ sampler.height = Math.ceil(h);
107
+ const sctx = sampler.getContext('2d', { willReadFrequently: true }) as Context2D | null;
108
+ if (!sctx) return;
109
+
110
+ sctx.font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
111
+ if ('letterSpacing' in sctx) sctx.letterSpacing = style.letterSpacing === 'normal' ? '0px' : style.letterSpacing;
112
+ const align = style.textAlign;
113
+ const alignRight = align === 'right' || align === 'end';
114
+ sctx.textAlign = align === 'center' ? 'center' : alignRight ? 'right' : 'left';
115
+ const originX = align === 'center' ? w / 2 : alignRight ? w : 0;
116
+
117
+ const lines = children.split('\n');
118
+ const lineHeight = h / lines.length;
119
+ lines.forEach((line, index) => {
120
+ const m = sctx.measureText(line);
121
+ const ascent = m.fontBoundingBoxAscent ?? m.actualBoundingBoxAscent;
122
+ const descent = m.fontBoundingBoxDescent ?? m.actualBoundingBoxDescent;
123
+ sctx.fillText(line, originX, index * lineHeight + (lineHeight - ascent - descent) / 2 + ascent);
124
+ });
125
+
126
+ const data = sctx.getImageData(0, 0, sampler.width, sampler.height).data;
127
+ const step = Math.max(1, gap);
128
+ const targets: [number, number][] = [];
129
+ for (let y = step / 2; y < sampler.height; y += step) {
130
+ for (let x = step / 2; x < sampler.width; x += step) {
131
+ if (data[(Math.floor(y) * sampler.width + Math.floor(x)) * 4 + 3] > 128) targets.push([x + pad, y + pad]);
132
+ }
133
+ }
134
+ // 打乱顺序,文本变化时粒子交错流动而非整行平移
135
+ for (let i = targets.length - 1; i > 0; i--) {
136
+ const j = Math.floor(Math.random() * (i + 1));
137
+ [targets[i], targets[j]] = [targets[j], targets[i]];
138
+ }
139
+
140
+ const prev = particlesRef.current;
141
+ const shift = pad - padRef.current;
142
+ const scatter = assemble && !prefersReducedMotion && prev.length === 0;
143
+ particlesRef.current = targets.map(([tx, ty], index) => {
144
+ const k = 0.03 + Math.random() * 0.04;
145
+ if (prefersReducedMotion) return { x: tx, y: ty, vx: 0, vy: 0, tx, ty, k };
146
+ const source = prev[index] ?? (prev.length ? prev[Math.floor(Math.random() * prev.length)] : null);
147
+ if (source) return { x: source.x + shift, y: source.y + shift, vx: source.vx, vy: source.vy, tx, ty, k };
148
+ if (scatter) return { x: Math.random() * width, y: Math.random() * height, vx: 0, vy: 0, tx, ty, k };
149
+ return { x: tx, y: ty, vx: 0, vy: 0, tx, ty, k };
150
+ });
151
+ padRef.current = pad;
152
+ lastColor = '';
153
+ setReady(true);
154
+ wake();
155
+ };
156
+
157
+ const tick = (now: number) => {
158
+ const dt = Math.min((now - last) / 16.667, 3);
159
+ last = now;
160
+ const { particleSize: size, force: strength } = optionsRef.current;
161
+ const damping = Math.pow(FRICTION, dt);
162
+ const r2 = radius * radius;
163
+ const push = Math.max(0, strength) * 3 * dt;
164
+ let active = false;
165
+
166
+ for (const p of particlesRef.current) {
167
+ p.vx += (p.tx - p.x) * p.k * dt;
168
+ p.vy += (p.ty - p.y) * p.k * dt;
169
+
170
+ if (pointer.active) {
171
+ const dx = p.x - pointer.x;
172
+ const dy = p.y - pointer.y;
173
+ const d2 = dx * dx + dy * dy;
174
+ if (d2 < r2 && d2 > 0.01) {
175
+ const d = Math.sqrt(d2);
176
+ const f = (1 - d / radius) * push;
177
+ p.vx += (dx / d) * f;
178
+ p.vy += (dy / d) * f;
179
+ }
180
+ }
181
+
182
+ p.vx *= damping;
183
+ p.vy *= damping;
184
+ p.x += p.vx * dt;
185
+ p.y += p.vy * dt;
186
+ if (!active && Math.abs(p.vx) + Math.abs(p.vy) + Math.abs(p.tx - p.x) + Math.abs(p.ty - p.y) > 0.05) {
187
+ active = true;
188
+ }
189
+ }
190
+
191
+ // 优先使用 color 属性写入的 CSS 变量,否则跟随文字颜色(主题切换时自动更新)
192
+ const fill = style.getPropertyValue('--elyri-particle-color').trim() || style.color;
193
+ if (fill !== lastColor) {
194
+ lastColor = fill;
195
+ active = true;
196
+ }
197
+
198
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
199
+ ctx.clearRect(0, 0, width, height);
200
+ ctx.fillStyle = fill;
201
+ const half = size / 2;
202
+ for (const p of particlesRef.current) ctx.fillRect(p.x - half, p.y - half, size, size);
203
+
204
+ // 全部静止且指针不在附近时停帧,省电
205
+ if (visible && (active || pointer.active)) {
206
+ frame = requestAnimationFrame(tick);
207
+ } else {
208
+ running = false;
209
+ }
210
+ };
211
+
212
+ function wake() {
213
+ if (running || !visible) return;
214
+ running = true;
215
+ last = performance.now();
216
+ frame = requestAnimationFrame(tick);
217
+ }
218
+ wakeRef.current = wake;
219
+
220
+ const scheduleBuild = () => {
221
+ cancelAnimationFrame(buildFrame);
222
+ buildFrame = requestAnimationFrame(build);
223
+ };
224
+
225
+ const handlePointerMove = (event: PointerEvent) => {
226
+ const rect = canvas.getBoundingClientRect();
227
+ const x = event.clientX - rect.left;
228
+ const y = event.clientY - rect.top;
229
+ const inside = x >= 0 && y >= 0 && x <= rect.width && y <= rect.height;
230
+ if (!inside && !pointer.active) return;
231
+ pointer.x = x;
232
+ pointer.y = y;
233
+ pointer.active = inside;
234
+ wake();
235
+ };
236
+ const handlePointerLeave = () => {
237
+ pointer.active = false;
238
+ };
239
+
240
+ const intersection = new IntersectionObserver(([entry]) => {
241
+ visible = entry.isIntersecting;
242
+ if (visible) wake();
243
+ });
244
+ const resize = new ResizeObserver(scheduleBuild);
245
+ intersection.observe(root);
246
+ resize.observe(root);
247
+ document.fonts?.addEventListener('loadingdone', scheduleBuild);
248
+ if (interactive) {
249
+ window.addEventListener('pointermove', handlePointerMove, { passive: true });
250
+ document.documentElement.addEventListener('pointerleave', handlePointerLeave);
251
+ }
252
+ build();
253
+
254
+ return () => {
255
+ cancelAnimationFrame(frame);
256
+ cancelAnimationFrame(buildFrame);
257
+ intersection.disconnect();
258
+ resize.disconnect();
259
+ document.fonts?.removeEventListener('loadingdone', scheduleBuild);
260
+ window.removeEventListener('pointermove', handlePointerMove);
261
+ document.documentElement.removeEventListener('pointerleave', handlePointerLeave);
262
+ wakeRef.current = null;
263
+ };
264
+ }, [children, gap, radius, assemble, prefersReducedMotion]);
265
+
266
+ const style = (color ? { '--elyri-particle-color': color } : undefined) as CSSProperties | undefined;
267
+
268
+ return (
269
+ <span
270
+ ref={rootRef}
271
+ className={cn('elyri-particle-text', ready && 'elyri-particle-text--ready', className)}
272
+ style={style}
273
+ >
274
+ {/* 真实文本负责排版尺寸与读屏,粒子就绪后才隐藏;服务端渲染与脚本未执行时仍可见 */}
275
+ <span className="elyri-particle-text__label">{children}</span>
276
+ <canvas ref={canvasRef} className="elyri-particle-text__canvas" aria-hidden="true" />
277
+ </span>
278
+ );
279
+ }
@@ -0,0 +1,2 @@
1
+ export { ParticleText } from './ParticleText';
2
+ export type { ParticleTextProps } from './ParticleText';
@@ -0,0 +1,133 @@
1
+ .elyri-rotating-text {
2
+ display: inline-block;
3
+ position: relative;
4
+ overflow: hidden;
5
+ vertical-align: bottom;
6
+ white-space: nowrap;
7
+ perspective: 600px;
8
+ transition: width var(--elyri-rotating-text-duration, 500ms) var(--elyri-ease-in-out);
9
+ }
10
+
11
+ .elyri-rotating-text__word {
12
+ display: inline-block;
13
+ transform-style: preserve-3d;
14
+ }
15
+
16
+ .elyri-rotating-text__word--leaving {
17
+ position: absolute;
18
+ top: 0;
19
+ left: 0;
20
+ }
21
+
22
+ .elyri-rotating-text__char {
23
+ display: inline-block;
24
+ animation-duration: var(--elyri-rotating-text-duration, 500ms);
25
+ animation-delay: calc(var(--elyri-rotating-text-index) * var(--elyri-rotating-text-stagger, 30ms));
26
+ animation-fill-mode: both;
27
+ }
28
+
29
+ .elyri-rotating-text__word--entering .elyri-rotating-text__char {
30
+ animation-name: elyri-rotating-text-in;
31
+ animation-timing-function: var(--elyri-ease-out);
32
+ }
33
+
34
+ .elyri-rotating-text__word--leaving .elyri-rotating-text__char {
35
+ animation-name: elyri-rotating-text-out;
36
+ animation-timing-function: var(--elyri-ease-in-out);
37
+ }
38
+
39
+ @keyframes elyri-rotating-text-in {
40
+ from {
41
+ opacity: 0;
42
+ transform: translateY(70%) rotateX(-90deg);
43
+ filter: blur(6px);
44
+ }
45
+
46
+ to {
47
+ opacity: 1;
48
+ transform: none;
49
+ filter: blur(0);
50
+ }
51
+ }
52
+
53
+ @keyframes elyri-rotating-text-out {
54
+ from {
55
+ opacity: 1;
56
+ transform: none;
57
+ filter: blur(0);
58
+ }
59
+
60
+ to {
61
+ opacity: 0;
62
+ transform: translateY(-70%) rotateX(90deg);
63
+ filter: blur(6px);
64
+ }
65
+ }
66
+
67
+ /* 色块效果:文字包裹在圆角色块中,块宽随词平滑伸缩 */
68
+ .elyri-rotating-text--block {
69
+ --elyri-rotating-text-block-bg: #7c3aed;
70
+ box-sizing: content-box;
71
+ padding: 0.00em 0.36em;
72
+ border-radius: 0.24em;
73
+ background: var(--elyri-rotating-text-block-bg);
74
+ color: var(--elyri-rotating-text-block-color, #fff);
75
+ }
76
+
77
+ /* 离场词与块内边距对齐,保证新旧词位置一致 */
78
+ .elyri-rotating-text--block .elyri-rotating-text__word--leaving {
79
+ padding: inherit;
80
+ }
81
+
82
+ .elyri-rotating-text--block .elyri-rotating-text__word--entering .elyri-rotating-text__char {
83
+ animation-name: elyri-rotating-text-block-in;
84
+ animation-duration: calc(var(--elyri-rotating-text-duration, 500ms) * 0.65);
85
+ /* 让色块先开始伸缩,文字稍后再浮现 */
86
+ animation-delay: calc(
87
+ var(--elyri-rotating-text-duration, 500ms) * 0.3 + var(--elyri-rotating-text-index) *
88
+ var(--elyri-rotating-text-stagger, 30ms)
89
+ );
90
+ }
91
+
92
+ .elyri-rotating-text--block .elyri-rotating-text__word--leaving .elyri-rotating-text__char {
93
+ animation-name: elyri-rotating-text-block-out;
94
+ animation-duration: calc(var(--elyri-rotating-text-duration, 500ms) * 0.5);
95
+ }
96
+
97
+ @keyframes elyri-rotating-text-block-in {
98
+ from {
99
+ opacity: 0;
100
+ transform: translateY(110%) scale(0.92);
101
+ filter: blur(4px);
102
+ }
103
+
104
+ to {
105
+ opacity: 1;
106
+ transform: none;
107
+ filter: blur(0);
108
+ }
109
+ }
110
+
111
+ @keyframes elyri-rotating-text-block-out {
112
+ from {
113
+ opacity: 1;
114
+ transform: none;
115
+ filter: blur(0);
116
+ }
117
+
118
+ to {
119
+ opacity: 0;
120
+ transform: translateY(-110%) scale(0.92);
121
+ filter: blur(4px);
122
+ }
123
+ }
124
+
125
+ @media (prefers-reduced-motion: reduce) {
126
+ .elyri-rotating-text {
127
+ transition: none;
128
+ }
129
+
130
+ .elyri-rotating-text__char {
131
+ animation: none;
132
+ }
133
+ }
@@ -0,0 +1,112 @@
1
+ import { useEffect, useLayoutEffect, useRef, useState } from 'react';
2
+ import type { CSSProperties } from 'react';
3
+
4
+ import { cn, usePrefersReducedMotion } from '../../../core';
5
+
6
+ import './RotatingText.css';
7
+
8
+ /** 相邻字符进出动画的间隔(毫秒) */
9
+ const CHAR_STAGGER = 30;
10
+
11
+ export interface RotatingTextProps {
12
+ /** 轮播的词语列表,至少一个 */
13
+ words: string[];
14
+ className?: string;
15
+ /** 每个词的停留时长(毫秒) */
16
+ interval?: number;
17
+ /** 单词翻转进出的动画时长(毫秒) */
18
+ duration?: number;
19
+ /** 切换效果:flip 为 3D 翻转字符;block 为圆角色块包裹文字,块宽随词伸缩 */
20
+ variant?: 'flip' | 'block';
21
+ }
22
+
23
+ /** 词语轮换:多个词依次翻转切换,容器宽度随词宽平滑过渡 */
24
+ export function RotatingText({
25
+ words,
26
+ className,
27
+ interval = 2500,
28
+ duration = 500,
29
+ variant = 'flip',
30
+ }: RotatingTextProps) {
31
+ const [index, setIndex] = useState(0);
32
+ const [leaving, setLeaving] = useState<string | null>(null);
33
+ const [width, setWidth] = useState<number>();
34
+ const wordRef = useRef<HTMLSpanElement>(null);
35
+ const prefersReducedMotion = usePrefersReducedMotion();
36
+
37
+ // words 变化时在渲染期间直接重置,从头开始轮播
38
+ const [prevWords, setPrevWords] = useState(words);
39
+ if (prevWords !== words) {
40
+ setPrevWords(words);
41
+ setIndex(0);
42
+ setLeaving(null);
43
+ }
44
+
45
+ const count = words.length;
46
+ const current = words[index] ?? words[0] ?? '';
47
+
48
+ useEffect(() => {
49
+ if (count < 2) return;
50
+
51
+ const timer = window.setInterval(() => {
52
+ // 减弱动态效果时直接替换,不播离场动画
53
+ setLeaving(prefersReducedMotion ? null : current);
54
+ setIndex((value) => (value + 1) % count);
55
+ }, interval);
56
+
57
+ return () => window.clearInterval(timer);
58
+ }, [count, current, interval, prefersReducedMotion]);
59
+
60
+ // 离场词在动画结束后移除
61
+ useEffect(() => {
62
+ if (leaving == null) return;
63
+
64
+ const timer = window.setTimeout(() => setLeaving(null), duration + CHAR_STAGGER * leaving.length);
65
+ return () => window.clearTimeout(timer);
66
+ }, [leaving, duration]);
67
+
68
+ // 容器宽度跟随当前词宽,配合 CSS transition 平滑伸缩
69
+ useLayoutEffect(() => {
70
+ const word = wordRef.current;
71
+ if (word) setWidth(word.offsetWidth);
72
+ }, [current]);
73
+
74
+ const style = {
75
+ '--elyri-rotating-text-duration': `${duration}ms`,
76
+ width: width != null ? `${width}px` : undefined,
77
+ } as CSSProperties;
78
+
79
+ const renderChars = (word: string) =>
80
+ Array.from(word).map((char, charIndex) => (
81
+ <span
82
+ key={`${char}-${charIndex}`}
83
+ className="elyri-rotating-text__char"
84
+ style={
85
+ {
86
+ '--elyri-rotating-text-index': charIndex,
87
+ '--elyri-rotating-text-stagger': `${CHAR_STAGGER}ms`,
88
+ } as CSSProperties
89
+ }
90
+ aria-hidden="true"
91
+ >
92
+ {char === ' ' ? '\u00A0' : char}
93
+ </span>
94
+ ));
95
+
96
+ return (
97
+ <span
98
+ className={cn('elyri-rotating-text', variant !== 'flip' && `elyri-rotating-text--${variant}`, className)}
99
+ style={style}
100
+ aria-label={current}
101
+ >
102
+ <span ref={wordRef} className="elyri-rotating-text__word elyri-rotating-text__word--entering">
103
+ {renderChars(current)}
104
+ </span>
105
+ {leaving != null && (
106
+ <span className="elyri-rotating-text__word elyri-rotating-text__word--leaving" aria-hidden="true">
107
+ {renderChars(leaving)}
108
+ </span>
109
+ )}
110
+ </span>
111
+ );
112
+ }
@@ -0,0 +1,2 @@
1
+ export { RotatingText } from './RotatingText';
2
+ export type { RotatingTextProps } from './RotatingText';
@@ -0,0 +1,48 @@
1
+ .elyri-scramble-text {
2
+ --elyri-scramble-accent: #22d3ee;
3
+ --elyri-scramble-glow: 1;
4
+
5
+ position: relative;
6
+ display: inline;
7
+ /* 保留原始空白,同时允许在空白处换行 */
8
+ white-space: pre-wrap;
9
+ font-variant-numeric: tabular-nums;
10
+ }
11
+
12
+ /* 供读屏软件读取的完整文本,视觉上隐藏 */
13
+ .elyri-scramble-text__sr {
14
+ position: absolute;
15
+ width: 1px;
16
+ height: 1px;
17
+ overflow: hidden;
18
+ clip: rect(0 0 0 0);
19
+ clip-path: inset(50%);
20
+ white-space: nowrap;
21
+ }
22
+
23
+ /* 保持 inline,避免单词内部出现额外的断行机会 */
24
+ .elyri-scramble-text__char {
25
+ position: relative;
26
+ }
27
+
28
+ /* 未定格时原字符只占位不显示 */
29
+ .elyri-scramble-text__char--scrambled {
30
+ color: transparent;
31
+ }
32
+
33
+ /* 乱码字符:叠在原字符的位置上居中,脱离文档流,不影响行宽 */
34
+ .elyri-scramble-text__glyph {
35
+ position: absolute;
36
+ top: 0;
37
+ left: 50%;
38
+ transform: translateX(-50%);
39
+ white-space: pre;
40
+ pointer-events: none;
41
+ user-select: none;
42
+ color: var(--elyri-scramble-accent);
43
+ opacity: 0.85;
44
+ /* 辉光半径随强度倍率缩放,倍率为 0 时阴影与字形重合,等同关闭 */
45
+ text-shadow:
46
+ 0 0 calc(0.35em * var(--elyri-scramble-glow)) color-mix(in srgb, var(--elyri-scramble-accent) 70%, transparent),
47
+ 0 0 calc(1em * var(--elyri-scramble-glow)) color-mix(in srgb, var(--elyri-scramble-accent) 35%, transparent);
48
+ }