trix-ui 0.2.0 → 0.2.2

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 (84) hide show
  1. package/README.md +110 -19
  2. package/dist/commands/add/__tests__/add.test.js +18 -6
  3. package/dist/commands/add/__tests__/add.test.js.map +1 -1
  4. package/dist/commands/add/analysis.js +6 -1
  5. package/dist/commands/add/analysis.js.map +1 -1
  6. package/dist/commands/add/command.js +6 -0
  7. package/dist/commands/add/command.js.map +1 -1
  8. package/dist/commands/add/types.d.ts +1 -0
  9. package/dist/commands/add/ui.js +4 -0
  10. package/dist/commands/add/ui.js.map +1 -1
  11. package/dist/commands/add-composite.d.ts +2 -0
  12. package/dist/commands/add-composite.js +202 -0
  13. package/dist/commands/add-composite.js.map +1 -0
  14. package/dist/commands/add-section.js +6 -0
  15. package/dist/commands/add-section.js.map +1 -1
  16. package/dist/commands/add-wrapper.js +7 -1
  17. package/dist/commands/add-wrapper.js.map +1 -1
  18. package/dist/commands/doctor.js +9 -4
  19. package/dist/commands/doctor.js.map +1 -1
  20. package/dist/commands/init/config.js +1 -0
  21. package/dist/commands/init/config.js.map +1 -1
  22. package/dist/commands/list-sections.js +2 -7
  23. package/dist/commands/list-sections.js.map +1 -1
  24. package/dist/commands/list-wrappers.js +2 -7
  25. package/dist/commands/list-wrappers.js.map +1 -1
  26. package/dist/commands/list.js +51 -8
  27. package/dist/commands/list.js.map +1 -1
  28. package/dist/commands/remove-section.js +9 -34
  29. package/dist/commands/remove-section.js.map +1 -1
  30. package/dist/commands/remove-wrapper.js +9 -34
  31. package/dist/commands/remove-wrapper.js.map +1 -1
  32. package/dist/commands/remove.js +71 -38
  33. package/dist/commands/remove.js.map +1 -1
  34. package/dist/commands/shared/add-collection.d.ts +2 -1
  35. package/dist/commands/shared/add-collection.js +10 -13
  36. package/dist/commands/shared/add-collection.js.map +1 -1
  37. package/dist/commands/shared/list-entries.d.ts +6 -0
  38. package/dist/commands/shared/list-entries.js +13 -0
  39. package/dist/commands/shared/list-entries.js.map +1 -0
  40. package/dist/commands/shared/name-utils.d.ts +1 -0
  41. package/dist/commands/shared/name-utils.js +14 -0
  42. package/dist/commands/shared/name-utils.js.map +1 -0
  43. package/dist/commands/shared/remove-entries.d.ts +16 -0
  44. package/dist/commands/shared/remove-entries.js +42 -0
  45. package/dist/commands/shared/remove-entries.js.map +1 -0
  46. package/dist/index.js +11 -8
  47. package/dist/index.js.map +1 -1
  48. package/dist/lib/config.d.ts +1 -0
  49. package/dist/lib/config.js +1 -0
  50. package/dist/lib/config.js.map +1 -1
  51. package/dist/lib/install.js +14 -7
  52. package/dist/lib/install.js.map +1 -1
  53. package/dist/lib/lockfile.d.ts +6 -5
  54. package/dist/lib/lockfile.js +26 -7
  55. package/dist/lib/lockfile.js.map +1 -1
  56. package/dist/lib/paths.d.ts +1 -0
  57. package/dist/lib/paths.js +1 -0
  58. package/dist/lib/paths.js.map +1 -1
  59. package/dist/lib/registry.d.ts +3 -0
  60. package/dist/lib/registry.js +15 -0
  61. package/dist/lib/registry.js.map +1 -1
  62. package/package.json +12 -12
  63. package/registry/index.json +67 -128
  64. package/templates/components/ui/avatar.tsx +109 -0
  65. package/templates/components/ui/button.tsx +48 -44
  66. package/templates/components/ui/label.tsx +24 -0
  67. package/templates/composites/feature-collection-card.tsx +113 -0
  68. package/templates/composites/music-player-card.tsx +221 -0
  69. package/templates/composites/user-profile-card.tsx +145 -0
  70. package/templates/sections/modern-hero.tsx +1226 -0
  71. package/templates/wrappers/Interative-wrapper.tsx +555 -0
  72. package/LICENSE.md +0 -21
  73. package/templates/components/ui/checkbox.tsx +0 -33
  74. package/templates/components/ui/dialog.tsx +0 -92
  75. package/templates/components/ui/dropdown.tsx +0 -75
  76. package/templates/components/ui/select.tsx +0 -24
  77. package/templates/components/ui/switch.tsx +0 -27
  78. package/templates/components/ui/toast.tsx +0 -100
  79. package/templates/sections/cta.tsx +0 -22
  80. package/templates/sections/feature-grid.tsx +0 -62
  81. package/templates/sections/hero.tsx +0 -63
  82. package/templates/wrappers/border-wrapper.tsx +0 -34
  83. package/templates/wrappers/glow-wrapper.tsx +0 -31
  84. package/templates/wrappers/lift-wrapper.tsx +0 -27
@@ -0,0 +1,1226 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { cn } from "@/lib/utils";
5
+ import { Button } from "@/components/ui/button";
6
+
7
+ /* --------------------------------- Types --------------------------------- */
8
+
9
+ export type GridRunnerBackgroundIntensity = "subtle" | "medium" | "vibrant";
10
+
11
+ export type GridRunnerAction = {
12
+ /** Button label */
13
+ label: string;
14
+ /** If provided, renders as <a> via Button asChild */
15
+ href?: string;
16
+ target?: string;
17
+ rel?: string;
18
+ onClick?: () => void;
19
+ /** Extra classes for the Button */
20
+ className?: string;
21
+ };
22
+
23
+ type Point = { x: number; y: number };
24
+
25
+ type RGBA = { r: number; g: number; b: number; a: number };
26
+
27
+ type Particle = {
28
+ id: number;
29
+ path: Point[];
30
+ duration: number;
31
+ delay: number;
32
+ color: RGBA;
33
+ size: number;
34
+ glowIntensity: number;
35
+ };
36
+
37
+ export type GridRunnerHeroProps = Omit<
38
+ React.ComponentPropsWithoutRef<"section">,
39
+ "title"
40
+ > & {
41
+ /* Content */
42
+ showBadge?: boolean;
43
+ badgeText?: string;
44
+ title?: React.ReactNode;
45
+ subtitle?: React.ReactNode;
46
+
47
+ primaryAction?: GridRunnerAction;
48
+ softAction?: GridRunnerAction;
49
+
50
+ /* Layout */
51
+ minHeight?: number; // px
52
+
53
+ /* Grid / Particles */
54
+ gridSize?: number; // px
55
+ particleCount?: number;
56
+ maxParticleCount?: number; // safety clamp
57
+ seed?: number; // deterministic randomness (stable across renders)
58
+
59
+ /** Global speed multiplier for all particle animations. (0.25..4) */
60
+ particleSpeed?: number;
61
+
62
+ /** Cap the generated keyframe points per particle to keep CSS small. (12..80) */
63
+ maxPathPoints?: number;
64
+
65
+ /** Connection range (higher = more connections). Default 8. */
66
+ connectionRange?: number;
67
+
68
+ /** Base size of particles in px. (4..16) */
69
+ particleSize?: number;
70
+
71
+ /** Random size variance added/subtracted from base size. (0..8) */
72
+ particleSizeVariance?: number;
73
+
74
+ /** Overall glow strength multiplier for particles. (0..2.5) */
75
+ particleGlowStrength?: number;
76
+
77
+ /** Shadow ring strength multiplier (the 1px outline). (0..2) */
78
+ particleRingStrength?: number;
79
+
80
+ /** Motion trail length multiplier. (0.5..2.5) */
81
+ particleTrailLength?: number;
82
+
83
+ /** Ghost trail settings */
84
+ showGhostTrail?: boolean;
85
+ ghostTrailStrength?: number; // 0..1
86
+ ghostTrailCount?: number; // 0..4
87
+ ghostTrailSpacing?: number; // seconds between ghost offsets
88
+
89
+ /** Draw connection lines between particles */
90
+ showConnections?: boolean;
91
+
92
+ /* Background layers */
93
+ backgroundIntensity?: GridRunnerBackgroundIntensity;
94
+ showMesh?: boolean;
95
+ showNoise?: boolean;
96
+ showOrbs?: boolean;
97
+ showBeams?: boolean;
98
+
99
+ /* Accessibility / Motion */
100
+ reducedMotion?: boolean; // force disable motion
101
+ };
102
+
103
+ /* -------------------------------- Helpers -------------------------------- */
104
+
105
+ const clamp = (n: number, min: number, max: number) =>
106
+ Math.max(min, Math.min(max, n));
107
+
108
+ function mulberry32(seed: number) {
109
+ let a = seed >>> 0;
110
+ return function rand() {
111
+ a |= 0;
112
+ a = (a + 0x6d2b79f5) | 0;
113
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
114
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
115
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
116
+ };
117
+ }
118
+
119
+ function sanitizeId(id: string) {
120
+ // React.useId() can contain ":" which isn't great for CSS animation names
121
+ return id.replace(/[^a-zA-Z0-9_-]/g, "");
122
+ }
123
+
124
+ function usePrefersReducedMotion() {
125
+ const [reduced, setReduced] = React.useState(false);
126
+ React.useEffect(() => {
127
+ const mql = window.matchMedia?.("(prefers-reduced-motion: reduce)");
128
+ if (!mql) return;
129
+ const onChange = () => setReduced(!!mql.matches);
130
+ onChange();
131
+ mql.addEventListener?.("change", onChange);
132
+ return () => mql.removeEventListener?.("change", onChange);
133
+ }, []);
134
+ return reduced;
135
+ }
136
+
137
+ function rgbaToCss(c: RGBA, alphaOverride?: number) {
138
+ const a = typeof alphaOverride === "number" ? alphaOverride : c.a;
139
+ return `rgba(${c.r}, ${c.g}, ${c.b}, ${a})`;
140
+ }
141
+
142
+ function safeNumber(name: string, value: number, fallback: number) {
143
+ const ok = Number.isFinite(value);
144
+ if (process.env.NODE_ENV !== "production" && !ok) {
145
+ // eslint-disable-next-line no-console
146
+ console.warn(
147
+ `[GridRunnerHero] Invalid "${name}" (${value}). Falling back to ${fallback}.`
148
+ );
149
+ }
150
+ return ok ? value : fallback;
151
+ }
152
+
153
+ function downsamplePath(path: Point[], maxPoints: number) {
154
+ if (path.length <= maxPoints) return path;
155
+
156
+ const out: Point[] = [];
157
+ const lastIdx = path.length - 1;
158
+ const step = lastIdx / (maxPoints - 1);
159
+
160
+ for (let i = 0; i < maxPoints; i++) {
161
+ out.push(path[Math.round(i * step)]);
162
+ }
163
+
164
+ // remove consecutive duplicates
165
+ return out.filter(
166
+ (p, i) => i === 0 || p.x !== out[i - 1]!.x || p.y !== out[i - 1]!.y
167
+ );
168
+ }
169
+
170
+ /* -------------------------- Particle Path Factory ------------------------- */
171
+
172
+ function generatePath(
173
+ patternIndex: number,
174
+ cols: number,
175
+ rows: number,
176
+ rand: () => number
177
+ ): Point[] {
178
+ const safeCols = Math.max(cols, 8);
179
+ const safeRows = Math.max(rows, 8);
180
+
181
+ const clampPoint = (p: Point): Point => ({
182
+ x: clamp(p.x, 1, safeCols - 2),
183
+ y: clamp(p.y, 1, safeRows - 2),
184
+ });
185
+
186
+ const patterns: Array<() => Point[]> = [
187
+ // Perimeter clockwise
188
+ () => {
189
+ const margin = 2;
190
+ return [
191
+ { x: margin, y: margin },
192
+ { x: safeCols - margin, y: margin },
193
+ { x: safeCols - margin, y: safeRows - margin },
194
+ { x: margin, y: safeRows - margin },
195
+ { x: margin, y: margin },
196
+ ].map(clampPoint);
197
+ },
198
+
199
+ // Figure-8
200
+ () => {
201
+ const cx = Math.floor(safeCols / 2);
202
+ const cy = Math.floor(safeRows / 2);
203
+ const r = 4;
204
+ return [
205
+ { x: cx - r, y: cy },
206
+ { x: cx, y: cy - r },
207
+ { x: cx + r, y: cy },
208
+ { x: cx, y: cy },
209
+ { x: cx - r, y: cy },
210
+ { x: cx, y: cy + r },
211
+ { x: cx + r, y: cy },
212
+ { x: cx, y: cy },
213
+ ].map(clampPoint);
214
+ },
215
+
216
+ // Spiral inward
217
+ () => {
218
+ const path: Point[] = [];
219
+ let x = 1,
220
+ y = 1;
221
+ let w = safeCols - 2,
222
+ h = safeRows - 2;
223
+
224
+ while (w > 2 && h > 2) {
225
+ for (let i = 0; i < w - 1; i++) path.push({ x: x + i, y });
226
+ for (let i = 0; i < h - 1; i++)
227
+ path.push({ x: x + w - 1, y: y + i });
228
+ for (let i = 0; i < w - 1; i++)
229
+ path.push({ x: x + w - 1 - i, y: y + h - 1 });
230
+ for (let i = 0; i < h - 1; i++)
231
+ path.push({ x, y: y + h - 1 - i });
232
+
233
+ x += 2;
234
+ y += 2;
235
+ w -= 4;
236
+ h -= 4;
237
+ }
238
+
239
+ return (
240
+ path.length
241
+ ? path
242
+ : [{ x: Math.floor(safeCols / 2), y: Math.floor(safeRows / 2) }]
243
+ ).map(clampPoint);
244
+ },
245
+
246
+ // Horizontal wave
247
+ () => {
248
+ const path: Point[] = [];
249
+ const baseRow = Math.floor(safeRows / 2) + (patternIndex % 3) - 1;
250
+ const amp = 2;
251
+ for (let i = 0; i <= safeCols - 2; i++) {
252
+ const wave = Math.floor(Math.sin(i / 3) * amp);
253
+ path.push({ x: i + 1, y: baseRow + wave });
254
+ }
255
+ return path.map(clampPoint);
256
+ },
257
+
258
+ // Diagonal zigzag
259
+ () => {
260
+ const path: Point[] = [];
261
+ let x = 2,
262
+ y = 2;
263
+ const dir = patternIndex % 2 === 0 ? 1 : -1;
264
+
265
+ for (let k = 0; k < Math.min(safeCols + safeRows, 40); k++) {
266
+ path.push({ x, y });
267
+ x += 1;
268
+ y += dir;
269
+ y = clamp(y, 2, safeRows - 3);
270
+ if (x >= safeCols - 2) break;
271
+ }
272
+ return path.map(clampPoint);
273
+ },
274
+
275
+ // Cross pattern
276
+ () => {
277
+ const cx = Math.floor(safeCols / 2);
278
+ const cy = Math.floor(safeRows / 2);
279
+ const len = 5;
280
+ return [
281
+ { x: cx - len, y: cy },
282
+ { x: cx, y: cy },
283
+ { x: cx, y: cy - len },
284
+ { x: cx, y: cy },
285
+ { x: cx + len, y: cy },
286
+ { x: cx, y: cy },
287
+ { x: cx, y: cy + len },
288
+ { x: cx, y: cy },
289
+ ].map(clampPoint);
290
+ },
291
+
292
+ // Random walk
293
+ () => {
294
+ const path: Point[] = [];
295
+ let x =
296
+ Math.floor(safeCols / 4) +
297
+ (patternIndex % 2) * Math.floor(safeCols / 2);
298
+ let y = Math.floor(safeRows / 3);
299
+ path.push({ x, y });
300
+
301
+ const moves = 16;
302
+ for (let i = 0; i < moves; i++) {
303
+ const roll = rand();
304
+ const dir =
305
+ roll < 0.25
306
+ ? { dx: 1, dy: 0 }
307
+ : roll < 0.5
308
+ ? { dx: -1, dy: 0 }
309
+ : roll < 0.75
310
+ ? { dx: 0, dy: 1 }
311
+ : { dx: 0, dy: -1 };
312
+
313
+ x = clamp(x + dir.dx, 1, safeCols - 2);
314
+ y = clamp(y + dir.dy, 1, safeRows - 2);
315
+ path.push({ x, y });
316
+ }
317
+ return path.map(clampPoint);
318
+ },
319
+
320
+ // L-shaped sweep
321
+ () => {
322
+ const startX = patternIndex % 2 === 0 ? 2 : safeCols - 3;
323
+ const startY = 2;
324
+ const midY = Math.floor(safeRows / 2);
325
+ const endX = patternIndex % 2 === 0 ? safeCols - 3 : 2;
326
+ return [
327
+ { x: startX, y: startY },
328
+ { x: startX, y: midY },
329
+ { x: endX, y: midY },
330
+ ].map(clampPoint);
331
+ },
332
+ ];
333
+
334
+ return patterns[patternIndex % patterns.length]();
335
+ }
336
+
337
+ /* ------------------------------- Main Component ---------------------------- */
338
+
339
+ export function GridRunnerHero(props: GridRunnerHeroProps) {
340
+ const {
341
+ showBadge = true,
342
+ badgeText = "Grid Runner Hero",
343
+ title = "Build premium UI, faster.",
344
+ subtitle = "A clean hero with a subtle grid and tiny particles that run on grid lines.",
345
+
346
+ primaryAction = { label: "Get Started" },
347
+ softAction = { label: "Explore Components" },
348
+
349
+ minHeight = 540,
350
+
351
+ gridSize: gridSizeProp = 48,
352
+ particleCount: particleCountProp = 8,
353
+ maxParticleCount = 24,
354
+ seed = 1,
355
+
356
+ particleSpeed: particleSpeedProp = 1,
357
+ maxPathPoints: maxPathPointsProp = 28,
358
+ connectionRange: connectionRangeProp = 8,
359
+
360
+ particleSize: particleSizeProp = 8,
361
+ particleSizeVariance: particleSizeVarianceProp = 2,
362
+ particleGlowStrength: particleGlowStrengthProp = 1,
363
+ particleRingStrength: particleRingStrengthProp = 1,
364
+ particleTrailLength: particleTrailLengthProp = 1,
365
+
366
+ showConnections = true,
367
+
368
+ showGhostTrail = true,
369
+ ghostTrailStrength: ghostTrailStrengthProp = 0.6,
370
+ ghostTrailCount: ghostTrailCountProp = 3,
371
+ ghostTrailSpacing: ghostTrailSpacingProp = 0.15,
372
+
373
+ backgroundIntensity = "vibrant",
374
+ showMesh = true,
375
+ showNoise = true,
376
+ showOrbs = true,
377
+ showBeams = true,
378
+
379
+ reducedMotion: reducedMotionProp,
380
+
381
+ className,
382
+ ...rest
383
+ } = props;
384
+
385
+ const prefersReduced = usePrefersReducedMotion();
386
+ const reducedMotion = !!reducedMotionProp || prefersReduced;
387
+
388
+ const gridSize = clamp(safeNumber("gridSize", gridSizeProp, 48), 16, 128);
389
+ const particleCount = clamp(
390
+ safeNumber("particleCount", particleCountProp, 8),
391
+ 0,
392
+ clamp(maxParticleCount, 0, 64)
393
+ );
394
+ const minH = clamp(safeNumber("minHeight", minHeight, 540), 320, 920);
395
+
396
+ const particleSpeed = clamp(
397
+ safeNumber("particleSpeed", particleSpeedProp, 1),
398
+ 0.25,
399
+ 4
400
+ );
401
+ const maxPathPoints = clamp(
402
+ safeNumber("maxPathPoints", maxPathPointsProp, 28),
403
+ 12,
404
+ 80
405
+ );
406
+ const connectionRange = clamp(
407
+ safeNumber("connectionRange", connectionRangeProp, 8),
408
+ 2,
409
+ 40
410
+ );
411
+
412
+ const particleSizeBase = clamp(
413
+ safeNumber("particleSize", particleSizeProp, 8),
414
+ 4,
415
+ 16
416
+ );
417
+ const particleSizeVariance = clamp(
418
+ safeNumber("particleSizeVariance", particleSizeVarianceProp, 2),
419
+ 0,
420
+ 8
421
+ );
422
+ const particleGlowStrength = clamp(
423
+ safeNumber("particleGlowStrength", particleGlowStrengthProp, 1),
424
+ 0,
425
+ 2.5
426
+ );
427
+ const particleRingStrength = clamp(
428
+ safeNumber("particleRingStrength", particleRingStrengthProp, 1),
429
+ 0,
430
+ 2
431
+ );
432
+ const particleTrailLength = clamp(
433
+ safeNumber("particleTrailLength", particleTrailLengthProp, 1),
434
+ 0.5,
435
+ 2.5
436
+ );
437
+
438
+ const ghostTrailStrength = clamp(
439
+ safeNumber("ghostTrailStrength", ghostTrailStrengthProp, 0.6),
440
+ 0,
441
+ 1
442
+ );
443
+ const ghostTrailCount = clamp(
444
+ safeNumber("ghostTrailCount", ghostTrailCountProp, 3),
445
+ 0,
446
+ 4
447
+ );
448
+ const ghostTrailSpacing = clamp(
449
+ safeNumber("ghostTrailSpacing", ghostTrailSpacingProp, 0.15),
450
+ 0.05,
451
+ 0.5
452
+ );
453
+
454
+ const uidRaw = React.useId();
455
+ const uid = React.useMemo(() => sanitizeId(uidRaw), [uidRaw]);
456
+
457
+ const containerRef = React.useRef<HTMLElement | null>(null);
458
+
459
+ // DOM refs for particles so the canvas can draw EXACTLY at their real position
460
+ const particleElsRef = React.useRef<(HTMLDivElement | null)[]>([]);
461
+ React.useEffect(() => {
462
+ particleElsRef.current.length = particleCount;
463
+ }, [particleCount]);
464
+
465
+ const [metrics, setMetrics] = React.useState(() => ({
466
+ width: 0,
467
+ height: 0,
468
+ cols: 12,
469
+ rows: 10,
470
+ }));
471
+
472
+ // Measure with ResizeObserver
473
+ React.useEffect(() => {
474
+ const el = containerRef.current;
475
+ if (!el) return;
476
+
477
+ const update = () => {
478
+ const rect = el.getBoundingClientRect();
479
+ const width = Math.max(1, Math.floor(rect.width));
480
+ const height = Math.max(1, Math.floor(rect.height));
481
+ const cols = Math.max(8, Math.floor(width / gridSize));
482
+ const rows = Math.max(8, Math.floor(height / gridSize));
483
+ setMetrics((prev) => {
484
+ if (
485
+ prev.width === width &&
486
+ prev.height === height &&
487
+ prev.cols === cols &&
488
+ prev.rows === rows
489
+ )
490
+ return prev;
491
+ return { width, height, cols, rows };
492
+ });
493
+ };
494
+
495
+ update();
496
+
497
+ const ro = new ResizeObserver(() => update());
498
+ ro.observe(el);
499
+ return () => ro.disconnect();
500
+ }, [gridSize]);
501
+
502
+ const intensityConfig = React.useMemo(() => {
503
+ const map: Record<
504
+ GridRunnerBackgroundIntensity,
505
+ { opacity: number; blurAmount: number }
506
+ > = {
507
+ subtle: { opacity: 0.4, blurAmount: 40 },
508
+ medium: { opacity: 0.6, blurAmount: 60 },
509
+ vibrant: { opacity: 0.8, blurAmount: 80 },
510
+ };
511
+ return map[backgroundIntensity] ?? map.medium;
512
+ }, [backgroundIntensity]);
513
+
514
+ const palette: Array<{ color: RGBA; glow: number }> = React.useMemo(
515
+ () => [
516
+ { color: { r: 255, g: 255, b: 255, a: 0.95 }, glow: 1.2 },
517
+ { color: { r: 147, g: 197, b: 253, a: 0.9 }, glow: 1.0 },
518
+ { color: { r: 167, g: 139, b: 250, a: 0.9 }, glow: 1.1 },
519
+ { color: { r: 251, g: 146, b: 168, a: 0.85 }, glow: 0.9 },
520
+ { color: { r: 134, g: 239, b: 172, a: 0.9 }, glow: 1.0 },
521
+ { color: { r: 253, g: 224, b: 71, a: 0.9 }, glow: 1.15 },
522
+ { color: { r: 252, g: 165, b: 165, a: 0.9 }, glow: 0.95 },
523
+ { color: { r: 165, g: 180, b: 252, a: 0.9 }, glow: 1.05 },
524
+ ],
525
+ []
526
+ );
527
+
528
+ const particles: Particle[] = React.useMemo(() => {
529
+ const list: Particle[] = [];
530
+
531
+ for (let i = 0; i < particleCount; i++) {
532
+ const colorData = palette[i % palette.length];
533
+ const localRand = mulberry32((seed + 1013 * (i + 1)) >>> 0);
534
+
535
+ // size with variance (stable per particle)
536
+ const sizeJitter = (localRand() - 0.5) * 2 * particleSizeVariance;
537
+ const size = clamp(particleSizeBase + sizeJitter, 4, 18);
538
+
539
+ // durations staggered but bounded
540
+ const duration = clamp(
541
+ 5 + (i % 4) * 1.5 + (localRand() - 0.5) * 0.8,
542
+ 3.5,
543
+ 12
544
+ );
545
+ const delay = i * 0.6;
546
+
547
+ const raw = generatePath(i, metrics.cols, metrics.rows, localRand);
548
+ const path = downsamplePath(raw, maxPathPoints);
549
+
550
+ list.push({
551
+ id: i,
552
+ path,
553
+ duration,
554
+ delay,
555
+ color: colorData.color,
556
+ size,
557
+ glowIntensity: colorData.glow,
558
+ });
559
+ }
560
+
561
+ return list;
562
+ }, [
563
+ seed,
564
+ particleCount,
565
+ palette,
566
+ metrics.cols,
567
+ metrics.rows,
568
+ maxPathPoints,
569
+ particleSizeBase,
570
+ particleSizeVariance,
571
+ ]);
572
+
573
+ const cssText = React.useMemo(() => {
574
+ const backgroundKeyframes = `
575
+ @keyframes grh-gradientShift {
576
+ 0%, 100% { transform: translate(0, 0) scale(1); opacity: 0.3; }
577
+ 33% { transform: translate(10%, 10%) scale(1.1); opacity: 0.4; }
578
+ 66% { transform: translate(-5%, 5%) scale(0.95); opacity: 0.35; }
579
+ }
580
+ @keyframes grh-gradientPulse {
581
+ 0%, 100% { transform: scale(1); opacity: 0.1; }
582
+ 50% { transform: scale(1.2); opacity: 0.15; }
583
+ }
584
+ @keyframes grh-noiseMove {
585
+ 0%, 100% { transform: translate(0, 0); }
586
+ 10% { transform: translate(-5%, -5%); }
587
+ 20% { transform: translate(-10%, 5%); }
588
+ 30% { transform: translate(5%, -10%); }
589
+ 40% { transform: translate(-5%, 10%); }
590
+ 50% { transform: translate(10%, -5%); }
591
+ 60% { transform: translate(5%, 10%); }
592
+ 70% { transform: translate(-10%, -10%); }
593
+ 80% { transform: translate(10%, 10%); }
594
+ 90% { transform: translate(-5%, 5%); }
595
+ }
596
+ @keyframes grh-float1 {
597
+ 0%, 100% { transform: translate(0, 0); }
598
+ 25% { transform: translate(30px, -40px); }
599
+ 50% { transform: translate(-20px, -60px); }
600
+ 75% { transform: translate(40px, -30px); }
601
+ }
602
+ @keyframes grh-float2 {
603
+ 0%, 100% { transform: translate(0, 0); }
604
+ 25% { transform: translate(-40px, 30px); }
605
+ 50% { transform: translate(20px, 50px); }
606
+ 75% { transform: translate(-30px, 20px); }
607
+ }
608
+ @keyframes grh-float3 {
609
+ 0%, 100% { transform: translate(0, 0); }
610
+ 25% { transform: translate(35px, 25px); }
611
+ 50% { transform: translate(-25px, 45px); }
612
+ 75% { transform: translate(45px, -20px); }
613
+ }
614
+ @keyframes grh-float4 {
615
+ 0%, 100% { transform: translate(0, 0); }
616
+ 33% { transform: translate(-30px, -25px); }
617
+ 66% { transform: translate(25px, -35px); }
618
+ }
619
+ @keyframes grh-beamSlide1 {
620
+ 0% { transform: translateX(0); opacity: 0; }
621
+ 20% { opacity: 1; }
622
+ 80% { opacity: 1; }
623
+ 100% { transform: translateX(100vw); opacity: 0; }
624
+ }
625
+ @keyframes grh-beamSlide2 {
626
+ 0% { transform: translateX(0); opacity: 0; }
627
+ 20% { opacity: 1; }
628
+ 80% { opacity: 1; }
629
+ 100% { transform: translateX(-100vw); opacity: 0; }
630
+ }
631
+ @keyframes grh-beamSlide3 {
632
+ 0% { transform: translateY(0); opacity: 0; }
633
+ 20% { opacity: 1; }
634
+ 80% { opacity: 1; }
635
+ 100% { transform: translateY(-100vh); opacity: 0; }
636
+ }
637
+ @keyframes grh-gridPulse {
638
+ 0%, 100% { opacity: 0.4; }
639
+ 50% { opacity: 0.5; }
640
+ }
641
+ `;
642
+
643
+ const particleKeyframes = particles
644
+ .map((p) => {
645
+ const pathLen = p.path.length;
646
+ const segments = Math.max(pathLen - 1, 1);
647
+ const nameP = `grh-${uid}-p-${p.id}`;
648
+ const nameT = `grh-${uid}-t-${p.id}`;
649
+
650
+ // NOTE: particle position in CSS is TOP-LEFT (we center for canvas via DOM rect)
651
+ const kfP = p.path
652
+ .map((pt, idx) => {
653
+ const pct = (idx / segments) * 100;
654
+ const isEndpoint = idx === 0 || idx === pathLen - 1;
655
+ const x = pt.x * gridSize - p.size / 2;
656
+ const y = pt.y * gridSize - p.size / 2;
657
+ const s = isEndpoint ? 0.6 : 1;
658
+ const o = isEndpoint ? 0.2 : 1;
659
+ return `${pct}% { transform: translate(${x}px, ${y}px) scale(${s}); opacity: ${o}; }`;
660
+ })
661
+ .join("\n");
662
+
663
+ const kfT = p.path
664
+ .map((pt, idx) => {
665
+ const pct = (idx / segments) * 100;
666
+ let rot = 0;
667
+ if (idx < pathLen - 1) {
668
+ const next = p.path[idx + 1];
669
+ const dx = next.x - pt.x;
670
+ const dy = next.y - pt.y;
671
+ rot = Math.atan2(dy, dx) * (180 / Math.PI);
672
+ }
673
+ const isEndpoint = idx === 0 || idx === pathLen - 1;
674
+ const x = pt.x * gridSize - p.size * 2.5 * particleTrailLength;
675
+ const y = pt.y * gridSize - p.size * 0.25;
676
+ return `${pct}% { transform: translate(${x}px, ${y}px) rotate(${rot}deg); opacity: ${
677
+ isEndpoint ? 0 : 0.7
678
+ }; }`;
679
+ })
680
+ .join("\n");
681
+
682
+ return `@keyframes ${nameP} {\n${kfP}\n}\n@keyframes ${nameT} {\n${kfT}\n}`;
683
+ })
684
+ .join("\n\n");
685
+
686
+ return `${backgroundKeyframes}\n${particleKeyframes}`;
687
+ }, [particles, uid, gridSize, particleTrailLength]);
688
+
689
+ const renderAction = (
690
+ action?: GridRunnerAction,
691
+ variant: "primary" | "soft" = "primary"
692
+ ) => {
693
+ if (!action?.label) return null;
694
+
695
+ const isLink = !!action.href;
696
+ const baseClassName = cn(
697
+ "rounded-2xl transition-all hover:scale-[1.02]",
698
+ action.className
699
+ );
700
+
701
+ if (variant === "primary") {
702
+ const primaryClassName = cn(
703
+ baseClassName,
704
+ "group relative overflow-hidden bg-white px-8 text-black font-semibold",
705
+ "shadow-[0_0_0_1px_rgba(255,255,255,0.1),0_1px_3px_rgba(0,0,0,0.3),0_8px_32px_rgba(0,0,0,0.4)]",
706
+ "hover:shadow-[0_0_0_1px_rgba(255,255,255,0.2),0_8px_48px_rgba(0,0,0,0.5)]"
707
+ );
708
+
709
+ if (isLink) {
710
+ return (
711
+ <Button size="lg" className={"se"}>
712
+ <a
713
+ href={action.href}
714
+ target={action.target}
715
+ rel={action.rel}
716
+ onClick={action.onClick}
717
+
718
+ >
719
+ <span className="relative z-10">{action.label}</span>
720
+ <span className="absolute inset-0 z-0 bg-gradient-to-r from-white via-blue-50 to-purple-50 opacity-0 transition-opacity duration-300 group-hover:opacity-100" />
721
+ </a>
722
+ </Button>
723
+ );
724
+ }
725
+
726
+ return (
727
+ <Button
728
+ size="lg"
729
+ onClick={action.onClick}
730
+ type="button"
731
+ className={""}
732
+ >
733
+ <span className="relative z-10">{action.label}</span>
734
+ <span className="absolute inset-0 z-0 bg-linear-to-r from-white via-blue-50 to-purple-50 opacity-0 transition-opacity duration-300 group-hover:opacity-100" />
735
+ </Button>
736
+ );
737
+ }
738
+
739
+ const softClassName = cn(
740
+ baseClassName,
741
+ "border-white/10 bg-white/5 font-semibold",
742
+ "shadow-[0_0_0_1px_rgba(255,255,255,0.05),0_1px_3px_rgba(0,0,0,0.2)]",
743
+ "backdrop-blur-xl hover:border-white/20 hover:bg-white/10",
744
+ "hover:shadow-[0_0_0_1px_rgba(255,255,255,0.1),0_8px_32px_rgba(0,0,0,0.3)]"
745
+ );
746
+
747
+ if (isLink) {
748
+ return (
749
+ <Button size="lg" variant="outline" className={""}>
750
+ <a
751
+ href={action.href}
752
+ target={action.target}
753
+ rel={action.rel}
754
+ onClick={action.onClick}
755
+ >
756
+ {action.label}
757
+ </a>
758
+ </Button>
759
+ );
760
+ }
761
+
762
+ return (
763
+ <Button
764
+ size="lg"
765
+ variant="soft"
766
+ onClick={action.onClick}
767
+ type="button"
768
+ className={""}
769
+ >
770
+ {action.label}
771
+ </Button>
772
+ );
773
+ };
774
+
775
+ return (
776
+ <section
777
+ ref={(node) => {
778
+ containerRef.current = node;
779
+ }}
780
+ className={cn("relative overflow-hidden bg-transparent", className)}
781
+ {...rest}
782
+ >
783
+ {/* One <style> for the whole instance */}
784
+ {!reducedMotion && <style dangerouslySetInnerHTML={{ __html: cssText }} />}
785
+
786
+ {/* Animated Gradient Mesh Background */}
787
+ {showMesh && !reducedMotion && (
788
+ <div className="pointer-events-none absolute inset-0 opacity-30">
789
+ <div className="absolute inset-0 bg-[radial-gradient(circle_at_20%_30%,rgba(99,102,241,0.15)_0%,transparent_50%)] animate-[grh-gradientShift_20s_ease-in-out_infinite]" />
790
+ <div className="absolute inset-0 bg-[radial-gradient(circle_at_80%_70%,rgba(168,85,247,0.12)_0%,transparent_50%)] animate-[grh-gradientShift_25s_ease-in-out_infinite_reverse]" />
791
+ <div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_50%,rgba(59,130,246,0.1)_0%,transparent_60%)] animate-[grh-gradientPulse_15s_ease-in-out_infinite]" />
792
+ </div>
793
+ )}
794
+
795
+ {/* Animated Noise Texture */}
796
+ {showNoise && !reducedMotion && (
797
+ <div
798
+ className="pointer-events-none absolute inset-0 opacity-20 mix-blend-soft-light"
799
+ style={{
800
+ backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 400 400' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E")`,
801
+ animation: "grh-noiseMove 8s steps(10) infinite",
802
+ }}
803
+ />
804
+ )}
805
+
806
+ {/* Floating Light Orbs */}
807
+ {showOrbs && !reducedMotion && (
808
+ <div className="pointer-events-none absolute inset-0">
809
+ <div
810
+ className="absolute left-[15%] top-[20%] h-64 w-64 rounded-full"
811
+ style={{
812
+ background:
813
+ "radial-gradient(circle, rgba(99,102,241,0.25) 0%, transparent 70%)",
814
+ filter: `blur(${intensityConfig.blurAmount}px)`,
815
+ opacity: intensityConfig.opacity,
816
+ animation: "grh-float1 20s ease-in-out infinite",
817
+ }}
818
+ />
819
+ <div
820
+ className="absolute right-[20%] top-[40%] h-80 w-80 rounded-full"
821
+ style={{
822
+ background:
823
+ "radial-gradient(circle, rgba(168,85,247,0.2) 0%, transparent 70%)",
824
+ filter: `blur(${intensityConfig.blurAmount}px)`,
825
+ opacity: intensityConfig.opacity,
826
+ animation: "grh-float2 25s ease-in-out infinite",
827
+ }}
828
+ />
829
+ <div
830
+ className="absolute bottom-[25%] left-[40%] h-72 w-72 rounded-full"
831
+ style={{
832
+ background:
833
+ "radial-gradient(circle, rgba(34,211,238,0.18) 0%, transparent 70%)",
834
+ filter: `blur(${intensityConfig.blurAmount}px)`,
835
+ opacity: intensityConfig.opacity,
836
+ animation: "grh-float3 22s ease-in-out infinite",
837
+ }}
838
+ />
839
+ <div
840
+ className="absolute right-[10%] bottom-[20%] h-56 w-56 rounded-full"
841
+ style={{
842
+ background:
843
+ "radial-gradient(circle, rgba(236,72,153,0.15) 0%, transparent 70%)",
844
+ filter: `blur(${intensityConfig.blurAmount}px)`,
845
+ opacity: intensityConfig.opacity * 0.8,
846
+ animation: "grh-float4 18s ease-in-out infinite",
847
+ }}
848
+ />
849
+ </div>
850
+ )}
851
+
852
+ {/* Animated Light Beams */}
853
+ {showBeams && !reducedMotion && (
854
+ <div className="pointer-events-none absolute inset-0 opacity-20">
855
+ <div
856
+ className="absolute left-0 top-0 h-full w-0.5 bg-gradient-to-b from-transparent via-blue-400/40 to-transparent"
857
+ style={{ animation: "grh-beamSlide1 8s linear infinite" }}
858
+ />
859
+ <div
860
+ className="absolute right-[20%] top-0 h-full w-px bg-gradient-to-b from-transparent via-purple-400/30 to-transparent"
861
+ style={{ animation: "grh-beamSlide2 10s linear infinite" }}
862
+ />
863
+ <div
864
+ className="absolute bottom-0 left-0 h-0.5 w-full bg-gradient-to-r from-transparent via-cyan-400/40 to-transparent"
865
+ style={{ animation: "grh-beamSlide3 12s linear infinite" }}
866
+ />
867
+ </div>
868
+ )}
869
+
870
+ {/* Grid */}
871
+ <div
872
+ aria-hidden
873
+ className="pointer-events-none absolute inset-0 opacity-40"
874
+ style={{
875
+ backgroundImage: `
876
+ linear-gradient(to right, rgba(255,255,255,0.06) 1px, transparent 1px),
877
+ linear-gradient(to bottom, rgba(255,255,255,0.06) 1px, transparent 1px)
878
+ `,
879
+ backgroundSize: `${gridSize}px ${gridSize}px`,
880
+ animation: !reducedMotion ? "grh-gridPulse 4s ease-in-out infinite" : undefined,
881
+ }}
882
+ />
883
+ <div
884
+ aria-hidden
885
+ className="pointer-events-none absolute inset-0 opacity-20"
886
+ style={{
887
+ backgroundImage: `
888
+ linear-gradient(to right, rgba(255,255,255,0.12) 1px, transparent 1px),
889
+ linear-gradient(to bottom, rgba(255,255,255,0.12) 1px, transparent 1px)
890
+ `,
891
+ backgroundSize: `${gridSize * 4}px ${gridSize * 4}px`,
892
+ }}
893
+ />
894
+
895
+ {/* Vignette */}
896
+ <div
897
+ aria-hidden
898
+ className="pointer-events-none absolute inset-0"
899
+ style={{
900
+ // background: `
901
+ // radial-gradient(45% 35% at 50% 40%, rgba(255,255,255,0.03) 0%, transparent 70%),
902
+ // radial-gradient(90% 90% at 50% 50%, transparent 30%, rgba(0,0,0,0.5) 100%),
903
+ // linear-gradient(180deg, transparent 0%, rgba(0,0,0,0.2) 100%)
904
+ // `,
905
+ }}
906
+ />
907
+
908
+ {/* Particles */}
909
+ {!reducedMotion && (
910
+ <div aria-hidden className="pointer-events-none absolute inset-0">
911
+ {particles.map((p) => (
912
+ <GridParticle
913
+ key={p.id}
914
+ uid={uid}
915
+ particle={p}
916
+ showGhostTrail={showGhostTrail}
917
+ ghostTrailStrength={ghostTrailStrength}
918
+ ghostTrailCount={ghostTrailCount}
919
+ ghostTrailSpacing={ghostTrailSpacing}
920
+ speed={particleSpeed}
921
+ glowStrength={particleGlowStrength}
922
+ ringStrength={particleRingStrength}
923
+ trailLength={particleTrailLength}
924
+ setMainEl={(id, el) => {
925
+ particleElsRef.current[id] = el;
926
+ }}
927
+ />
928
+ ))}
929
+ </div>
930
+ )}
931
+
932
+ {/* Connection Lines (DOM-locked: perfectly sticks to particles) */}
933
+ {!reducedMotion && showConnections && (
934
+ <div aria-hidden className="pointer-events-none absolute inset-0">
935
+ <ParticleConnections
936
+ particles={particles}
937
+ particleElsRef={particleElsRef}
938
+ connectionRange={connectionRange}
939
+ />
940
+ </div>
941
+ )}
942
+
943
+ {/* Content */}
944
+ <div
945
+ className={cn(
946
+ "relative mx-auto flex max-w-4xl flex-col items-center justify-center px-8 py-24 text-center"
947
+ )}
948
+ style={{ minHeight: `${minH}px` }}
949
+ >
950
+ {showBadge && (
951
+ <div className="group inline-flex items-center gap-2.5 rounded-full border border-white/10 bg-slate-900/50 px-4 py-1.5 text-xs font-medium text-muted-foreground backdrop-blur-xl transition-all hover:border-white/20 hover:bg-slate-900/70">
952
+ <span className="relative flex h-2 w-2">
953
+ <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400/60 opacity-75 duration-1000" />
954
+ <span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500/90 shadow-[0_0_12px_rgba(52,211,153,0.5)]" />
955
+ </span>
956
+ <span className="text-primary bg-clip-text ">
957
+ {badgeText}
958
+ </span>
959
+ </div>
960
+ )}
961
+
962
+ <h1 className="mt-8 bg-primary bg-clip-text text-balance text-5xl font-bold leading-[1.1] tracking-tight text-transparent sm:text-7xl">
963
+ {title}
964
+ </h1>
965
+
966
+ <div className="mt-6 max-w-2xl text-pretty text-base leading-relaxed text-stone-400 sm:text-lg">
967
+ {subtitle}
968
+ </div>
969
+
970
+ <div className="mt-10 flex flex-wrap items-center justify-center gap-4">
971
+ {renderAction(primaryAction, "primary")}
972
+ {renderAction(softAction, "soft")}
973
+ </div>
974
+ </div>
975
+ </section>
976
+ );
977
+ }
978
+
979
+ /* ------------------------------- Subcomponents ----------------------------- */
980
+
981
+ const GridParticle = React.memo(function GridParticle({
982
+ uid,
983
+ particle,
984
+ showGhostTrail,
985
+ ghostTrailStrength,
986
+ ghostTrailCount,
987
+ ghostTrailSpacing,
988
+ speed,
989
+ glowStrength,
990
+ ringStrength,
991
+ trailLength,
992
+ setMainEl,
993
+ }: {
994
+ uid: string;
995
+ particle: Particle;
996
+ showGhostTrail: boolean;
997
+ ghostTrailStrength: number;
998
+ ghostTrailCount: number;
999
+ ghostTrailSpacing: number;
1000
+ speed: number;
1001
+ glowStrength: number;
1002
+ ringStrength: number;
1003
+ trailLength: number;
1004
+ setMainEl?: (id: number, el: HTMLDivElement | null) => void;
1005
+ }) {
1006
+ const animName = `grh-${uid}-p-${particle.id}`;
1007
+ const trailName = `grh-${uid}-t-${particle.id}`;
1008
+ const baseColor = rgbaToCss(particle.color);
1009
+
1010
+ const effectiveDuration = Math.max(0.2, particle.duration / speed);
1011
+
1012
+ // Glow sizes scale with particle size + multiplier
1013
+ const glow1 = 12 * particle.glowIntensity * glowStrength;
1014
+ const glow2 = 24 * particle.glowIntensity * glowStrength;
1015
+ const glow3 = 36 * particle.glowIntensity * glowStrength;
1016
+
1017
+ const ringAlpha = 0.2 * ringStrength;
1018
+
1019
+ // Motion trail dims
1020
+ const trailW = particle.size * 5 * trailLength;
1021
+ const trailH = particle.size * 0.5;
1022
+
1023
+ const ghostOpacities =
1024
+ ghostTrailCount <= 0
1025
+ ? []
1026
+ : Array.from({ length: ghostTrailCount }, (_, i) => {
1027
+ const t = i / Math.max(ghostTrailCount - 1, 1);
1028
+ const o = (1 - t) * ghostTrailStrength;
1029
+ return clamp(o, 0, 1);
1030
+ });
1031
+
1032
+ return (
1033
+ <>
1034
+ {/* Ghost trail */}
1035
+ {showGhostTrail &&
1036
+ ghostOpacities.map((o, idx) => (
1037
+ <div
1038
+ key={`ghost-${particle.id}-${idx}`}
1039
+ className="absolute rounded-full blur-[1px]"
1040
+ style={{
1041
+ width: `${particle.size * 0.8}px`,
1042
+ height: `${particle.size * 0.8}px`,
1043
+ animationName: animName,
1044
+ animationDuration: `${effectiveDuration}s`,
1045
+ animationTimingFunction: "ease-in-out",
1046
+ animationDelay: `${particle.delay - idx * ghostTrailSpacing}s`,
1047
+ animationIterationCount: "infinite",
1048
+ opacity: o,
1049
+ backgroundColor: baseColor,
1050
+ willChange: "transform",
1051
+ }}
1052
+ />
1053
+ ))}
1054
+
1055
+ {/* Main particle (we attach DOM ref here for perfect connection sync) */}
1056
+ <div
1057
+ ref={(el) => setMainEl?.(particle.id, el)}
1058
+ className="absolute"
1059
+ style={{
1060
+ width: `${particle.size}px`,
1061
+ height: `${particle.size}px`,
1062
+ animationName: animName,
1063
+ animationDuration: `${effectiveDuration}s`,
1064
+ animationTimingFunction: "ease-in-out",
1065
+ animationDelay: `${particle.delay}s`,
1066
+ animationIterationCount: "infinite",
1067
+ willChange: "transform",
1068
+ }}
1069
+ >
1070
+ <div
1071
+ className="relative h-full w-full rounded-full"
1072
+ style={{
1073
+ backgroundColor: baseColor,
1074
+ boxShadow: `
1075
+ 0 0 0 1px rgba(255,255,255,${ringAlpha}),
1076
+ 0 0 ${glow1}px ${baseColor},
1077
+ 0 0 ${glow2}px ${baseColor},
1078
+ 0 0 ${glow3}px ${baseColor}
1079
+ `,
1080
+ }}
1081
+ >
1082
+ <div className="absolute inset-0 rounded-full bg-gradient-to-br from-white/40 to-transparent" />
1083
+ </div>
1084
+ </div>
1085
+
1086
+ {/* Motion trail */}
1087
+ <div
1088
+ className="absolute"
1089
+ style={{
1090
+ width: `${trailW}px`,
1091
+ height: `${trailH}px`,
1092
+ animationName: trailName,
1093
+ animationDuration: `${effectiveDuration}s`,
1094
+ animationTimingFunction: "ease-in-out",
1095
+ animationDelay: `${particle.delay}s`,
1096
+ animationIterationCount: "infinite",
1097
+ willChange: "transform",
1098
+ }}
1099
+ >
1100
+ <div
1101
+ className="h-full w-full rounded-full blur-[2px]"
1102
+ style={{
1103
+ background: `linear-gradient(90deg, ${baseColor} 0%, transparent 100%)`,
1104
+ }}
1105
+ />
1106
+ </div>
1107
+ </>
1108
+ );
1109
+ });
1110
+
1111
+ function ParticleConnections({
1112
+ particles,
1113
+ particleElsRef,
1114
+ connectionRange,
1115
+ }: {
1116
+ particles: Particle[];
1117
+ particleElsRef: React.RefObject<(HTMLDivElement | null)[]>;
1118
+ connectionRange: number;
1119
+ }) {
1120
+ const canvasRef = React.useRef<HTMLCanvasElement>(null);
1121
+ const rafRef = React.useRef<number | null>(null);
1122
+
1123
+ React.useEffect(() => {
1124
+ const canvas = canvasRef.current;
1125
+ if (!canvas) return;
1126
+
1127
+ const ctx = canvas.getContext("2d");
1128
+ if (!ctx) return;
1129
+
1130
+ const parent = canvas.parentElement;
1131
+ if (!parent) return;
1132
+
1133
+ let dpr = Math.max(1, Math.floor(window.devicePixelRatio || 1));
1134
+
1135
+ const resize = () => {
1136
+ dpr = Math.max(1, Math.floor(window.devicePixelRatio || 1));
1137
+ const w = parent.clientWidth;
1138
+ const h = parent.clientHeight;
1139
+
1140
+ canvas.width = Math.max(1, w * dpr);
1141
+ canvas.height = Math.max(1, h * dpr);
1142
+ canvas.style.width = `${w}px`;
1143
+ canvas.style.height = `${h}px`;
1144
+
1145
+ // draw in CSS pixels
1146
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
1147
+ };
1148
+
1149
+ resize();
1150
+ const ro = new ResizeObserver(resize);
1151
+ ro.observe(parent);
1152
+
1153
+ const animate = () => {
1154
+ const w = parent.clientWidth;
1155
+ const h = parent.clientHeight;
1156
+ ctx.clearRect(0, 0, w, h);
1157
+
1158
+ const parentRect = parent.getBoundingClientRect();
1159
+ const els = particleElsRef.current;
1160
+
1161
+ const positions: Array<{ x: number; y: number; c: RGBA }> = [];
1162
+
1163
+ // Read REAL DOM positions -> lines are perfectly locked to particles
1164
+ for (let i = 0; i < particles.length; i++) {
1165
+ const el = els[i];
1166
+ if (!el) continue;
1167
+
1168
+ const r = el.getBoundingClientRect();
1169
+ const x = r.left + r.width / 2 - parentRect.left;
1170
+ const y = r.top + r.height / 2 - parentRect.top;
1171
+
1172
+ if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
1173
+
1174
+ positions.push({ x, y, c: particles[i]!.color });
1175
+ }
1176
+
1177
+ // Range mapped to layout size so it feels consistent at different hero sizes
1178
+ const base = Math.min(w, h);
1179
+ const maxDistance = Math.max(48, base * (connectionRange / 40));
1180
+
1181
+ for (let i = 0; i < positions.length; i++) {
1182
+ for (let j = i + 1; j < positions.length; j++) {
1183
+ const p1 = positions[i]!;
1184
+ const p2 = positions[j]!;
1185
+
1186
+ const dx = p2.x - p1.x;
1187
+ const dy = p2.y - p1.y;
1188
+ const dist = Math.sqrt(dx * dx + dy * dy);
1189
+
1190
+ if (dist < maxDistance) {
1191
+ const falloff = 1 - dist / maxDistance;
1192
+ const alpha = falloff * falloff * 0.42;
1193
+
1194
+ const grad = ctx.createLinearGradient(p1.x, p1.y, p2.x, p2.y);
1195
+ grad.addColorStop(0, rgbaToCss(p1.c, alpha));
1196
+ grad.addColorStop(1, rgbaToCss(p2.c, alpha));
1197
+
1198
+ ctx.beginPath();
1199
+ ctx.strokeStyle = grad;
1200
+ ctx.lineWidth = 1.5;
1201
+ ctx.moveTo(p1.x, p1.y);
1202
+ ctx.lineTo(p2.x, p2.y);
1203
+ ctx.stroke();
1204
+ }
1205
+ }
1206
+ }
1207
+
1208
+ rafRef.current = requestAnimationFrame(animate);
1209
+ };
1210
+
1211
+ rafRef.current = requestAnimationFrame(animate);
1212
+
1213
+ return () => {
1214
+ ro.disconnect();
1215
+ if (rafRef.current) cancelAnimationFrame(rafRef.current);
1216
+ };
1217
+ }, [particles, particleElsRef, connectionRange]);
1218
+
1219
+ return (
1220
+ <canvas
1221
+ ref={canvasRef}
1222
+ className="absolute inset-0 h-full w-full"
1223
+ style={{ mixBlendMode: "screen" }}
1224
+ />
1225
+ );
1226
+ }