@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,366 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import type { CSSProperties } from 'react';
3
+
4
+ import { cn, useColorScheme, usePrefersReducedMotion } from '../../../core';
5
+
6
+ import './AsciiImage.css';
7
+
8
+ export interface AsciiImageProps {
9
+ /** 图片地址。跨域图片需服务端允许 CORS,否则无法读取像素,会退回显示原图 */
10
+ src: string;
11
+ /** 替代文本,提供给读屏软件 */
12
+ alt?: string;
13
+ className?: string;
14
+ /** 每行字符数,越大细节越多 */
15
+ columns?: number;
16
+ /** 字符梯度,从稀疏到密集排列 */
17
+ characters?: string;
18
+ /** 是否按原图像素着色 */
19
+ colored?: boolean;
20
+ /** 单色模式下的字符颜色,默认继承文字颜色;也可用 CSS 变量 --elyri-ascii-color 覆盖 */
21
+ color?: string;
22
+ /** 反转明暗映射(默认深色主题下亮处字符密集,浅色主题下暗处字符密集) */
23
+ invert?: boolean;
24
+ /** 进入视口时是否自上而下解码显现 */
25
+ animated?: boolean;
26
+ /** 显现动画时长(毫秒) */
27
+ duration?: number;
28
+ /** 是否有一道斜向高光周期性扫过 */
29
+ shimmer?: boolean;
30
+ /** 每帧随机闪烁的字符比例(0-1),0 为关闭 */
31
+ flicker?: number;
32
+ /** 指针附近的字符是否被推开、提亮并乱码 */
33
+ interactive?: boolean;
34
+ /** 指针影响半径(CSS 像素) */
35
+ radius?: number;
36
+ }
37
+
38
+ interface Cell {
39
+ /** 字符格左上角坐标 */
40
+ x: number;
41
+ y: number;
42
+ /** 字符在梯度中的下标 */
43
+ glyph: number;
44
+ r: number;
45
+ g: number;
46
+ b: number;
47
+ /** 开始显现的进度阈值 */
48
+ threshold: number;
49
+ /** 沿高光扫过方向的归一化坐标 */
50
+ diagonal: number;
51
+ }
52
+
53
+ const DEFAULT_CHARACTERS = ' .:-=+*#%@';
54
+ /** 解码时先显示乱码的进度窗口 */
55
+ const SCRAMBLE_WINDOW = 0.08;
56
+ /** 持续动效的帧间隔,字符画无需满帧 */
57
+ const FRAME_INTERVAL = 1000 / 40;
58
+ /** 高光扫过一次的周期(秒)与光带半宽 */
59
+ const SHIMMER_PERIOD = 4.5;
60
+ const SHIMMER_WIDTH = 0.07;
61
+
62
+ export function AsciiImage({
63
+ src,
64
+ alt = '',
65
+ className,
66
+ columns = 100,
67
+ characters = DEFAULT_CHARACTERS,
68
+ colored = false,
69
+ color,
70
+ invert = false,
71
+ animated = true,
72
+ duration = 1200,
73
+ shimmer = true,
74
+ flicker = 0.01,
75
+ interactive = true,
76
+ radius = 90,
77
+ }: AsciiImageProps) {
78
+ const rootRef = useRef<HTMLDivElement>(null);
79
+ const canvasRef = useRef<HTMLCanvasElement>(null);
80
+ const prefersReducedMotion = usePrefersReducedMotion();
81
+ const scheme = useColorScheme();
82
+ const [image, setImage] = useState<HTMLImageElement | null>(null);
83
+ const [failed, setFailed] = useState(false);
84
+ const [width, setWidth] = useState(0);
85
+ const [inView, setInView] = useState(false);
86
+ // 只播放一次显现动画,之后尺寸、参数或可见性变化直接进入持续动效
87
+ const playedRef = useRef(false);
88
+
89
+ useEffect(() => {
90
+ let disposed = false;
91
+ const img = new Image();
92
+ img.crossOrigin = 'anonymous';
93
+ img.decoding = 'async';
94
+ img.onload = () => {
95
+ if (disposed) return;
96
+ // 跨域图片未开启 CORS 时画布被污染,无法读取像素,改为显示原图
97
+ const probe = document.createElement('canvas').getContext('2d');
98
+ try {
99
+ probe?.drawImage(img, 0, 0, 1, 1);
100
+ probe?.getImageData(0, 0, 1, 1);
101
+ } catch {
102
+ setFailed(true);
103
+ return;
104
+ }
105
+ setFailed(false);
106
+ setImage(img);
107
+ };
108
+ img.onerror = () => {
109
+ if (!disposed) setFailed(true);
110
+ };
111
+ img.src = src;
112
+ return () => {
113
+ disposed = true;
114
+ };
115
+ }, [src]);
116
+
117
+ useEffect(() => {
118
+ const root = rootRef.current;
119
+ if (!root) return;
120
+
121
+ const resize = new ResizeObserver(([entry]) => setWidth(Math.round(entry.contentRect.width)));
122
+ // 持续跟踪可见性,离屏时停止动效
123
+ const intersection = new IntersectionObserver(([entry]) => setInView(entry.isIntersecting));
124
+ resize.observe(root);
125
+ intersection.observe(root);
126
+
127
+ return () => {
128
+ resize.disconnect();
129
+ intersection.disconnect();
130
+ };
131
+ }, []);
132
+
133
+ useEffect(() => {
134
+ const root = rootRef.current;
135
+ const canvas = canvasRef.current;
136
+ const ctx = canvas?.getContext('2d');
137
+ if (!root || !canvas || !ctx || !image || !width || !image.naturalWidth) return;
138
+
139
+ const style = getComputedStyle(root);
140
+ const ramp = Array.from(characters || DEFAULT_CHARACTERS);
141
+ const cols = Math.min(400, Math.max(8, Math.round(columns)));
142
+ const font = (size: number) => `${style.fontWeight} ${size}px ${style.fontFamily}`;
143
+
144
+ // 按等宽字体实际字宽反推字号,让每行恰好铺满容器宽度;行高等于字号
145
+ ctx.font = font(100);
146
+ const cellW = width / cols;
147
+ const fontSize = (cellW * 100) / ctx.measureText('M').width;
148
+ const cellH = fontSize;
149
+ const rows = Math.max(1, Math.round(((cols * image.naturalHeight) / image.naturalWidth) * (cellW / cellH)));
150
+ const height = rows * cellH;
151
+
152
+ // 把图片缩放到 cols × rows,每个像素对应一个字符格
153
+ const sampler = document.createElement('canvas');
154
+ sampler.width = cols;
155
+ sampler.height = rows;
156
+ const sctx = sampler.getContext('2d', { willReadFrequently: true });
157
+ if (!sctx) return;
158
+ sctx.imageSmoothingQuality = 'high';
159
+ sctx.drawImage(image, 0, 0, cols, rows);
160
+
161
+ let data: Uint8ClampedArray;
162
+ try {
163
+ data = sctx.getImageData(0, 0, cols, rows).data;
164
+ } catch {
165
+ // 加载时已校验过可读性,这里仅作兜底
166
+ return;
167
+ }
168
+
169
+ // 深色主题下亮处字符密集,浅色主题下暗处字符密集,保证字符与背景形成对比
170
+ const light = scheme === 'light';
171
+ const flip = light !== invert;
172
+ // 只保留非空白格:透明或最稀疏处始终留白,动效不会越出图形轮廓
173
+ const cells: Cell[] = [];
174
+ for (let i = 0; i < cols * rows; i++) {
175
+ const r = data[i * 4];
176
+ const g = data[i * 4 + 1];
177
+ const b = data[i * 4 + 2];
178
+ const a = data[i * 4 + 3] / 255;
179
+ const lum = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
180
+ const glyph = Math.min(ramp.length - 1, Math.floor((flip ? 1 - lum : lum) * a * ramp.length));
181
+ if (!ramp[glyph].trim()) continue;
182
+ const col = i % cols;
183
+ const row = Math.floor(i / cols);
184
+ cells.push({
185
+ x: col * cellW,
186
+ y: row * cellH,
187
+ glyph,
188
+ r,
189
+ g,
190
+ b,
191
+ // 自上而下显现,并带少许随机错落
192
+ threshold: (row / rows) * (1 - SCRAMBLE_WINDOW - 0.2) + Math.random() * 0.2,
193
+ diagonal: (col / cols + row / rows) / 2,
194
+ });
195
+ }
196
+
197
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
198
+ canvas.width = Math.round(width * dpr);
199
+ canvas.height = Math.round(height * dpr);
200
+ canvas.style.height = `${height}px`;
201
+
202
+ const noise = ramp.filter((char) => char.trim());
203
+ const fill = style.getPropertyValue('--elyri-ascii-color').trim() || style.color;
204
+ // 高光在深色主题下偏白,浅色主题下偏黑,始终朝着更醒目的方向
205
+ const highlight = light ? 0 : 255;
206
+ const lift = Math.min(3, ramp.length - 1);
207
+ const flickerRate = Math.min(1, Math.max(0, flicker));
208
+ const reach = Math.max(1, radius);
209
+ const motion = !prefersReducedMotion;
210
+ const lens = motion && interactive;
211
+ const pointer = { x: 0, y: 0, strength: 0, target: 0, tx: 0, ty: 0 };
212
+
213
+ const draw = (progress: number, time: number) => {
214
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
215
+ ctx.clearRect(0, 0, width, height);
216
+ ctx.font = font(fontSize);
217
+ ctx.textBaseline = 'top';
218
+ ctx.fillStyle = fill;
219
+
220
+ // 光带中心从左上外侧扫到右下外侧,之后留一段空档
221
+ const band = motion && shimmer ? ((time / SHIMMER_PERIOD) % 1) * 1.8 - 0.4 : -10;
222
+
223
+ for (const cell of cells) {
224
+ let char = ramp[cell.glyph];
225
+ if (progress < 1) {
226
+ if (progress < cell.threshold) continue;
227
+ // 刚进入显现窗口的格子先闪烁随机字符,再定格
228
+ if (progress < cell.threshold + SCRAMBLE_WINDOW) char = noise[Math.floor(Math.random() * noise.length)];
229
+ }
230
+
231
+ const offset = (cell.diagonal - band) / SHIMMER_WIDTH;
232
+ let boost = offset > -3 && offset < 3 ? Math.exp(-offset * offset) : 0;
233
+ let x = cell.x;
234
+ let y = cell.y;
235
+
236
+ if (pointer.strength > 0.01) {
237
+ const dx = x + cellW / 2 - pointer.x;
238
+ const dy = y + cellH / 2 - pointer.y;
239
+ const d = Math.hypot(dx, dy);
240
+ if (d < reach) {
241
+ const t = (1 - d / reach) * pointer.strength;
242
+ // 指针处的字符被推开,形成一圈凸起的透镜
243
+ const push = t * t * cellW * 2.2;
244
+ if (d > 0.01) {
245
+ x += (dx / d) * push;
246
+ y += (dy / d) * push;
247
+ }
248
+ boost = Math.max(boost, t);
249
+ if (Math.random() < t * 0.35) char = noise[Math.floor(Math.random() * noise.length)];
250
+ }
251
+ }
252
+
253
+ if (boost > 0.05) char = ramp[Math.min(ramp.length - 1, cell.glyph + Math.round(boost * lift))];
254
+ if (flickerRate && Math.random() < flickerRate) char = noise[Math.floor(Math.random() * noise.length)];
255
+
256
+ ctx.globalAlpha = 0.82 + 0.18 * boost;
257
+ if (colored) {
258
+ const mix = boost * 0.6;
259
+ ctx.fillStyle = `rgb(${cell.r + (highlight - cell.r) * mix},${cell.g + (highlight - cell.g) * mix},${cell.b + (highlight - cell.b) * mix})`;
260
+ }
261
+ ctx.fillText(char, x, y);
262
+ }
263
+ ctx.globalAlpha = 1;
264
+ };
265
+
266
+ // 减弱动态效果时只画静止成品
267
+ if (!motion) {
268
+ draw(1, 0);
269
+ return;
270
+ }
271
+ const reveal = animated && !playedRef.current;
272
+ // 未进入视口前:需要显现的保持空白,否则画出成品
273
+ if (!inView) {
274
+ draw(reveal ? 0 : 1, 0);
275
+ return;
276
+ }
277
+
278
+ const continuous = shimmer || flickerRate > 0 || lens;
279
+ const start = performance.now();
280
+ let frame = 0;
281
+ let last = 0;
282
+
283
+ const tick = (now: number) => {
284
+ frame = requestAnimationFrame(tick);
285
+ if (now - last < FRAME_INTERVAL) return;
286
+ const dt = last ? Math.min((now - last) / 1000, 0.1) : 0;
287
+ last = now;
288
+
289
+ const progress = reveal ? Math.min(1, (now - start) / Math.max(1, duration)) : 1;
290
+ if (progress >= 1) playedRef.current = true;
291
+
292
+ // 指针位置与强度都做平滑,进出时透镜渐显渐隐
293
+ const ease = 1 - Math.exp(-dt * 12);
294
+ pointer.x += (pointer.tx - pointer.x) * ease;
295
+ pointer.y += (pointer.ty - pointer.y) * ease;
296
+ pointer.strength += (pointer.target - pointer.strength) * (1 - Math.exp(-dt * 6));
297
+
298
+ draw(progress, (now - start) / 1000);
299
+ if (!continuous && progress >= 1) cancelAnimationFrame(frame);
300
+ };
301
+ frame = requestAnimationFrame(tick);
302
+
303
+ const handlePointerMove = (event: PointerEvent) => {
304
+ const rect = canvas.getBoundingClientRect();
305
+ const x = event.clientX - rect.left;
306
+ const y = event.clientY - rect.top;
307
+ const inside = x >= -reach && y >= -reach && x <= rect.width + reach && y <= rect.height + reach;
308
+ // 首次进入时直接跳到指针处,避免透镜从角落滑过来
309
+ if (inside && pointer.target === 0 && pointer.strength < 0.01) {
310
+ pointer.x = x;
311
+ pointer.y = y;
312
+ }
313
+ pointer.tx = x;
314
+ pointer.ty = y;
315
+ pointer.target = inside ? 1 : 0;
316
+ };
317
+ const handlePointerLeave = () => {
318
+ pointer.target = 0;
319
+ };
320
+ if (lens) {
321
+ window.addEventListener('pointermove', handlePointerMove, { passive: true });
322
+ document.documentElement.addEventListener('pointerleave', handlePointerLeave);
323
+ }
324
+
325
+ return () => {
326
+ cancelAnimationFrame(frame);
327
+ window.removeEventListener('pointermove', handlePointerMove);
328
+ document.documentElement.removeEventListener('pointerleave', handlePointerLeave);
329
+ };
330
+ }, [
331
+ image,
332
+ width,
333
+ columns,
334
+ characters,
335
+ colored,
336
+ color,
337
+ invert,
338
+ scheme,
339
+ animated,
340
+ duration,
341
+ shimmer,
342
+ flicker,
343
+ interactive,
344
+ radius,
345
+ inView,
346
+ prefersReducedMotion,
347
+ ]);
348
+
349
+ const style = (color ? { '--elyri-ascii-color': color } : undefined) as CSSProperties | undefined;
350
+
351
+ return (
352
+ <div ref={rootRef} className={cn('elyri-ascii-image', className)} style={style}>
353
+ {failed ? (
354
+ <img className="elyri-ascii-image__fallback" src={src} alt={alt} />
355
+ ) : (
356
+ <canvas
357
+ ref={canvasRef}
358
+ className="elyri-ascii-image__canvas"
359
+ role={alt ? 'img' : undefined}
360
+ aria-label={alt || undefined}
361
+ aria-hidden={alt ? undefined : true}
362
+ />
363
+ )}
364
+ </div>
365
+ );
366
+ }
@@ -0,0 +1,2 @@
1
+ export { AsciiImage } from './AsciiImage';
2
+ export type { AsciiImageProps } from './AsciiImage';
@@ -0,0 +1,2 @@
1
+ export { AsciiImage } from './AsciiImage';
2
+ export type { AsciiImageProps } from './AsciiImage';
@@ -0,0 +1,32 @@
1
+ .elyri-gradient-text {
2
+ --elyri-gradient-colors: #6d5cff, #22d3ee, #f472b6;
3
+
4
+ background-image: linear-gradient(90deg, var(--elyri-gradient-colors));
5
+ background-clip: text;
6
+ -webkit-background-clip: text;
7
+ color: transparent;
8
+ -webkit-text-fill-color: transparent;
9
+ }
10
+
11
+ .elyri-gradient-text--animated {
12
+ background-size: 200% 100%;
13
+ /* --elyri-gradient-speed 为速度倍率,越大越快 */
14
+ animation: elyri-gradient-text-flow calc(var(--elyri-duration-slow) / var(--elyri-gradient-speed, 1))
15
+ var(--elyri-ease-in-out) infinite alternate;
16
+ }
17
+
18
+ @keyframes elyri-gradient-text-flow {
19
+ from {
20
+ background-position: 0% 50%;
21
+ }
22
+
23
+ to {
24
+ background-position: 100% 50%;
25
+ }
26
+ }
27
+
28
+ @media (prefers-reduced-motion: reduce) {
29
+ .elyri-gradient-text--animated {
30
+ animation: none;
31
+ }
32
+ }
@@ -0,0 +1,27 @@
1
+ import type { CSSProperties, ReactNode } from 'react';
2
+
3
+ import { cn } from '../../../core';
4
+
5
+ import './GradientText.css';
6
+
7
+ export interface GradientTextProps {
8
+ children: ReactNode;
9
+ className?: string;
10
+ colors?: string[];
11
+ animated?: boolean;
12
+ /** 流动动画速度倍率 */
13
+ speed?: number;
14
+ }
15
+
16
+ export function GradientText({ children, className, colors, animated = true, speed = 0.5 }: GradientTextProps) {
17
+ const style = {
18
+ ...(colors?.length ? { '--elyri-gradient-colors': colors.join(', ') } : null),
19
+ '--elyri-gradient-speed': speed,
20
+ } as CSSProperties;
21
+
22
+ return (
23
+ <span className={cn('elyri-gradient-text', animated && 'elyri-gradient-text--animated', className)} style={style}>
24
+ {children}
25
+ </span>
26
+ );
27
+ }
@@ -0,0 +1,2 @@
1
+ export { GradientText } from './GradientText';
2
+ export type { GradientTextProps } from './GradientText';
@@ -0,0 +1,91 @@
1
+ .elyri-marquee {
2
+ --elyri-marquee-duration: 20s;
3
+ --elyri-marquee-repeat: 2;
4
+ --elyri-marquee-gap: 0px;
5
+
6
+ width: 100%;
7
+ overflow: hidden;
8
+ /* 隔离绘制,内容再重也不会波及页面其他部分(注意:不能用 layout 包含,否则自动高度会塌陷) */
9
+ contain: paint;
10
+ }
11
+
12
+ /* 轨道宽度由内容撑开,整体平移一份内容宽度后与起点重合,从而实现无缝循环 */
13
+ .elyri-marquee__track {
14
+ display: flex;
15
+ width: max-content;
16
+ animation: elyri-marquee-x var(--elyri-marquee-duration) linear infinite;
17
+ will-change: transform;
18
+ }
19
+
20
+ .elyri-marquee__copy {
21
+ display: flex;
22
+ flex: none;
23
+ /* 份内元素之间按间距排列 */
24
+ gap: var(--elyri-marquee-gap);
25
+ /* 再补一份尾随间距,使相邻两份内容之间的间隔与份内一致,且循环周期正好是整份内容宽度 */
26
+ padding-right: var(--elyri-marquee-gap);
27
+ }
28
+
29
+ @keyframes elyri-marquee-x {
30
+ from {
31
+ transform: translate3d(0, 0, 0);
32
+ }
33
+ to {
34
+ transform: translate3d(calc(-100% / var(--elyri-marquee-repeat)), 0, 0);
35
+ }
36
+ }
37
+
38
+ /* 纵向滚动改为上下平移,轨道沿列方向排列 */
39
+ .elyri-marquee--vertical .elyri-marquee__track {
40
+ flex-direction: column;
41
+ width: auto;
42
+ height: max-content;
43
+ animation-name: elyri-marquee-y;
44
+ }
45
+
46
+ .elyri-marquee--vertical .elyri-marquee__copy {
47
+ padding-right: 0;
48
+ padding-bottom: var(--elyri-marquee-gap);
49
+ }
50
+
51
+ @keyframes elyri-marquee-y {
52
+ from {
53
+ transform: translate3d(0, 0, 0);
54
+ }
55
+ to {
56
+ transform: translate3d(0, calc(-100% / var(--elyri-marquee-repeat)), 0);
57
+ }
58
+ }
59
+
60
+ /* 反向播放即可实现向右 / 向下滚动 */
61
+ .elyri-marquee--reverse .elyri-marquee__track {
62
+ animation-direction: reverse;
63
+ }
64
+
65
+ /* 关闭无限重复时只播放一轮 */
66
+ .elyri-marquee--once .elyri-marquee__track {
67
+ animation-iteration-count: 1;
68
+ }
69
+
70
+ .elyri-marquee--pausable:hover .elyri-marquee__track {
71
+ animation-play-state: paused;
72
+ }
73
+
74
+ /* 离开视口或页面切到后台时暂停,避免无谓的合成开销与耗电 */
75
+ .elyri-marquee--paused .elyri-marquee__track {
76
+ animation-play-state: paused;
77
+ }
78
+
79
+ .elyri-marquee--static .elyri-marquee__track {
80
+ animation: none;
81
+ transform: none;
82
+ will-change: auto;
83
+ }
84
+
85
+ @media (prefers-reduced-motion: reduce) {
86
+ .elyri-marquee__track {
87
+ animation: none;
88
+ transform: none;
89
+ will-change: auto;
90
+ }
91
+ }
@@ -0,0 +1,130 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import type { CSSProperties, ReactNode } from 'react';
3
+
4
+ import { cn, usePrefersReducedMotion } from '../../../core';
5
+
6
+ import './Marquee.css';
7
+
8
+ export type MarqueeDirection = 'left' | 'right' | 'up' | 'down';
9
+
10
+ export interface MarqueeProps {
11
+ /** 循环滚动的内容,任意节点,会被复制多份以形成无缝循环 */
12
+ children: ReactNode;
13
+ className?: string;
14
+ /** 滚动方向 */
15
+ direction?: MarqueeDirection;
16
+ /** 跑完一轮所需的秒数,数值越大越慢 */
17
+ duration?: number;
18
+ /** 最少重复份数;实际份数会按容器尺寸自动补足,保证循环无缝 */
19
+ repeat?: number;
20
+ /** 每份内容之间的间距,单位 px */
21
+ gap?: number;
22
+ /** 是否无限重复播放,关闭时只播放一轮 */
23
+ infinite?: boolean;
24
+ /** 鼠标悬停时是否暂停 */
25
+ pauseOnHover?: boolean;
26
+ }
27
+
28
+ export function Marquee({
29
+ children,
30
+ className,
31
+ direction = 'left',
32
+ duration = 20,
33
+ repeat = 2,
34
+ gap = 0,
35
+ infinite = true,
36
+ pauseOnHover = true,
37
+ }: MarqueeProps) {
38
+ const rootRef = useRef<HTMLDivElement>(null);
39
+ const trackRef = useRef<HTMLDivElement>(null);
40
+ const [paused, setPaused] = useState(false);
41
+ const prefersReducedMotion = usePrefersReducedMotion();
42
+ const minCopies = Math.max(2, Math.floor(repeat));
43
+ const [copies, setCopies] = useState(minCopies);
44
+ const vertical = direction === 'up' || direction === 'down';
45
+ const reverse = direction === 'right' || direction === 'down';
46
+
47
+ // 份数不足时轨道会短于容器,循环到末尾会露白;按容器尺寸补足份数
48
+ useEffect(() => {
49
+ const root = rootRef.current;
50
+ const track = trackRef.current;
51
+ if (!root || !track) return;
52
+
53
+ const measure = () => {
54
+ const first = track.firstElementChild as HTMLElement | null;
55
+ if (!first) return;
56
+
57
+ const copySize = vertical ? first.offsetHeight : first.offsetWidth;
58
+ const rootSize = vertical ? root.clientHeight : root.clientWidth;
59
+ const trackSize = vertical ? track.offsetHeight : track.offsetWidth;
60
+ if (!copySize) return;
61
+
62
+ // 纵向未限制高度时容器与轨道等高、没有可视窗口,保持最小份数
63
+ if (vertical && rootSize >= trackSize) {
64
+ setCopies(minCopies);
65
+ return;
66
+ }
67
+
68
+ // 轨道至少覆盖「容器 + 一份内容」,这样每一帧都有内容填满窗口
69
+ setCopies(Math.max(minCopies, Math.ceil((rootSize + copySize) / copySize)));
70
+ };
71
+
72
+ const observer = new ResizeObserver(measure);
73
+ observer.observe(root);
74
+ if (track.firstElementChild) observer.observe(track.firstElementChild);
75
+ return () => observer.disconnect();
76
+ }, [vertical, minCopies]);
77
+
78
+ // 离开视口或页面切到后台时暂停动画,避免无谓的合成开销与耗电
79
+ useEffect(() => {
80
+ const node = rootRef.current;
81
+ if (!node || prefersReducedMotion) return;
82
+
83
+ let inView = true;
84
+ const sync = () => setPaused(!inView || document.hidden);
85
+
86
+ const observer = new IntersectionObserver(([entry]) => {
87
+ inView = entry.isIntersecting;
88
+ sync();
89
+ });
90
+ observer.observe(node);
91
+ document.addEventListener('visibilitychange', sync);
92
+
93
+ return () => {
94
+ observer.disconnect();
95
+ document.removeEventListener('visibilitychange', sync);
96
+ };
97
+ }, [prefersReducedMotion]);
98
+
99
+ return (
100
+ <div
101
+ ref={rootRef}
102
+ className={cn(
103
+ 'elyri-marquee',
104
+ vertical && 'elyri-marquee--vertical',
105
+ reverse && 'elyri-marquee--reverse',
106
+ !infinite && 'elyri-marquee--once',
107
+ paused && 'elyri-marquee--paused',
108
+ pauseOnHover && 'elyri-marquee--pausable',
109
+ prefersReducedMotion && 'elyri-marquee--static',
110
+ className,
111
+ )}
112
+ style={
113
+ {
114
+ '--elyri-marquee-duration': `${duration}s`,
115
+ '--elyri-marquee-repeat': copies,
116
+ '--elyri-marquee-gap': `${gap}px`,
117
+ } as CSSProperties
118
+ }
119
+ >
120
+ <div ref={trackRef} className="elyri-marquee__track">
121
+ {Array.from({ length: copies }, (_, index) => (
122
+ // 复制份仅用于视觉衔接,对辅助技术隐藏,避免重复朗读
123
+ <div key={index} className="elyri-marquee__copy" aria-hidden={index > 0 ? true : undefined}>
124
+ {children}
125
+ </div>
126
+ ))}
127
+ </div>
128
+ </div>
129
+ );
130
+ }
@@ -0,0 +1,2 @@
1
+ export { Marquee } from './Marquee';
2
+ export type { MarqueeDirection, MarqueeProps } from './Marquee';
@@ -0,0 +1,16 @@
1
+ .elyri-particle-text {
2
+ position: relative;
3
+ display: inline-block;
4
+ /* 按原文排版,仅在 \n 处换行,保证画布采样与占位尺寸一致 */
5
+ white-space: pre;
6
+ }
7
+
8
+ /* 粒子就绪后原文只占位不显示,仍可被读屏软件读取 */
9
+ .elyri-particle-text--ready .elyri-particle-text__label {
10
+ color: transparent;
11
+ }
12
+
13
+ .elyri-particle-text__canvas {
14
+ position: absolute;
15
+ pointer-events: none;
16
+ }