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.
- package/README.md +110 -19
- package/dist/commands/add/__tests__/add.test.js +18 -6
- package/dist/commands/add/__tests__/add.test.js.map +1 -1
- package/dist/commands/add/analysis.js +6 -1
- package/dist/commands/add/analysis.js.map +1 -1
- package/dist/commands/add/command.js +6 -0
- package/dist/commands/add/command.js.map +1 -1
- package/dist/commands/add/types.d.ts +1 -0
- package/dist/commands/add/ui.js +4 -0
- package/dist/commands/add/ui.js.map +1 -1
- package/dist/commands/add-composite.d.ts +2 -0
- package/dist/commands/add-composite.js +202 -0
- package/dist/commands/add-composite.js.map +1 -0
- package/dist/commands/add-section.js +6 -0
- package/dist/commands/add-section.js.map +1 -1
- package/dist/commands/add-wrapper.js +7 -1
- package/dist/commands/add-wrapper.js.map +1 -1
- package/dist/commands/doctor.js +9 -4
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/init/config.js +1 -0
- package/dist/commands/init/config.js.map +1 -1
- package/dist/commands/list-sections.js +2 -7
- package/dist/commands/list-sections.js.map +1 -1
- package/dist/commands/list-wrappers.js +2 -7
- package/dist/commands/list-wrappers.js.map +1 -1
- package/dist/commands/list.js +51 -8
- package/dist/commands/list.js.map +1 -1
- package/dist/commands/remove-section.js +9 -34
- package/dist/commands/remove-section.js.map +1 -1
- package/dist/commands/remove-wrapper.js +9 -34
- package/dist/commands/remove-wrapper.js.map +1 -1
- package/dist/commands/remove.js +71 -38
- package/dist/commands/remove.js.map +1 -1
- package/dist/commands/shared/add-collection.d.ts +2 -1
- package/dist/commands/shared/add-collection.js +10 -13
- package/dist/commands/shared/add-collection.js.map +1 -1
- package/dist/commands/shared/list-entries.d.ts +6 -0
- package/dist/commands/shared/list-entries.js +13 -0
- package/dist/commands/shared/list-entries.js.map +1 -0
- package/dist/commands/shared/name-utils.d.ts +1 -0
- package/dist/commands/shared/name-utils.js +14 -0
- package/dist/commands/shared/name-utils.js.map +1 -0
- package/dist/commands/shared/remove-entries.d.ts +16 -0
- package/dist/commands/shared/remove-entries.js +42 -0
- package/dist/commands/shared/remove-entries.js.map +1 -0
- package/dist/index.js +11 -8
- package/dist/index.js.map +1 -1
- package/dist/lib/config.d.ts +1 -0
- package/dist/lib/config.js +1 -0
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/install.js +14 -7
- package/dist/lib/install.js.map +1 -1
- package/dist/lib/lockfile.d.ts +6 -5
- package/dist/lib/lockfile.js +26 -7
- package/dist/lib/lockfile.js.map +1 -1
- package/dist/lib/paths.d.ts +1 -0
- package/dist/lib/paths.js +1 -0
- package/dist/lib/paths.js.map +1 -1
- package/dist/lib/registry.d.ts +3 -0
- package/dist/lib/registry.js +15 -0
- package/dist/lib/registry.js.map +1 -1
- package/package.json +12 -12
- package/registry/index.json +67 -128
- package/templates/components/ui/avatar.tsx +109 -0
- package/templates/components/ui/button.tsx +48 -44
- package/templates/components/ui/label.tsx +24 -0
- package/templates/composites/feature-collection-card.tsx +113 -0
- package/templates/composites/music-player-card.tsx +221 -0
- package/templates/composites/user-profile-card.tsx +145 -0
- package/templates/sections/modern-hero.tsx +1226 -0
- package/templates/wrappers/Interative-wrapper.tsx +555 -0
- package/LICENSE.md +0 -21
- package/templates/components/ui/checkbox.tsx +0 -33
- package/templates/components/ui/dialog.tsx +0 -92
- package/templates/components/ui/dropdown.tsx +0 -75
- package/templates/components/ui/select.tsx +0 -24
- package/templates/components/ui/switch.tsx +0 -27
- package/templates/components/ui/toast.tsx +0 -100
- package/templates/sections/cta.tsx +0 -22
- package/templates/sections/feature-grid.tsx +0 -62
- package/templates/sections/hero.tsx +0 -63
- package/templates/wrappers/border-wrapper.tsx +0 -34
- package/templates/wrappers/glow-wrapper.tsx +0 -31
- 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
|
+
}
|