@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,141 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
+ import type { CSSProperties } from 'react';
3
+
4
+ import { cn, usePrefersReducedMotion } from '../../../core';
5
+
6
+ import './ScrambleText.css';
7
+
8
+ export type ScrambleTextTrigger = 'view' | 'hover';
9
+
10
+ export interface ScrambleTextProps {
11
+ /** 需要解码显现的文本,仅支持纯字符串 */
12
+ children: string;
13
+ className?: string;
14
+ /** 触发方式:进入视口或指针悬停 */
15
+ trigger?: ScrambleTextTrigger;
16
+ /** 乱码刷新间隔(毫秒) */
17
+ speed?: number;
18
+ /** 相邻字符依次定格的间隔(毫秒) */
19
+ stagger?: number;
20
+ /** 乱码字符池 */
21
+ characters?: string;
22
+ /** 乱码字符的强调色 */
23
+ accentColor?: string;
24
+ /** 乱码辉光强度倍率,0 为关闭,默认 1 */
25
+ glow?: number;
26
+ /** 视口模式下是否只在首次进入时触发 */
27
+ once?: boolean;
28
+ }
29
+
30
+ interface Frame {
31
+ chars: string[];
32
+ resolved: number;
33
+ }
34
+
35
+ const DEFAULT_CHARACTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!<>-_\\/[]{}=+*^?#';
36
+
37
+ const isSpace = (char: string) => /\s/.test(char);
38
+
39
+ export function ScrambleText({
40
+ children,
41
+ className,
42
+ trigger = 'view',
43
+ speed = 40,
44
+ stagger = 60,
45
+ characters = DEFAULT_CHARACTERS,
46
+ accentColor,
47
+ glow,
48
+ once = true,
49
+ }: ScrambleTextProps) {
50
+ const elementRef = useRef<HTMLSpanElement>(null);
51
+ const prefersReducedMotion = usePrefersReducedMotion();
52
+ // 每次自增代表重新播放一轮解码
53
+ const [runId, setRunId] = useState(0);
54
+ const [frame, setFrame] = useState<Frame | null>(null);
55
+
56
+ const chars = useMemo(() => Array.from(children), [children]);
57
+
58
+ useEffect(() => {
59
+ const element = elementRef.current;
60
+ if (!element || prefersReducedMotion || trigger !== 'view') return;
61
+
62
+ const observer = new IntersectionObserver(
63
+ ([entry]) => {
64
+ if (!entry.isIntersecting) return;
65
+ setRunId((id) => id + 1);
66
+ if (once) observer.disconnect();
67
+ },
68
+ { threshold: 0.1 },
69
+ );
70
+
71
+ observer.observe(element);
72
+
73
+ return () => observer.disconnect();
74
+ }, [trigger, once, prefersReducedMotion]);
75
+
76
+ useEffect(() => {
77
+ if (runId === 0 || prefersReducedMotion) return;
78
+
79
+ const pool = Array.from(characters || DEFAULT_CHARACTERS);
80
+ const start = performance.now();
81
+
82
+ // 按经过时间决定已定格的字符数,其余位置刷新为随机字符;返回是否已全部定格
83
+ const step = () => {
84
+ const resolved = Math.floor((performance.now() - start) / stagger);
85
+
86
+ if (resolved >= chars.length) {
87
+ setFrame(null);
88
+ return true;
89
+ }
90
+
91
+ setFrame({
92
+ resolved,
93
+ chars: chars.map((char, index) =>
94
+ index < resolved || isSpace(char) ? char : pool[Math.floor(Math.random() * pool.length)],
95
+ ),
96
+ });
97
+ return false;
98
+ };
99
+
100
+ if (step()) return;
101
+
102
+ const timer = setInterval(() => {
103
+ if (step()) clearInterval(timer);
104
+ }, speed);
105
+
106
+ return () => clearInterval(timer);
107
+ }, [runId, chars, characters, speed, stagger, prefersReducedMotion]);
108
+
109
+ const replay = () => {
110
+ if (!frame && !prefersReducedMotion) setRunId((id) => id + 1);
111
+ };
112
+
113
+ const hoverHandlers = trigger === 'hover' ? { onPointerEnter: replay, onFocus: replay } : null;
114
+
115
+ const style = {
116
+ ...(accentColor ? { '--elyri-scramble-accent': accentColor } : null),
117
+ ...(glow !== undefined ? { '--elyri-scramble-glow': Math.max(0, glow) } : null),
118
+ } as CSSProperties;
119
+
120
+ return (
121
+ <span ref={elementRef} className={cn('elyri-scramble-text', className)} style={style} {...hoverHandlers}>
122
+ <span className="elyri-scramble-text__sr">{children}</span>
123
+ <span aria-hidden="true">
124
+ {chars.map((char, index) => {
125
+ const isScrambled = frame !== null && index >= frame.resolved && !isSpace(char);
126
+
127
+ // 原字符始终参与排版以固定宽度,乱码绝对定位叠在其上,不会改变行宽导致换行
128
+ return (
129
+ <span
130
+ key={index}
131
+ className={cn('elyri-scramble-text__char', isScrambled && 'elyri-scramble-text__char--scrambled')}
132
+ >
133
+ {char}
134
+ {isScrambled && <span className="elyri-scramble-text__glyph">{frame.chars[index]}</span>}
135
+ </span>
136
+ );
137
+ })}
138
+ </span>
139
+ </span>
140
+ );
141
+ }
@@ -0,0 +1,2 @@
1
+ export { ScrambleText } from './ScrambleText';
2
+ export type { ScrambleTextProps, ScrambleTextTrigger } from './ScrambleText';
@@ -0,0 +1,33 @@
1
+ .elyri-scroll-marquee {
2
+ --elyri-scroll-marquee-gap: 0.6em;
3
+
4
+ display: flex;
5
+ flex-direction: column;
6
+ width: 100%;
7
+ overflow: hidden;
8
+ line-height: 1.15;
9
+ white-space: nowrap;
10
+ }
11
+
12
+ /* 每行内容横向平铺,位移由页面滚动量驱动,相邻行方向相反 */
13
+ .elyri-scroll-marquee__row {
14
+ display: flex;
15
+ width: max-content;
16
+ /* --base 为 -1 时整体先左移一份内容宽度,让向右滚动时左侧也有内容可填,避免露白 */
17
+ margin-left: calc(var(--elyri-scroll-marquee-base, 0) * var(--elyri-scroll-marquee-copy-width, 0px));
18
+ will-change: transform;
19
+ transform: translate3d(calc(var(--elyri-scroll-marquee-shift, 0px) * var(--elyri-scroll-marquee-dir, 1)), 0, 0);
20
+ }
21
+
22
+ /* 单份内容,padding 计入宽度以保证取模后无缝衔接 */
23
+ .elyri-scroll-marquee__copy {
24
+ flex: none;
25
+ padding-right: var(--elyri-scroll-marquee-gap);
26
+ }
27
+
28
+ @media (prefers-reduced-motion: reduce) {
29
+ .elyri-scroll-marquee__row {
30
+ transform: none;
31
+ will-change: auto;
32
+ }
33
+ }
@@ -0,0 +1,110 @@
1
+ import { useEffect, useRef } from 'react';
2
+ import type { CSSProperties } from 'react';
3
+
4
+ import { cn, usePrefersReducedMotion } from '../../../core';
5
+
6
+ import './ScrollMarquee.css';
7
+
8
+ export type ScrollMarqueeDirection = 'left' | 'right';
9
+
10
+ export interface ScrollMarqueeProps {
11
+ /** 需要滚动的文本,仅支持纯字符串 */
12
+ children: string;
13
+ className?: string;
14
+ /** 滚动方向 */
15
+ direction?: ScrollMarqueeDirection;
16
+ /** 页面每滚动 1px,文字横向移动的像素数 */
17
+ speed?: number;
18
+ /** 行数,相邻两行方向相反 */
19
+ rows?: number;
20
+ /** 内容重复份数,用于铺满并实现无缝循环 */
21
+ repeat?: number;
22
+ /** 是否无限循环,关闭时走满一份内容后停住 */
23
+ infinite?: boolean;
24
+ }
25
+
26
+ export function ScrollMarquee({
27
+ children,
28
+ className,
29
+ direction = 'left',
30
+ speed = 0.5,
31
+ rows = 1,
32
+ repeat = 6,
33
+ infinite = true,
34
+ }: ScrollMarqueeProps) {
35
+ const rootRef = useRef<HTMLDivElement>(null);
36
+ const firstCopyRef = useRef<HTMLSpanElement>(null);
37
+ const prefersReducedMotion = usePrefersReducedMotion();
38
+
39
+ useEffect(() => {
40
+ const root = rootRef.current;
41
+ if (!root || prefersReducedMotion) return;
42
+
43
+ let frame = 0;
44
+
45
+ const update = () => {
46
+ frame = 0;
47
+
48
+ // 单份内容的宽度即循环周期,按它取模即可无缝衔接
49
+ const copyWidth = firstCopyRef.current?.offsetWidth ?? 0;
50
+ if (!copyWidth) return;
51
+
52
+ const offset = window.scrollY * speed;
53
+ // 无限循环时按单份宽度取模,滚动中不断回绕;关闭时走满一份内容后停住
54
+ const shift = infinite ? -(offset % copyWidth) : -Math.min(offset, copyWidth);
55
+ root.style.setProperty('--elyri-scroll-marquee-shift', `${shift}px`);
56
+ root.style.setProperty('--elyri-scroll-marquee-copy-width', `${copyWidth}px`);
57
+ };
58
+
59
+ const onScroll = () => {
60
+ if (frame) return;
61
+ frame = requestAnimationFrame(update);
62
+ };
63
+
64
+ update();
65
+ window.addEventListener('scroll', onScroll, { passive: true });
66
+ window.addEventListener('resize', onScroll);
67
+
68
+ return () => {
69
+ window.removeEventListener('scroll', onScroll);
70
+ window.removeEventListener('resize', onScroll);
71
+ if (frame) cancelAnimationFrame(frame);
72
+ };
73
+ }, [prefersReducedMotion, speed, rows, repeat, infinite]);
74
+
75
+ const rowCount = Math.max(1, Math.floor(rows));
76
+ const copyCount = Math.max(1, Math.floor(repeat));
77
+ const baseDirection = direction === 'left' ? 1 : -1;
78
+
79
+ return (
80
+ <div ref={rootRef} className={cn('elyri-scroll-marquee', className)}>
81
+ {Array.from({ length: rowCount }, (_, rowIndex) => {
82
+ const dir = rowIndex % 2 === 0 ? baseDirection : -baseDirection;
83
+
84
+ return (
85
+ <div
86
+ key={rowIndex}
87
+ className="elyri-scroll-marquee__row"
88
+ style={
89
+ {
90
+ '--elyri-scroll-marquee-dir': dir,
91
+ // 向右滚动时先整体左移一份内容宽度,避免左侧露白
92
+ '--elyri-scroll-marquee-base': dir === 1 ? 0 : -1,
93
+ } as CSSProperties
94
+ }
95
+ >
96
+ {Array.from({ length: copyCount }, (_, copyIndex) => (
97
+ <span
98
+ key={copyIndex}
99
+ ref={rowIndex === 0 && copyIndex === 0 ? firstCopyRef : undefined}
100
+ className="elyri-scroll-marquee__copy"
101
+ >
102
+ {children}
103
+ </span>
104
+ ))}
105
+ </div>
106
+ );
107
+ })}
108
+ </div>
109
+ );
110
+ }
@@ -0,0 +1,2 @@
1
+ export { ScrollMarquee } from './ScrollMarquee';
2
+ export type { ScrollMarqueeDirection, ScrollMarqueeProps } from './ScrollMarquee';
@@ -0,0 +1,51 @@
1
+ .elyri-scroll-reveal {
2
+ /* 点亮范围占全文的比例:0 为全暗、1 为全亮,默认全亮以兼容 SSR 与无 JS 场景 */
3
+ --elyri-scroll-reveal-progress: 1;
4
+ --elyri-scroll-reveal-dim: 0.15;
5
+ --elyri-scroll-reveal-blur: 0px;
6
+ --elyri-scroll-reveal-height: 280px;
7
+
8
+ display: block;
9
+ height: var(--elyri-scroll-reveal-height);
10
+ overflow-y: auto;
11
+ overscroll-behavior: contain;
12
+ }
13
+
14
+ .elyri-scroll-reveal__content {
15
+ /* 保留原始空白,同时允许在空白处换行 */
16
+ white-space: pre-wrap;
17
+ }
18
+
19
+ /* 单词容器:整词不拆行,逐字模式下作为字符的布局外壳 */
20
+ .elyri-scroll-reveal__word {
21
+ display: inline-block;
22
+ }
23
+
24
+ /* 点亮单元:逐字模式下为单字,逐词模式下为单词;亮度随滚动进度点亮 */
25
+ .elyri-scroll-reveal__unit {
26
+ /* 本单元的点亮程度:点亮范围推进到第 index 个单元时从 0 过渡到 1 */
27
+ --elyri-scroll-reveal-lit: clamp(
28
+ 0,
29
+ calc(var(--elyri-scroll-reveal-progress) * var(--elyri-scroll-reveal-count) - var(--elyri-scroll-reveal-index)),
30
+ 1
31
+ );
32
+
33
+ display: inline-block;
34
+ white-space: pre;
35
+ opacity: calc(var(--elyri-scroll-reveal-dim) + (1 - var(--elyri-scroll-reveal-dim)) * var(--elyri-scroll-reveal-lit));
36
+ }
37
+
38
+ /* 开启模糊时,未点亮的单元同时带模糊 */
39
+ .elyri-scroll-reveal--blur .elyri-scroll-reveal__unit {
40
+ filter: blur(calc((1 - var(--elyri-scroll-reveal-lit)) * var(--elyri-scroll-reveal-blur)));
41
+ }
42
+
43
+ @media (prefers-reduced-motion: reduce) {
44
+ .elyri-scroll-reveal__unit {
45
+ opacity: 1;
46
+ }
47
+
48
+ .elyri-scroll-reveal--blur .elyri-scroll-reveal__unit {
49
+ filter: none;
50
+ }
51
+ }
@@ -0,0 +1,186 @@
1
+ import { useEffect, useMemo, useRef } from 'react';
2
+ import type { CSSProperties } from 'react';
3
+
4
+ import { cn, splitTextUnits, usePrefersReducedMotion } from '../../../core';
5
+ import type { SplitTextSegment } from '../../../core';
6
+
7
+ import './ScrollReveal.css';
8
+
9
+ export type ScrollRevealBy = 'char' | 'word';
10
+
11
+ /**
12
+ * 点亮线固定在视口该比例高度处(0.65 即屏幕下方 35% 始终留作未点亮区)。
13
+ * 固定不动才能让明暗过渡一直在屏幕里可见;若让它随滚动漂移,中后段整屏都会落在线上方。
14
+ */
15
+ const LEAD_RATIO = 0.65;
16
+
17
+ export interface ScrollRevealProps {
18
+ /** 需要点亮的文本,仅支持纯字符串 */
19
+ children: string;
20
+ className?: string;
21
+ /** 点亮单位:按单词或按字符 */
22
+ by?: ScrollRevealBy;
23
+ /** 滚动区域高度(像素),文本超出后可滚动 */
24
+ height?: number;
25
+ /** 未点亮时的不透明度,0 为完全不可见 */
26
+ dimOpacity?: number;
27
+ /** 未点亮时的模糊半径(像素),0 为不模糊 */
28
+ blur?: number;
29
+ }
30
+
31
+ const segmenter =
32
+ typeof Intl !== 'undefined' && 'Segmenter' in Intl ? new Intl.Segmenter(undefined, { granularity: 'word' }) : null;
33
+
34
+ /**
35
+ * 按「词」切分文本。用 Intl.Segmenter 取词边界,中文、日文等没有空格的语言也能按词点亮;
36
+ * 标点并入前一个词(没有前词时暂存到下一个词),空白保持原样输出。
37
+ * 不支持 Intl.Segmenter 时退回按空白切分。
38
+ */
39
+ const splitWords = (text: string): SplitTextSegment[] => {
40
+ if (!segmenter) return splitTextUnits(text, 'word');
41
+
42
+ const segments: SplitTextSegment[] = [];
43
+ let index = 0;
44
+ let wordPart = 0;
45
+ let spacePart = 0;
46
+ let pending = '';
47
+
48
+ for (const { segment: value, isWordLike } of segmenter.segment(text)) {
49
+ if (/^\s+$/.test(value)) {
50
+ segments.push({ kind: 'space', key: `space-${spacePart++}`, value });
51
+ continue;
52
+ }
53
+
54
+ if (isWordLike) {
55
+ segments.push({ kind: 'word', key: `word-${wordPart++}`, units: [{ char: pending + value, index: index++ }] });
56
+ pending = '';
57
+ continue;
58
+ }
59
+
60
+ const last = segments[segments.length - 1];
61
+ if (!pending && last?.kind === 'word') {
62
+ last.units[0].char += value;
63
+ } else {
64
+ pending += value;
65
+ }
66
+ }
67
+
68
+ if (pending) {
69
+ segments.push({ kind: 'word', key: `word-${wordPart}`, units: [{ char: pending, index }] });
70
+ }
71
+
72
+ return segments;
73
+ };
74
+
75
+ /** 滚动点亮:随容器滚动,文本从头至尾逐单元点亮 */
76
+ export function ScrollReveal({
77
+ children,
78
+ className,
79
+ by = 'word',
80
+ height = 280,
81
+ dimOpacity = 0.15,
82
+ blur = 0,
83
+ }: ScrollRevealProps) {
84
+ const rootRef = useRef<HTMLDivElement>(null);
85
+ const contentRef = useRef<HTMLDivElement>(null);
86
+ const prefersReducedMotion = usePrefersReducedMotion();
87
+
88
+ const segments = useMemo(
89
+ () => (by === 'word' ? splitWords(children) : splitTextUnits(children, 'char')),
90
+ [children, by],
91
+ );
92
+ const count = useMemo(
93
+ () => segments.reduce((total, segment) => (segment.kind === 'word' ? total + segment.units.length : total), 0),
94
+ [segments],
95
+ );
96
+
97
+ useEffect(() => {
98
+ const root = rootRef.current;
99
+ const content = contentRef.current;
100
+ // 减弱动态效果时保持 CSS 默认的全亮进度,不挂监听
101
+ if (!root || !content || prefersReducedMotion) return;
102
+
103
+ let frame = 0;
104
+
105
+ const update = () => {
106
+ frame = 0;
107
+
108
+ // 文本自身高度即点亮线的行程终点
109
+ const travel = content.offsetHeight;
110
+ if (travel <= 0) return;
111
+
112
+ const { clientHeight, scrollHeight, scrollTop } = root;
113
+ const range = scrollHeight - clientHeight;
114
+ if (range <= 0) {
115
+ root.style.setProperty('--elyri-scroll-reveal-progress', '1');
116
+ return;
117
+ }
118
+
119
+ // 点亮线默认固定在视口的 LEAD_RATIO 高度处,屏幕下方始终留出一段未点亮的文字,
120
+ // 明暗过渡在任何滚动位置都看得见。
121
+ const line = scrollTop + clientHeight * LEAD_RATIO;
122
+
123
+ // 收尾:在最后一屏的滚动里把点亮线平滑推到文末,因此滚到底时正好全文点亮,
124
+ // 也就不必在文末补白。
125
+ const tailStart = Math.max(0, range - clientHeight);
126
+ const tail = Math.min(1, Math.max(0, (scrollTop - tailStart) / clientHeight));
127
+ // 像素取整会让滚到底时差出 1px,直接判为全文点亮,避免最后一个词只亮一半
128
+ const head = scrollTop >= range - 1 ? travel : line + tail * (travel - range - clientHeight * LEAD_RATIO);
129
+
130
+ root.style.setProperty('--elyri-scroll-reveal-progress', String(Math.min(1, Math.max(0, head / travel))));
131
+ };
132
+
133
+ const onScroll = () => {
134
+ if (frame) return;
135
+ frame = requestAnimationFrame(update);
136
+ };
137
+
138
+ update();
139
+ root.addEventListener('scroll', onScroll, { passive: true });
140
+ // 内容尺寸变化(字体加载、容器宽度变化)会改变行程长度,需要重新计算
141
+ const observer = new ResizeObserver(onScroll);
142
+ observer.observe(content);
143
+
144
+ return () => {
145
+ root.removeEventListener('scroll', onScroll);
146
+ observer.disconnect();
147
+ if (frame) cancelAnimationFrame(frame);
148
+ };
149
+ }, [prefersReducedMotion, count]);
150
+
151
+ const style = {
152
+ '--elyri-scroll-reveal-height': `${height}px`,
153
+ '--elyri-scroll-reveal-dim': dimOpacity,
154
+ '--elyri-scroll-reveal-blur': `${blur}px`,
155
+ '--elyri-scroll-reveal-count': count,
156
+ } as CSSProperties;
157
+
158
+ return (
159
+ <div
160
+ ref={rootRef}
161
+ className={cn('elyri-scroll-reveal', blur > 0 && 'elyri-scroll-reveal--blur', className)}
162
+ style={style}
163
+ >
164
+ <div ref={contentRef} className="elyri-scroll-reveal__content" aria-label={children}>
165
+ {segments.map((segment) =>
166
+ segment.kind === 'space' ? (
167
+ segment.value
168
+ ) : (
169
+ <span key={segment.key} className="elyri-scroll-reveal__word">
170
+ {segment.units.map((unit) => (
171
+ <span
172
+ key={unit.index}
173
+ className="elyri-scroll-reveal__unit"
174
+ style={{ '--elyri-scroll-reveal-index': unit.index } as CSSProperties}
175
+ aria-hidden="true"
176
+ >
177
+ {unit.char}
178
+ </span>
179
+ ))}
180
+ </span>
181
+ ),
182
+ )}
183
+ </div>
184
+ </div>
185
+ );
186
+ }
@@ -0,0 +1,2 @@
1
+ export { ScrollReveal } from './ScrollReveal';
2
+ export type { ScrollRevealBy, ScrollRevealProps } from './ScrollReveal';
@@ -0,0 +1,52 @@
1
+ .elyri-split-reveal {
2
+ --elyri-split-reveal-duration: var(--elyri-duration-base);
3
+ --elyri-split-reveal-delay: 0ms;
4
+ --elyri-split-reveal-stagger: 40ms;
5
+ --elyri-split-reveal-distance: 24px;
6
+
7
+ display: inline;
8
+ /* 保留原始空白,同时允许在空白处换行 */
9
+ white-space: pre-wrap;
10
+ }
11
+
12
+ /* 单词容器:整词不拆行,逐字模式下作为字符的布局外壳 */
13
+ .elyri-split-reveal__word {
14
+ display: inline-block;
15
+ }
16
+
17
+ /* 动画单元:逐字模式下为单字,逐词模式下为单词 */
18
+ .elyri-split-reveal__unit {
19
+ display: inline-block;
20
+ white-space: pre;
21
+ opacity: 0;
22
+ will-change: opacity, transform;
23
+ }
24
+
25
+ .elyri-split-reveal--visible .elyri-split-reveal__unit {
26
+ animation: elyri-split-reveal-rise var(--elyri-split-reveal-duration) var(--elyri-ease-out) both;
27
+ animation-delay: calc(
28
+ var(--elyri-split-reveal-delay) + var(--elyri-split-reveal-index, 0) * var(--elyri-split-reveal-stagger)
29
+ );
30
+ }
31
+
32
+ @keyframes elyri-split-reveal-rise {
33
+ from {
34
+ opacity: 0;
35
+ transform: translate3d(0, var(--elyri-split-reveal-distance), 0);
36
+ }
37
+
38
+ to {
39
+ opacity: 1;
40
+ transform: none;
41
+ }
42
+ }
43
+
44
+ @media (prefers-reduced-motion: reduce) {
45
+ .elyri-split-reveal__unit {
46
+ opacity: 1;
47
+ }
48
+
49
+ .elyri-split-reveal--visible .elyri-split-reveal__unit {
50
+ animation: none;
51
+ }
52
+ }