@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.
- package/LICENSE +21 -0
- package/README.md +70 -2
- package/dist/chunk-A7JCTGZO.js +230 -0
- package/dist/chunk-A7JCTGZO.js.map +1 -0
- package/dist/core.cjs +238 -0
- package/dist/core.cjs.map +1 -0
- package/dist/core.d.cts +77 -0
- package/dist/core.d.ts +77 -0
- package/dist/core.js +3 -0
- package/dist/core.js.map +1 -0
- package/dist/index.cjs +4288 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.css +1118 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.cts +532 -0
- package/dist/index.d.ts +532 -0
- package/dist/index.js +4029 -0
- package/dist/index.js.map +1 -0
- package/package.json +60 -3
- package/src/components/animations/CountUp/CountUp.css +4 -0
- package/src/components/animations/CountUp/CountUp.tsx +100 -0
- package/src/components/animations/CountUp/index.ts +2 -0
- package/src/components/animations/FadeIn/FadeIn.css +43 -0
- package/src/components/animations/FadeIn/FadeIn.tsx +72 -0
- package/src/components/animations/FadeIn/index.ts +2 -0
- package/src/components/animations/Magnetic/Magnetic.css +11 -0
- package/src/components/animations/Magnetic/Magnetic.tsx +107 -0
- package/src/components/animations/Magnetic/index.ts +2 -0
- package/src/components/animations/ScaleIn/ScaleIn.css +24 -0
- package/src/components/animations/ScaleIn/ScaleIn.tsx +65 -0
- package/src/components/animations/ScaleIn/index.ts +2 -0
- package/src/components/animations/Spotlight/Spotlight.css +93 -0
- package/src/components/animations/Spotlight/Spotlight.tsx +100 -0
- package/src/components/animations/Spotlight/index.ts +2 -0
- package/src/components/animations/Tilt/Tilt.css +35 -0
- package/src/components/animations/Tilt/Tilt.tsx +123 -0
- package/src/components/animations/Tilt/index.ts +2 -0
- package/src/components/animations/index.ts +12 -0
- package/src/components/backgrounds/Aurora/Aurora.css +33 -0
- package/src/components/backgrounds/Aurora/Aurora.tsx +147 -0
- package/src/components/backgrounds/Aurora/index.ts +2 -0
- package/src/components/backgrounds/Caustics/Caustics.css +34 -0
- package/src/components/backgrounds/Caustics/Caustics.tsx +142 -0
- package/src/components/backgrounds/Caustics/index.ts +2 -0
- package/src/components/backgrounds/Contours/Contours.css +42 -0
- package/src/components/backgrounds/Contours/Contours.tsx +147 -0
- package/src/components/backgrounds/Contours/index.ts +2 -0
- package/src/components/backgrounds/DotField/DotField.css +44 -0
- package/src/components/backgrounds/DotField/DotField.tsx +135 -0
- package/src/components/backgrounds/DotField/index.ts +2 -0
- package/src/components/backgrounds/DragonScales/DragonScales.css +44 -0
- package/src/components/backgrounds/DragonScales/DragonScales.tsx +250 -0
- package/src/components/backgrounds/DragonScales/index.ts +2 -0
- package/src/components/backgrounds/Filaments/Filaments.css +38 -0
- package/src/components/backgrounds/Filaments/Filaments.tsx +138 -0
- package/src/components/backgrounds/Filaments/index.ts +2 -0
- package/src/components/backgrounds/FlipTiles/FlipTiles.css +44 -0
- package/src/components/backgrounds/FlipTiles/FlipTiles.tsx +368 -0
- package/src/components/backgrounds/FlipTiles/index.ts +2 -0
- package/src/components/backgrounds/Halo/Halo.css +43 -0
- package/src/components/backgrounds/Halo/Halo.tsx +122 -0
- package/src/components/backgrounds/Halo/index.ts +2 -0
- package/src/components/backgrounds/MeshGradient/MeshGradient.css +43 -0
- package/src/components/backgrounds/MeshGradient/MeshGradient.tsx +195 -0
- package/src/components/backgrounds/MeshGradient/index.ts +2 -0
- package/src/components/backgrounds/PixelVortex/PixelVortex.css +44 -0
- package/src/components/backgrounds/PixelVortex/PixelVortex.tsx +278 -0
- package/src/components/backgrounds/PixelVortex/index.ts +2 -0
- package/src/components/backgrounds/Planet/Planet.css +39 -0
- package/src/components/backgrounds/Planet/Planet.tsx +736 -0
- package/src/components/backgrounds/Planet/index.ts +2 -0
- package/src/components/backgrounds/SilkWaves/SilkWaves.css +44 -0
- package/src/components/backgrounds/SilkWaves/SilkWaves.tsx +144 -0
- package/src/components/backgrounds/SilkWaves/index.ts +2 -0
- package/src/components/backgrounds/Velvet/Velvet.css +35 -0
- package/src/components/backgrounds/Velvet/Velvet.tsx +246 -0
- package/src/components/backgrounds/Velvet/index.ts +2 -0
- package/src/components/backgrounds/index.ts +26 -0
- package/src/components/index.ts +4 -0
- package/src/components/media/AsciiImage/AsciiImage.css +16 -0
- package/src/components/media/AsciiImage/AsciiImage.tsx +366 -0
- package/src/components/media/AsciiImage/index.ts +2 -0
- package/src/components/media/index.ts +2 -0
- package/src/components/text/GradientText/GradientText.css +32 -0
- package/src/components/text/GradientText/GradientText.tsx +27 -0
- package/src/components/text/GradientText/index.ts +2 -0
- package/src/components/text/Marquee/Marquee.css +91 -0
- package/src/components/text/Marquee/Marquee.tsx +130 -0
- package/src/components/text/Marquee/index.ts +2 -0
- package/src/components/text/ParticleText/ParticleText.css +16 -0
- package/src/components/text/ParticleText/ParticleText.tsx +279 -0
- package/src/components/text/ParticleText/index.ts +2 -0
- package/src/components/text/RotatingText/RotatingText.css +133 -0
- package/src/components/text/RotatingText/RotatingText.tsx +112 -0
- package/src/components/text/RotatingText/index.ts +2 -0
- package/src/components/text/ScrambleText/ScrambleText.css +48 -0
- package/src/components/text/ScrambleText/ScrambleText.tsx +141 -0
- package/src/components/text/ScrambleText/index.ts +2 -0
- package/src/components/text/ScrollMarquee/ScrollMarquee.css +33 -0
- package/src/components/text/ScrollMarquee/ScrollMarquee.tsx +110 -0
- package/src/components/text/ScrollMarquee/index.ts +2 -0
- package/src/components/text/ScrollReveal/ScrollReveal.css +51 -0
- package/src/components/text/ScrollReveal/ScrollReveal.tsx +186 -0
- package/src/components/text/ScrollReveal/index.ts +2 -0
- package/src/components/text/SplitReveal/SplitReveal.css +52 -0
- package/src/components/text/SplitReveal/SplitReveal.tsx +127 -0
- package/src/components/text/SplitReveal/index.ts +2 -0
- package/src/components/text/Typewriter/Typewriter.css +37 -0
- package/src/components/text/Typewriter/Typewriter.tsx +80 -0
- package/src/components/text/Typewriter/index.ts +2 -0
- package/src/components/text/WaveText/WaveText.css +28 -0
- package/src/components/text/WaveText/WaveText.tsx +52 -0
- package/src/components/text/WaveText/index.ts +2 -0
- 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,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,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
|
+
}
|