@oalacea/chaosui 0.1.0

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 (45) hide show
  1. package/bin/cli.js +216 -0
  2. package/components/backgrounds/glow-orbs/glow-orbs.module.css +31 -0
  3. package/components/backgrounds/glow-orbs/index.tsx +87 -0
  4. package/components/backgrounds/light-beams/index.tsx +80 -0
  5. package/components/backgrounds/light-beams/light-beams.module.css +27 -0
  6. package/components/backgrounds/noise-canvas/index.tsx +113 -0
  7. package/components/backgrounds/noise-canvas/noise-canvas.module.css +8 -0
  8. package/components/backgrounds/particle-field/index.tsx +81 -0
  9. package/components/backgrounds/particle-field/particle-field.module.css +31 -0
  10. package/components/buttons/chaos-button/chaos-button.module.css +173 -0
  11. package/components/buttons/chaos-button/index.tsx +60 -0
  12. package/components/buttons/glitch-button/glitch-button.module.css +197 -0
  13. package/components/buttons/glitch-button/index.tsx +53 -0
  14. package/components/effects/cursor-follower/cursor-follower.module.css +50 -0
  15. package/components/effects/cursor-follower/index.tsx +83 -0
  16. package/components/effects/screen-distortion/index.tsx +54 -0
  17. package/components/effects/screen-distortion/screen-distortion.module.css +127 -0
  18. package/components/effects/warning-tape/index.tsx +64 -0
  19. package/components/effects/warning-tape/warning-tape.module.css +29 -0
  20. package/components/glow-orbs/glow-orbs.module.css +31 -0
  21. package/components/glow-orbs/index.tsx +87 -0
  22. package/components/light-beams/index.tsx +80 -0
  23. package/components/light-beams/light-beams.module.css +27 -0
  24. package/components/noise-canvas/index.tsx +113 -0
  25. package/components/noise-canvas/noise-canvas.module.css +8 -0
  26. package/components/overlays/noise-overlay/index.tsx +56 -0
  27. package/components/overlays/noise-overlay/noise-overlay.module.css +20 -0
  28. package/components/overlays/scanlines/index.tsx +61 -0
  29. package/components/overlays/scanlines/scanlines.module.css +16 -0
  30. package/components/overlays/static-flicker/index.tsx +51 -0
  31. package/components/overlays/static-flicker/static-flicker.module.css +27 -0
  32. package/components/overlays/vignette/index.tsx +58 -0
  33. package/components/overlays/vignette/vignette.module.css +7 -0
  34. package/components/package.json +13 -0
  35. package/components/particle-field/index.tsx +81 -0
  36. package/components/particle-field/particle-field.module.css +31 -0
  37. package/components/text/distortion-text/distortion-text.module.css +100 -0
  38. package/components/text/distortion-text/index.tsx +53 -0
  39. package/components/text/falling-text/falling-text.module.css +57 -0
  40. package/components/text/falling-text/index.tsx +61 -0
  41. package/components/text/flicker-text/flicker-text.module.css +91 -0
  42. package/components/text/flicker-text/index.tsx +48 -0
  43. package/components/text/glitch-text/glitch-text.module.css +142 -0
  44. package/components/text/glitch-text/index.tsx +53 -0
  45. package/package.json +38 -0
package/bin/cli.js ADDED
@@ -0,0 +1,216 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Command } from 'commander';
4
+ import pc from 'picocolors';
5
+ import prompts from 'prompts';
6
+ import fs from 'fs-extra';
7
+ import path from 'path';
8
+ import { fileURLToPath } from 'url';
9
+
10
+ const __filename = fileURLToPath(import.meta.url);
11
+ const __dirname = path.dirname(__filename);
12
+ const COMPONENTS_DIR = path.join(__dirname, '..', 'components');
13
+
14
+ // Component registry
15
+ const COMPONENTS = {
16
+ // Overlays
17
+ 'noise-overlay': { category: 'overlays', description: 'SVG fractal noise texture overlay', status: 'ready' },
18
+ 'scanlines': { category: 'overlays', description: 'CRT-style horizontal scanlines', status: 'ready' },
19
+ 'vignette': { category: 'overlays', description: 'Dark edges radial gradient', status: 'ready' },
20
+ 'static-flicker': { category: 'overlays', description: 'Animated noise with flicker effect', status: 'ready' },
21
+
22
+ // Text effects
23
+ 'glitch-text': { category: 'text', description: 'RGB split glitch text effect', status: 'ready' },
24
+ 'flicker-text': { category: 'text', description: 'Text that flickers randomly', status: 'ready' },
25
+ 'distortion-text': { category: 'text', description: 'Wave/shake/skew/blur text effects', status: 'ready' },
26
+ 'falling-text': { category: 'text', description: 'Letters falling in cascade', status: 'ready' },
27
+
28
+ // Buttons
29
+ 'glitch-button': { category: 'buttons', description: 'Button with glitch hover effect', status: 'ready' },
30
+ 'chaos-button': { category: 'buttons', description: 'Chaotic animated button with debris', status: 'ready' },
31
+
32
+ // Backgrounds
33
+ 'noise-canvas': { category: 'backgrounds', description: 'Animated noise canvas background', status: 'ready' },
34
+ 'light-beams': { category: 'backgrounds', description: 'Vertical colored light beams', status: 'ready' },
35
+ 'glow-orbs': { category: 'backgrounds', description: 'Floating blurred orbs', status: 'ready' },
36
+ 'particle-field': { category: 'backgrounds', description: 'Drifting particle background', status: 'ready' },
37
+
38
+ // Effects
39
+ 'warning-tape': { category: 'effects', description: 'Scrolling warning tape banner', status: 'ready' },
40
+ 'cursor-follower': { category: 'effects', description: 'Custom cursor with trail', status: 'ready' },
41
+ 'screen-distortion': { category: 'effects', description: 'Full screen distortion effect', status: 'ready' },
42
+ };
43
+
44
+ const program = new Command();
45
+
46
+ program
47
+ .name('chaos-ui')
48
+ .description('Add glitch & noise components to your project')
49
+ .version('0.1.0');
50
+
51
+ // LIST command
52
+ program
53
+ .command('list')
54
+ .description('List all available components')
55
+ .action(() => {
56
+ console.log(pc.bold('\n🗡️ Chaos UI Components\n'));
57
+
58
+ const categories = {};
59
+ for (const [name, info] of Object.entries(COMPONENTS)) {
60
+ if (!categories[info.category]) categories[info.category] = [];
61
+ categories[info.category].push({ name, ...info });
62
+ }
63
+
64
+ for (const [category, components] of Object.entries(categories)) {
65
+ console.log(pc.cyan(` ${category}/`));
66
+ for (const comp of components) {
67
+ console.log(` ${pc.white(comp.name.padEnd(20))} ${pc.dim(comp.description)}`);
68
+ }
69
+ console.log();
70
+ }
71
+ });
72
+
73
+ // ADD command
74
+ program
75
+ .command('add [component]')
76
+ .description('Add a component to your project')
77
+ .option('-d, --dir <path>', 'Target directory', './components/chaos')
78
+ .option('-y, --yes', 'Skip confirmation')
79
+ .action(async (componentName, options) => {
80
+ // If no component specified, show interactive picker
81
+ if (!componentName) {
82
+ const choices = Object.entries(COMPONENTS).map(([name, info]) => ({
83
+ title: name,
84
+ description: info.description,
85
+ value: name,
86
+ }));
87
+
88
+ const response = await prompts({
89
+ type: 'autocomplete',
90
+ name: 'component',
91
+ message: 'Which component?',
92
+ choices,
93
+ suggest: (input, choices) =>
94
+ choices.filter(c => c.title.includes(input) || c.description.toLowerCase().includes(input.toLowerCase()))
95
+ });
96
+
97
+ if (!response.component) {
98
+ console.log(pc.dim('Cancelled.'));
99
+ return;
100
+ }
101
+ componentName = response.component;
102
+ }
103
+
104
+ // Validate component exists
105
+ if (!COMPONENTS[componentName]) {
106
+ console.log(pc.red(`\n✗ Component "${componentName}" not found.`));
107
+ console.log(pc.dim(` Run ${pc.white('chaos-ui list')} to see available components.\n`));
108
+ return;
109
+ }
110
+
111
+ const info = COMPONENTS[componentName];
112
+ const sourceDir = path.join(COMPONENTS_DIR, info.category, componentName);
113
+ const targetDir = path.resolve(options.dir, info.category);
114
+
115
+ // Check source exists
116
+ if (!fs.existsSync(sourceDir)) {
117
+ console.log(pc.yellow(`\n⚠ Component "${componentName}" is not yet implemented.`));
118
+ console.log(pc.dim(' Coming soon!\n'));
119
+ return;
120
+ }
121
+
122
+ // Confirm
123
+ if (!options.yes) {
124
+ const confirm = await prompts({
125
+ type: 'confirm',
126
+ name: 'value',
127
+ message: `Add ${pc.cyan(componentName)} to ${pc.dim(targetDir)}?`,
128
+ initial: true,
129
+ });
130
+
131
+ if (!confirm.value) {
132
+ console.log(pc.dim('Cancelled.'));
133
+ return;
134
+ }
135
+ }
136
+
137
+ // Copy files
138
+ try {
139
+ await fs.ensureDir(targetDir);
140
+ await fs.copy(sourceDir, path.join(targetDir, componentName));
141
+
142
+ console.log(pc.green(`\n✓ Added ${componentName}`));
143
+ console.log(pc.dim(` → ${path.join(targetDir, componentName)}\n`));
144
+
145
+ // Show usage hint
146
+ const pascalName = componentName
147
+ .split('-')
148
+ .map(s => s.charAt(0).toUpperCase() + s.slice(1))
149
+ .join('');
150
+
151
+ console.log(pc.dim(' Import:'));
152
+ console.log(pc.white(` import { ${pascalName} } from './components/chaos/${info.category}/${componentName}';\n`));
153
+
154
+ } catch (err) {
155
+ console.log(pc.red(`\n✗ Failed to add component: ${err.message}\n`));
156
+ }
157
+ });
158
+
159
+ // INIT command
160
+ program
161
+ .command('init')
162
+ .description('Initialize chaos-ui in your project')
163
+ .action(async () => {
164
+ console.log(pc.bold('\n🗡️ Initializing Chaos UI...\n'));
165
+
166
+ const response = await prompts([
167
+ {
168
+ type: 'select',
169
+ name: 'framework',
170
+ message: 'Framework?',
171
+ choices: [
172
+ { title: 'React', value: 'react' },
173
+ { title: 'Vue', value: 'vue' },
174
+ { title: 'Vanilla (HTML/CSS/JS)', value: 'vanilla' },
175
+ ],
176
+ },
177
+ {
178
+ type: 'select',
179
+ name: 'styling',
180
+ message: 'Styling?',
181
+ choices: [
182
+ { title: 'CSS Modules', value: 'css-modules' },
183
+ { title: 'Plain CSS', value: 'css' },
184
+ { title: 'Tailwind (with CSS vars)', value: 'tailwind' },
185
+ ],
186
+ },
187
+ {
188
+ type: 'text',
189
+ name: 'dir',
190
+ message: 'Components directory?',
191
+ initial: './components/chaos',
192
+ },
193
+ ]);
194
+
195
+ if (!response.framework) {
196
+ console.log(pc.dim('Cancelled.'));
197
+ return;
198
+ }
199
+
200
+ // Create config file
201
+ const config = {
202
+ framework: response.framework,
203
+ styling: response.styling,
204
+ componentsDir: response.dir,
205
+ };
206
+
207
+ await fs.writeJson('./chaos-ui.json', config, { spaces: 2 });
208
+ await fs.ensureDir(response.dir);
209
+
210
+ console.log(pc.green('\n✓ Initialized!'));
211
+ console.log(pc.dim(` Config: ${pc.white('./chaos-ui.json')}`));
212
+ console.log(pc.dim(` Components: ${pc.white(response.dir)}\n`));
213
+ console.log(pc.dim(` Run ${pc.white('chaos-ui add <component>')} to add components.\n`));
214
+ });
215
+
216
+ program.parse();
@@ -0,0 +1,31 @@
1
+ .container {
2
+ inset: 0;
3
+ width: 100%;
4
+ height: 100%;
5
+ overflow: hidden;
6
+ pointer-events: none;
7
+ z-index: 1;
8
+ }
9
+
10
+ .orb {
11
+ position: absolute;
12
+ border-radius: 50%;
13
+ opacity: 0.3;
14
+ transform: translate(-50%, -50%);
15
+ animation: float 20s ease-in-out infinite;
16
+ }
17
+
18
+ @keyframes float {
19
+ 0%, 100% {
20
+ transform: translate(-50%, -50%) translate(0, 0);
21
+ }
22
+ 25% {
23
+ transform: translate(-50%, -50%) translate(30px, -40px);
24
+ }
25
+ 50% {
26
+ transform: translate(-50%, -50%) translate(-20px, 20px);
27
+ }
28
+ 75% {
29
+ transform: translate(-50%, -50%) translate(40px, 30px);
30
+ }
31
+ }
@@ -0,0 +1,87 @@
1
+ 'use client';
2
+
3
+ import { forwardRef, HTMLAttributes } from 'react';
4
+ import styles from './glow-orbs.module.css';
5
+
6
+ export interface GlowOrbsProps extends HTMLAttributes<HTMLDivElement> {
7
+ /** Array of orb colors (will cycle through) */
8
+ colors?: string[];
9
+ /** Number of orbs */
10
+ count?: number;
11
+ /** Orb size range [min, max] in pixels */
12
+ sizeRange?: [number, number];
13
+ /** Blur amount in pixels */
14
+ blur?: number;
15
+ /** Animation duration range [min, max] in seconds */
16
+ durationRange?: [number, number];
17
+ /** Fixed or absolute positioning */
18
+ position?: 'fixed' | 'absolute';
19
+ }
20
+
21
+ export const GlowOrbs = forwardRef<HTMLDivElement, GlowOrbsProps>(
22
+ (
23
+ {
24
+ colors = ['#7c3aed', '#06b6d4', '#ec4899', '#10b981'],
25
+ count = 5,
26
+ sizeRange = [100, 300],
27
+ blur = 80,
28
+ durationRange = [15, 25],
29
+ position = 'fixed',
30
+ className,
31
+ style,
32
+ ...props
33
+ },
34
+ ref
35
+ ) => {
36
+ // Generate random orbs
37
+ const orbs = Array.from({ length: count }, (_, i) => {
38
+ const size = sizeRange[0] + Math.random() * (sizeRange[1] - sizeRange[0]);
39
+ const duration = durationRange[0] + Math.random() * (durationRange[1] - durationRange[0]);
40
+ const delay = Math.random() * -duration;
41
+ const color = colors[i % colors.length];
42
+ const startX = Math.random() * 100;
43
+ const startY = Math.random() * 100;
44
+
45
+ return {
46
+ id: i,
47
+ size,
48
+ duration,
49
+ delay,
50
+ color,
51
+ startX,
52
+ startY,
53
+ };
54
+ });
55
+
56
+ return (
57
+ <div
58
+ ref={ref}
59
+ className={`${styles.container} ${className || ''}`}
60
+ style={{ position, ...style }}
61
+ aria-hidden="true"
62
+ {...props}
63
+ >
64
+ {orbs.map((orb) => (
65
+ <div
66
+ key={orb.id}
67
+ className={styles.orb}
68
+ style={{
69
+ width: orb.size,
70
+ height: orb.size,
71
+ background: orb.color,
72
+ filter: `blur(${blur}px)`,
73
+ left: `${orb.startX}%`,
74
+ top: `${orb.startY}%`,
75
+ animationDuration: `${orb.duration}s`,
76
+ animationDelay: `${orb.delay}s`,
77
+ }}
78
+ />
79
+ ))}
80
+ </div>
81
+ );
82
+ }
83
+ );
84
+
85
+ GlowOrbs.displayName = 'GlowOrbs';
86
+
87
+ export default GlowOrbs;
@@ -0,0 +1,80 @@
1
+ 'use client';
2
+
3
+ import { forwardRef, HTMLAttributes } from 'react';
4
+ import styles from './light-beams.module.css';
5
+
6
+ export interface LightBeamsProps extends HTMLAttributes<HTMLDivElement> {
7
+ /** Array of beam colors */
8
+ colors?: string[];
9
+ /** Number of beams */
10
+ count?: number;
11
+ /** Beam width in pixels */
12
+ beamWidth?: number;
13
+ /** Beam opacity (0-1) */
14
+ opacity?: number;
15
+ /** Animation duration range [min, max] in seconds */
16
+ durationRange?: [number, number];
17
+ /** Fixed or absolute positioning */
18
+ position?: 'fixed' | 'absolute';
19
+ }
20
+
21
+ export const LightBeams = forwardRef<HTMLDivElement, LightBeamsProps>(
22
+ (
23
+ {
24
+ colors = ['#7c3aed', '#06b6d4', '#ec4899', '#10b981', '#f59e0b'],
25
+ count = 5,
26
+ beamWidth = 2,
27
+ opacity = 0.15,
28
+ durationRange = [15, 25],
29
+ position = 'fixed',
30
+ className,
31
+ style,
32
+ ...props
33
+ },
34
+ ref
35
+ ) => {
36
+ const beams = Array.from({ length: count }, (_, i) => {
37
+ const duration = durationRange[0] + Math.random() * (durationRange[1] - durationRange[0]);
38
+ const delay = Math.random() * -duration;
39
+ const color = colors[i % colors.length];
40
+ const positionX = ((i + 1) / (count + 1)) * 100;
41
+
42
+ return {
43
+ id: i,
44
+ duration,
45
+ delay,
46
+ color,
47
+ positionX,
48
+ };
49
+ });
50
+
51
+ return (
52
+ <div
53
+ ref={ref}
54
+ className={`${styles.container} ${className || ''}`}
55
+ style={{ position, ...style }}
56
+ aria-hidden="true"
57
+ {...props}
58
+ >
59
+ {beams.map((beam) => (
60
+ <div
61
+ key={beam.id}
62
+ className={styles.beam}
63
+ style={{
64
+ left: `${beam.positionX}%`,
65
+ width: beamWidth,
66
+ background: `linear-gradient(180deg, transparent, ${beam.color}, transparent)`,
67
+ opacity,
68
+ animationDuration: `${beam.duration}s`,
69
+ animationDelay: `${beam.delay}s`,
70
+ }}
71
+ />
72
+ ))}
73
+ </div>
74
+ );
75
+ }
76
+ );
77
+
78
+ LightBeams.displayName = 'LightBeams';
79
+
80
+ export default LightBeams;
@@ -0,0 +1,27 @@
1
+ .container {
2
+ inset: 0;
3
+ width: 100%;
4
+ height: 100%;
5
+ overflow: hidden;
6
+ pointer-events: none;
7
+ z-index: 1;
8
+ }
9
+
10
+ .beam {
11
+ position: absolute;
12
+ top: 0;
13
+ height: 100%;
14
+ filter: blur(1px);
15
+ animation: beam-move 20s ease-in-out infinite;
16
+ }
17
+
18
+ @keyframes beam-move {
19
+ 0%, 100% {
20
+ transform: translateX(-50px) skewX(-5deg);
21
+ opacity: 0.1;
22
+ }
23
+ 50% {
24
+ transform: translateX(50px) skewX(5deg);
25
+ opacity: 0.2;
26
+ }
27
+ }
@@ -0,0 +1,113 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, forwardRef, HTMLAttributes } from 'react';
4
+ import styles from './noise-canvas.module.css';
5
+
6
+ export interface NoiseCanvasProps extends HTMLAttributes<HTMLCanvasElement> {
7
+ /** Noise opacity (0-1) */
8
+ opacity?: number;
9
+ /** Animation speed (fps) */
10
+ fps?: number;
11
+ /** Noise intensity (0-255) */
12
+ intensity?: number;
13
+ /** Monochrome or colored noise */
14
+ monochrome?: boolean;
15
+ /** Fixed or absolute positioning */
16
+ position?: 'fixed' | 'absolute';
17
+ }
18
+
19
+ export const NoiseCanvas = forwardRef<HTMLCanvasElement, NoiseCanvasProps>(
20
+ (
21
+ {
22
+ opacity = 0.05,
23
+ fps = 24,
24
+ intensity = 50,
25
+ monochrome = true,
26
+ position = 'fixed',
27
+ className,
28
+ style,
29
+ ...props
30
+ },
31
+ ref
32
+ ) => {
33
+ const canvasRef = useRef<HTMLCanvasElement>(null);
34
+ const animationRef = useRef<number>();
35
+
36
+ useEffect(() => {
37
+ const canvas = canvasRef.current;
38
+ if (!canvas) return;
39
+
40
+ const ctx = canvas.getContext('2d');
41
+ if (!ctx) return;
42
+
43
+ const resize = () => {
44
+ canvas.width = window.innerWidth;
45
+ canvas.height = window.innerHeight;
46
+ };
47
+
48
+ resize();
49
+ window.addEventListener('resize', resize);
50
+
51
+ let lastFrame = 0;
52
+ const frameInterval = 1000 / fps;
53
+
54
+ const render = (timestamp: number) => {
55
+ if (timestamp - lastFrame >= frameInterval) {
56
+ const imageData = ctx.createImageData(canvas.width, canvas.height);
57
+ const data = imageData.data;
58
+
59
+ for (let i = 0; i < data.length; i += 4) {
60
+ const value = Math.random() * intensity;
61
+
62
+ if (monochrome) {
63
+ data[i] = value;
64
+ data[i + 1] = value;
65
+ data[i + 2] = value;
66
+ } else {
67
+ data[i] = Math.random() * intensity;
68
+ data[i + 1] = Math.random() * intensity;
69
+ data[i + 2] = Math.random() * intensity;
70
+ }
71
+ data[i + 3] = 255;
72
+ }
73
+
74
+ ctx.putImageData(imageData, 0, 0);
75
+ lastFrame = timestamp;
76
+ }
77
+
78
+ animationRef.current = requestAnimationFrame(render);
79
+ };
80
+
81
+ animationRef.current = requestAnimationFrame(render);
82
+
83
+ return () => {
84
+ window.removeEventListener('resize', resize);
85
+ if (animationRef.current) {
86
+ cancelAnimationFrame(animationRef.current);
87
+ }
88
+ };
89
+ }, [fps, intensity, monochrome]);
90
+
91
+ return (
92
+ <canvas
93
+ ref={(node) => {
94
+ (canvasRef as React.MutableRefObject<HTMLCanvasElement | null>).current = node;
95
+ if (typeof ref === 'function') ref(node);
96
+ else if (ref) ref.current = node;
97
+ }}
98
+ className={`${styles.canvas} ${className || ''}`}
99
+ style={{
100
+ position,
101
+ opacity,
102
+ ...style,
103
+ }}
104
+ aria-hidden="true"
105
+ {...props}
106
+ />
107
+ );
108
+ }
109
+ );
110
+
111
+ NoiseCanvas.displayName = 'NoiseCanvas';
112
+
113
+ export default NoiseCanvas;
@@ -0,0 +1,8 @@
1
+ .canvas {
2
+ inset: 0;
3
+ width: 100%;
4
+ height: 100%;
5
+ pointer-events: none;
6
+ z-index: 9999;
7
+ mix-blend-mode: overlay;
8
+ }
@@ -0,0 +1,81 @@
1
+ 'use client';
2
+
3
+ import { forwardRef, HTMLAttributes, useMemo } from 'react';
4
+ import styles from './particle-field.module.css';
5
+
6
+ export interface ParticleFieldProps extends HTMLAttributes<HTMLDivElement> {
7
+ /** Number of particles */
8
+ count?: number;
9
+ /** Particle color */
10
+ color?: string;
11
+ /** Particle size range [min, max] in pixels */
12
+ sizeRange?: [number, number];
13
+ /** Animation duration range [min, max] in seconds */
14
+ durationRange?: [number, number];
15
+ /** Particle opacity */
16
+ opacity?: number;
17
+ /** Fixed or absolute positioning */
18
+ position?: 'fixed' | 'absolute';
19
+ }
20
+
21
+ export const ParticleField = forwardRef<HTMLDivElement, ParticleFieldProps>(
22
+ (
23
+ {
24
+ count = 50,
25
+ color = '#ffffff',
26
+ sizeRange = [1, 3],
27
+ durationRange = [10, 20],
28
+ opacity = 0.5,
29
+ position = 'fixed',
30
+ className,
31
+ style,
32
+ ...props
33
+ },
34
+ ref
35
+ ) => {
36
+ const particles = useMemo(() => {
37
+ return Array.from({ length: count }, (_, i) => {
38
+ const size = sizeRange[0] + Math.random() * (sizeRange[1] - sizeRange[0]);
39
+ const duration = durationRange[0] + Math.random() * (durationRange[1] - durationRange[0]);
40
+ const delay = Math.random() * -duration;
41
+ const startX = Math.random() * 100;
42
+ const startY = Math.random() * 100;
43
+ const drift = (Math.random() - 0.5) * 100;
44
+
45
+ return { id: i, size, duration, delay, startX, startY, drift };
46
+ });
47
+ }, [count, sizeRange, durationRange]);
48
+
49
+ return (
50
+ <div
51
+ ref={ref}
52
+ className={`${styles.container} ${className || ''}`}
53
+ style={{ position, ...style }}
54
+ aria-hidden="true"
55
+ {...props}
56
+ >
57
+ {particles.map((p) => (
58
+ <div
59
+ key={p.id}
60
+ className={styles.particle}
61
+ style={{
62
+ width: p.size,
63
+ height: p.size,
64
+ backgroundColor: color,
65
+ opacity,
66
+ left: `${p.startX}%`,
67
+ top: `${p.startY}%`,
68
+ animationDuration: `${p.duration}s`,
69
+ animationDelay: `${p.delay}s`,
70
+ '--drift': `${p.drift}px`,
71
+ } as React.CSSProperties}
72
+ />
73
+ ))}
74
+ </div>
75
+ );
76
+ }
77
+ );
78
+
79
+ ParticleField.displayName = 'ParticleField';
80
+
81
+ export default ParticleField;
@@ -0,0 +1,31 @@
1
+ .container {
2
+ inset: 0;
3
+ width: 100%;
4
+ height: 100%;
5
+ overflow: hidden;
6
+ pointer-events: none;
7
+ z-index: 1;
8
+ }
9
+
10
+ .particle {
11
+ position: absolute;
12
+ border-radius: 50%;
13
+ animation: drift linear infinite;
14
+ }
15
+
16
+ @keyframes drift {
17
+ 0% {
18
+ transform: translate(0, 0);
19
+ opacity: 0;
20
+ }
21
+ 10% {
22
+ opacity: var(--opacity, 0.5);
23
+ }
24
+ 90% {
25
+ opacity: var(--opacity, 0.5);
26
+ }
27
+ 100% {
28
+ transform: translate(var(--drift, 50px), -100vh);
29
+ opacity: 0;
30
+ }
31
+ }