@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
package/package.json CHANGED
@@ -1,6 +1,63 @@
1
1
  {
2
2
  "name": "@elyri/motion",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.0.1",
4
+ "description": "React animation component library with CSS variables theming.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/TechCat-Team/Elyri.git",
9
+ "directory": "packages/motion"
10
+ },
11
+ "type": "module",
12
+ "files": [
13
+ "dist",
14
+ "src/components"
15
+ ],
16
+ "main": "./dist/index.cjs",
17
+ "module": "./dist/index.js",
18
+ "types": "./dist/index.d.ts",
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/index.d.ts",
22
+ "import": "./dist/index.js",
23
+ "require": "./dist/index.cjs"
24
+ },
25
+ "./core": {
26
+ "types": "./dist/core.d.ts",
27
+ "import": "./dist/core.js",
28
+ "require": "./dist/core.cjs"
29
+ },
30
+ "./styles.css": "./dist/index.css",
31
+ "./package.json": "./package.json"
32
+ },
33
+ "sideEffects": [
34
+ "**/*.css"
35
+ ],
36
+ "peerDependencies": {
37
+ "react": "^18.0.0 || ^19.0.0",
38
+ "react-dom": "^18.0.0 || ^19.0.0"
39
+ },
40
+ "devDependencies": {
41
+ "@types/react": "^19.3.0",
42
+ "@types/react-dom": "^19.3.0",
43
+ "react": "^19.3.0",
44
+ "react-dom": "^19.3.0",
45
+ "tsup": "^8.5.1",
46
+ "typescript": "^5.9.3"
47
+ },
48
+ "keywords": [
49
+ "react",
50
+ "component-library",
51
+ "animation",
52
+ "ui",
53
+ "css-variables"
54
+ ],
55
+ "publishConfig": {
56
+ "access": "public"
57
+ },
58
+ "scripts": {
59
+ "build": "tsup",
60
+ "dev": "tsup --watch",
61
+ "typecheck": "tsc --noEmit"
62
+ }
6
63
  }
@@ -0,0 +1,4 @@
1
+ .elyri-count-up {
2
+ /* 等宽数字,滚动过程中宽度不跳动 */
3
+ font-variant-numeric: tabular-nums;
4
+ }
@@ -0,0 +1,100 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+
3
+ import { cn, usePrefersReducedMotion } from '../../../core';
4
+
5
+ import './CountUp.css';
6
+
7
+ export interface CountUpProps {
8
+ className?: string;
9
+ /** 目标数值 */
10
+ to: number;
11
+ /** 起始数值 */
12
+ from?: number;
13
+ /** 动画时长(毫秒) */
14
+ duration?: number;
15
+ /** 保留的小数位数 */
16
+ decimals?: number;
17
+ /** 是否使用千分位分隔符 */
18
+ separator?: boolean;
19
+ /** 数字前缀 */
20
+ prefix?: string;
21
+ /** 数字后缀 */
22
+ suffix?: string;
23
+ /** 是否只在首次进入视口时播放 */
24
+ once?: boolean;
25
+ }
26
+
27
+ const group = (value: string) => value.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
28
+
29
+ const easeOutCubic = (progress: number) => 1 - (1 - progress) ** 3;
30
+
31
+ /** 数字滚动:进入视口时从起始值过渡到目标值 */
32
+ export function CountUp({
33
+ className,
34
+ to,
35
+ from = 0,
36
+ duration = 1200,
37
+ decimals = 0,
38
+ separator = false,
39
+ prefix,
40
+ suffix,
41
+ once = true,
42
+ }: CountUpProps) {
43
+ const elementRef = useRef<HTMLSpanElement>(null);
44
+ const prefersReducedMotion = usePrefersReducedMotion();
45
+ const [value, setValue] = useState(from);
46
+ const [isIntersecting, setIsIntersecting] = useState(false);
47
+
48
+ useEffect(() => {
49
+ const element = elementRef.current;
50
+ if (!element || prefersReducedMotion) return;
51
+
52
+ const observer = new IntersectionObserver(
53
+ ([entry]) => {
54
+ if (entry.isIntersecting) {
55
+ setIsIntersecting(true);
56
+ if (once) observer.disconnect();
57
+ return;
58
+ }
59
+
60
+ if (!once) setIsIntersecting(false);
61
+ },
62
+ { threshold: 0.1 },
63
+ );
64
+
65
+ observer.observe(element);
66
+
67
+ return () => observer.disconnect();
68
+ }, [once, prefersReducedMotion]);
69
+
70
+ useEffect(() => {
71
+ if (!isIntersecting || prefersReducedMotion) return;
72
+
73
+ let frame = 0;
74
+ const startedAt = performance.now();
75
+
76
+ const tick = (now: number) => {
77
+ const progress = duration > 0 ? Math.min((now - startedAt) / duration, 1) : 1;
78
+ setValue(from + (to - from) * easeOutCubic(progress));
79
+
80
+ if (progress < 1) frame = requestAnimationFrame(tick);
81
+ };
82
+
83
+ frame = requestAnimationFrame(tick);
84
+
85
+ return () => cancelAnimationFrame(frame);
86
+ }, [isIntersecting, from, to, duration, prefersReducedMotion]);
87
+
88
+ const fixed = (prefersReducedMotion ? to : value).toFixed(decimals);
89
+ const [integer, fraction] = fixed.split('.');
90
+ const grouped = separator ? group(integer) : integer;
91
+ const display = fraction ? `${grouped}.${fraction}` : grouped;
92
+
93
+ return (
94
+ <span ref={elementRef} className={cn('elyri-count-up', className)}>
95
+ {prefix}
96
+ {display}
97
+ {suffix}
98
+ </span>
99
+ );
100
+ }
@@ -0,0 +1,2 @@
1
+ export { CountUp } from './CountUp';
2
+ export type { CountUpProps } from './CountUp';
@@ -0,0 +1,43 @@
1
+ .elyri-fade-in {
2
+ --elyri-fade-in-distance: 24px;
3
+ --elyri-fade-in-duration: var(--elyri-duration-base);
4
+ --elyri-fade-in-delay: 0ms;
5
+
6
+ opacity: 0;
7
+ transition:
8
+ opacity var(--elyri-fade-in-duration) var(--elyri-ease-out) var(--elyri-fade-in-delay),
9
+ transform var(--elyri-fade-in-duration) var(--elyri-ease-out) var(--elyri-fade-in-delay);
10
+ }
11
+
12
+ .elyri-fade-in--up {
13
+ transform: translate3d(0, var(--elyri-fade-in-distance), 0);
14
+ }
15
+
16
+ .elyri-fade-in--down {
17
+ transform: translate3d(0, calc(var(--elyri-fade-in-distance) * -1), 0);
18
+ }
19
+
20
+ .elyri-fade-in--left {
21
+ transform: translate3d(var(--elyri-fade-in-distance), 0, 0);
22
+ }
23
+
24
+ .elyri-fade-in--right {
25
+ transform: translate3d(calc(var(--elyri-fade-in-distance) * -1), 0, 0);
26
+ }
27
+
28
+ .elyri-fade-in--none {
29
+ transform: none;
30
+ }
31
+
32
+ .elyri-fade-in--visible {
33
+ opacity: 1;
34
+ transform: none;
35
+ }
36
+
37
+ @media (prefers-reduced-motion: reduce) {
38
+ .elyri-fade-in {
39
+ opacity: 1;
40
+ transform: none;
41
+ transition: none;
42
+ }
43
+ }
@@ -0,0 +1,72 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import type { CSSProperties, ReactNode } from 'react';
3
+
4
+ import { cn, usePrefersReducedMotion } from '../../../core';
5
+
6
+ import './FadeIn.css';
7
+
8
+ export type FadeInDirection = 'up' | 'down' | 'left' | 'right' | 'none';
9
+
10
+ export interface FadeInProps {
11
+ children: ReactNode;
12
+ className?: string;
13
+ direction?: FadeInDirection;
14
+ delay?: number;
15
+ duration?: number;
16
+ distance?: number;
17
+ once?: boolean;
18
+ }
19
+
20
+ export function FadeIn({
21
+ children,
22
+ className,
23
+ direction = 'up',
24
+ delay = 0,
25
+ duration = 600,
26
+ distance = 24,
27
+ once = true,
28
+ }: FadeInProps) {
29
+ const elementRef = useRef<HTMLDivElement>(null);
30
+ const prefersReducedMotion = usePrefersReducedMotion();
31
+ const [isIntersecting, setIsIntersecting] = useState(false);
32
+
33
+ useEffect(() => {
34
+ const element = elementRef.current;
35
+ if (!element || prefersReducedMotion) return;
36
+
37
+ const observer = new IntersectionObserver(
38
+ ([entry]) => {
39
+ if (entry.isIntersecting) {
40
+ setIsIntersecting(true);
41
+ if (once) observer.disconnect();
42
+ return;
43
+ }
44
+
45
+ if (!once) setIsIntersecting(false);
46
+ },
47
+ { threshold: 0.1 },
48
+ );
49
+
50
+ observer.observe(element);
51
+
52
+ return () => observer.disconnect();
53
+ }, [once, prefersReducedMotion]);
54
+
55
+ const style = {
56
+ '--elyri-fade-in-delay': `${delay}ms`,
57
+ '--elyri-fade-in-duration': `${duration}ms`,
58
+ '--elyri-fade-in-distance': `${distance}px`,
59
+ } as CSSProperties;
60
+
61
+ const isVisible = prefersReducedMotion || isIntersecting;
62
+
63
+ return (
64
+ <div
65
+ ref={elementRef}
66
+ className={cn('elyri-fade-in', `elyri-fade-in--${direction}`, isVisible && 'elyri-fade-in--visible', className)}
67
+ style={style}
68
+ >
69
+ {children}
70
+ </div>
71
+ );
72
+ }
@@ -0,0 +1,2 @@
1
+ export { FadeIn } from './FadeIn';
2
+ export type { FadeInDirection, FadeInProps } from './FadeIn';
@@ -0,0 +1,11 @@
1
+ .elyri-magnetic {
2
+ display: inline-block;
3
+ transform: translate3d(var(--elyri-magnetic-x, 0px), var(--elyri-magnetic-y, 0px), 0);
4
+ will-change: transform;
5
+ }
6
+
7
+ @media (prefers-reduced-motion: reduce) {
8
+ .elyri-magnetic {
9
+ transform: none;
10
+ }
11
+ }
@@ -0,0 +1,107 @@
1
+ import { useEffect, useRef } from 'react';
2
+ import type { ReactNode } from 'react';
3
+
4
+ import { cn, usePrefersReducedMotion } from '../../../core';
5
+
6
+ import './Magnetic.css';
7
+
8
+ const easeOutCubic = (progress: number) => 1 - (1 - progress) ** 3;
9
+
10
+ export interface MagneticProps {
11
+ children: ReactNode;
12
+ className?: string;
13
+ /** 吸附强度(0-1),元素向指针偏移的距离比例 */
14
+ strength?: number;
15
+ /** 元素边缘向外扩展的触发范围(像素) */
16
+ range?: number;
17
+ /** 指针离开触发范围后的回正时长(毫秒) */
18
+ resetDuration?: number;
19
+ }
20
+
21
+ /** 磁吸:指针靠近时元素被吸附偏移,离开后缓动回正 */
22
+ export function Magnetic({ children, className, strength = 0.35, range = 120, resetDuration = 400 }: MagneticProps) {
23
+ const elementRef = useRef<HTMLDivElement>(null);
24
+ const prefersReducedMotion = usePrefersReducedMotion();
25
+
26
+ useEffect(() => {
27
+ const element = elementRef.current;
28
+ if (!element || prefersReducedMotion) return;
29
+
30
+ let moveFrame = 0;
31
+ let resetFrame = 0;
32
+ let offset = { x: 0, y: 0 };
33
+ let pointer: { x: number; y: number } | null = null;
34
+
35
+ const setOffset = (x: number, y: number) => {
36
+ offset = { x, y };
37
+ element.style.setProperty('--elyri-magnetic-x', `${x}px`);
38
+ element.style.setProperty('--elyri-magnetic-y', `${y}px`);
39
+ };
40
+
41
+ // 缓动补间回正,避免指针离开范围时位置瞬间归零
42
+ const startReset = () => {
43
+ if (resetFrame || (!offset.x && !offset.y)) return;
44
+
45
+ const from = { ...offset };
46
+ const startedAt = performance.now();
47
+
48
+ const step = (now: number) => {
49
+ const progress = Math.min((now - startedAt) / resetDuration, 1);
50
+ const remaining = 1 - easeOutCubic(progress);
51
+
52
+ setOffset(from.x * remaining, from.y * remaining);
53
+
54
+ resetFrame = progress < 1 ? requestAnimationFrame(step) : 0;
55
+ };
56
+
57
+ resetFrame = requestAnimationFrame(step);
58
+ };
59
+
60
+ const handlePointerMove = (event: PointerEvent) => {
61
+ pointer = { x: event.clientX, y: event.clientY };
62
+
63
+ if (moveFrame) return;
64
+
65
+ moveFrame = requestAnimationFrame(() => {
66
+ moveFrame = 0;
67
+ if (!pointer) return;
68
+
69
+ const rect = element.getBoundingClientRect();
70
+ const dx = pointer.x - (rect.left + rect.width / 2);
71
+ const dy = pointer.y - (rect.top + rect.height / 2);
72
+ const inside = Math.abs(dx) <= rect.width / 2 + range && Math.abs(dy) <= rect.height / 2 + range;
73
+
74
+ if (inside) {
75
+ // 指针回到触发范围时打断尚未结束的回正
76
+ cancelAnimationFrame(resetFrame);
77
+ resetFrame = 0;
78
+ // 边缘衰减:边界处系数为 0,越靠近元素越大,避免进入范围瞬间位移跳变
79
+ const overflowX = Math.max(0, Math.abs(dx) - rect.width / 2);
80
+ const overflowY = Math.max(0, Math.abs(dy) - rect.height / 2);
81
+ const proximity = 1 - Math.min(Math.max(overflowX, overflowY) / range, 1);
82
+ setOffset(dx * strength * proximity, dy * strength * proximity);
83
+ return;
84
+ }
85
+
86
+ startReset();
87
+ });
88
+ };
89
+
90
+ window.addEventListener('pointermove', handlePointerMove, { passive: true });
91
+ // 指针移出窗口时不再触发 pointermove,需要手动回正
92
+ document.documentElement.addEventListener('pointerleave', startReset);
93
+
94
+ return () => {
95
+ window.removeEventListener('pointermove', handlePointerMove);
96
+ document.documentElement.removeEventListener('pointerleave', startReset);
97
+ cancelAnimationFrame(moveFrame);
98
+ cancelAnimationFrame(resetFrame);
99
+ };
100
+ }, [prefersReducedMotion, range, strength, resetDuration]);
101
+
102
+ return (
103
+ <div ref={elementRef} className={cn('elyri-magnetic', className)}>
104
+ {children}
105
+ </div>
106
+ );
107
+ }
@@ -0,0 +1,2 @@
1
+ export { Magnetic } from './Magnetic';
2
+ export type { MagneticProps } from './Magnetic';
@@ -0,0 +1,24 @@
1
+ .elyri-scale-in {
2
+ --elyri-scale-in-from: 0.9;
3
+ --elyri-scale-in-duration: var(--elyri-duration-base);
4
+ --elyri-scale-in-delay: 0ms;
5
+
6
+ opacity: 0;
7
+ transform: scale(var(--elyri-scale-in-from));
8
+ transition:
9
+ opacity var(--elyri-scale-in-duration) var(--elyri-ease-out) var(--elyri-scale-in-delay),
10
+ transform var(--elyri-scale-in-duration) var(--elyri-ease-out) var(--elyri-scale-in-delay);
11
+ }
12
+
13
+ .elyri-scale-in--visible {
14
+ opacity: 1;
15
+ transform: none;
16
+ }
17
+
18
+ @media (prefers-reduced-motion: reduce) {
19
+ .elyri-scale-in {
20
+ opacity: 1;
21
+ transform: none;
22
+ transition: none;
23
+ }
24
+ }
@@ -0,0 +1,65 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import type { CSSProperties, ReactNode } from 'react';
3
+
4
+ import { cn, usePrefersReducedMotion } from '../../../core';
5
+
6
+ import './ScaleIn.css';
7
+
8
+ export interface ScaleInProps {
9
+ children: ReactNode;
10
+ className?: string;
11
+ /** 起始缩放比例 */
12
+ from?: number;
13
+ /** 延迟(毫秒) */
14
+ delay?: number;
15
+ /** 动画时长(毫秒) */
16
+ duration?: number;
17
+ /** 是否只在首次进入视口时触发 */
18
+ once?: boolean;
19
+ }
20
+
21
+ export function ScaleIn({ children, className, from = 0.9, delay = 0, duration = 600, once = true }: ScaleInProps) {
22
+ const elementRef = useRef<HTMLDivElement>(null);
23
+ const prefersReducedMotion = usePrefersReducedMotion();
24
+ const [isIntersecting, setIsIntersecting] = useState(false);
25
+
26
+ useEffect(() => {
27
+ const element = elementRef.current;
28
+ if (!element || prefersReducedMotion) return;
29
+
30
+ const observer = new IntersectionObserver(
31
+ ([entry]) => {
32
+ if (entry.isIntersecting) {
33
+ setIsIntersecting(true);
34
+ if (once) observer.disconnect();
35
+ return;
36
+ }
37
+
38
+ if (!once) setIsIntersecting(false);
39
+ },
40
+ { threshold: 0.1 },
41
+ );
42
+
43
+ observer.observe(element);
44
+
45
+ return () => observer.disconnect();
46
+ }, [once, prefersReducedMotion]);
47
+
48
+ const style = {
49
+ '--elyri-scale-in-from': from,
50
+ '--elyri-scale-in-delay': `${delay}ms`,
51
+ '--elyri-scale-in-duration': `${duration}ms`,
52
+ } as CSSProperties;
53
+
54
+ const isVisible = prefersReducedMotion || isIntersecting;
55
+
56
+ return (
57
+ <div
58
+ ref={elementRef}
59
+ className={cn('elyri-scale-in', isVisible && 'elyri-scale-in--visible', className)}
60
+ style={style}
61
+ >
62
+ {children}
63
+ </div>
64
+ );
65
+ }
@@ -0,0 +1,2 @@
1
+ export { ScaleIn } from './ScaleIn';
2
+ export type { ScaleInProps } from './ScaleIn';
@@ -0,0 +1,93 @@
1
+ .elyri-spotlight {
2
+ --elyri-spotlight-x: 50%;
3
+ --elyri-spotlight-y: 50%;
4
+
5
+ position: relative;
6
+ overflow: hidden;
7
+ border-radius: var(--elyri-spotlight-radius, 16px);
8
+ }
9
+
10
+ /* 卡片内部的径向光斑 */
11
+ .elyri-spotlight--glow::before {
12
+ content: '';
13
+ position: absolute;
14
+ inset: 0;
15
+ background: radial-gradient(
16
+ var(--elyri-spotlight-size, 240px) circle at var(--elyri-spotlight-x) var(--elyri-spotlight-y),
17
+ color-mix(in srgb, var(--elyri-spotlight-color, #7c8cff) 18%, transparent),
18
+ transparent 70%
19
+ );
20
+ opacity: 0;
21
+ transition: opacity var(--elyri-duration-base) var(--elyri-ease-out);
22
+ pointer-events: none;
23
+ }
24
+
25
+ /* 只保留 1px 边框区域的光晕:content-box 与 border-box 两层遮罩求差 */
26
+ .elyri-spotlight--border::after {
27
+ content: '';
28
+ position: absolute;
29
+ inset: 0;
30
+ border-radius: inherit;
31
+ padding: 1px;
32
+ background: radial-gradient(
33
+ calc(var(--elyri-spotlight-size, 240px) * 0.75) circle at var(--elyri-spotlight-x) var(--elyri-spotlight-y),
34
+ var(--elyri-spotlight-color, #7c8cff),
35
+ transparent 70%
36
+ );
37
+ -webkit-mask:
38
+ linear-gradient(#000 0 0) content-box,
39
+ linear-gradient(#000 0 0);
40
+ -webkit-mask-composite: xor;
41
+ mask:
42
+ linear-gradient(#000 0 0) content-box,
43
+ linear-gradient(#000 0 0);
44
+ mask-composite: exclude;
45
+ opacity: 0;
46
+ transition: opacity var(--elyri-duration-base) var(--elyri-ease-out);
47
+ pointer-events: none;
48
+ }
49
+
50
+ .elyri-spotlight--glow:hover::before,
51
+ .elyri-spotlight--border:hover::after {
52
+ opacity: 1;
53
+ }
54
+
55
+ /* 内部组件的边框光晕:给需要点亮的元素加 elyri-spotlight__item 即可 */
56
+ .elyri-spotlight__item {
57
+ position: relative;
58
+ }
59
+
60
+ .elyri-spotlight__item::after {
61
+ content: '';
62
+ position: absolute;
63
+ inset: 0;
64
+ border-radius: inherit;
65
+ padding: 1px;
66
+ background: radial-gradient(
67
+ calc(var(--elyri-spotlight-size, 240px) * 0.75) circle at var(--elyri-spotlight-x, -500px)
68
+ var(--elyri-spotlight-y, -500px),
69
+ var(--elyri-spotlight-color, #7c8cff),
70
+ transparent 70%
71
+ );
72
+ -webkit-mask:
73
+ linear-gradient(#000 0 0) content-box,
74
+ linear-gradient(#000 0 0);
75
+ -webkit-mask-composite: xor;
76
+ mask:
77
+ linear-gradient(#000 0 0) content-box,
78
+ linear-gradient(#000 0 0);
79
+ mask-composite: exclude;
80
+ opacity: 0;
81
+ transition: opacity var(--elyri-duration-base) var(--elyri-ease-out);
82
+ pointer-events: none;
83
+ }
84
+
85
+ .elyri-spotlight:hover .elyri-spotlight__item::after {
86
+ opacity: 1;
87
+ }
88
+
89
+ .elyri-spotlight__content {
90
+ position: relative;
91
+ z-index: 1;
92
+ height: 100%;
93
+ }