apex-ui-kit 2.5.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. package/ReadMe.md +23 -11
  2. package/dist/ApexUI-Kit/templates/AccordionMarquee/AccordionMarquee.jsx +340 -161
  3. package/dist/ApexUI-Kit/templates/AnimatedTabs/AnimatedTabs.jsx +1 -1
  4. package/dist/ApexUI-Kit/templates/ArcGallery/ArcGallery.jsx +356 -0
  5. package/dist/ApexUI-Kit/templates/BounceCards/BounceCards.jsx +22 -2
  6. package/dist/ApexUI-Kit/templates/CircularCarousel/CircularCarousel.jsx +381 -0
  7. package/dist/ApexUI-Kit/templates/EdgeBlur/EdgeBlur.jsx +92 -0
  8. package/dist/ApexUI-Kit/templates/FlipClock/FlipClock.jsx +5 -1
  9. package/dist/ApexUI-Kit/templates/GooeyNav/GooeyNav.jsx +2 -2
  10. package/dist/ApexUI-Kit/templates/HorizonGlow/HorizonGlow.jsx +259 -0
  11. package/dist/ApexUI-Kit/templates/LoadingScreen/LoadingScreen.jsx +40 -48
  12. package/dist/ApexUI-Kit/templates/LoadingScreen/usePageProgress.js +135 -0
  13. package/dist/ApexUI-Kit/templates/MagneticButton/MagneticButton.jsx +69 -0
  14. package/dist/ApexUI-Kit/templates/Marquee3D/Marquee3D.jsx +68 -0
  15. package/dist/ApexUI-Kit/templates/NavDropDown/NavDropDown.jsx +123 -1
  16. package/dist/ApexUI-Kit/templates/ParticleText/ParticleText.jsx +328 -0
  17. package/dist/ApexUI-Kit/templates/RadialMenu/RadialMenu.jsx +103 -0
  18. package/dist/ApexUI-Kit/templates/ScrollStack/ScrollStack.jsx +88 -0
  19. package/dist/ApexUI-Kit/templates/SidebarNav/SidebarNav.jsx +22 -1
  20. package/dist/ApexUI-Kit/templates/SunriseBackground/SunriseBackground.jsx +290 -0
  21. package/dist/ApexUI-Kit/templates/WireframeKnot3D/WireframeKnot3D.jsx +112 -0
  22. package/dist/cli/index.js +11 -0
  23. package/package.json +2 -2
  24. package/dist/ApexUI-Kit/templates/AccordionMarquee/AccordionMarquee.css +0 -1
@@ -0,0 +1,259 @@
1
+ import React, { useEffect, useRef } from "react";
2
+ import { Color, Mesh, OrthographicCamera, PlaneGeometry, Scene, ShaderMaterial, Vector2, WebGLRenderer } from "three";
3
+
4
+ /*
5
+ * Horizon Glow: a soft, wide light rising from below the bottom edge, fading
6
+ * from a pale core through an accent band and a mid tone into a deep sky,
7
+ * drawn in one WebGL shader. A thin luminous arc marks the horizon, faint
8
+ * volumetric rays climb out of it and specks of dust drift up through the
9
+ * light. It rises in on mount, breathes slowly and leans toward the pointer;
10
+ * a filmic tone curve and dithering keep the long gradient silky.
11
+ */
12
+
13
+ const PRESETS = {
14
+ lilac: { sky: "#0d1438", mid: "#6f4c86", accent: "#ff9fd2", core: "#fff1f8" },
15
+ ocean: { sky: "#020f1c", mid: "#195a78", accent: "#58e1ff", core: "#e9fbff" },
16
+ ember: { sky: "#100606", mid: "#7a2f1e", accent: "#ff9b54", core: "#fff0dc" },
17
+ mint: { sky: "#04131a", mid: "#22625a", accent: "#6dffc4", core: "#ecfff6" },
18
+ mono: { sky: "#08080a", mid: "#45454d", accent: "#c9c9d6", core: "#ffffff" },
19
+ };
20
+
21
+ const VERT = `void main(){ gl_Position = vec4(position.xy, 0.0, 1.0); }`;
22
+
23
+ const FRAG = `
24
+ precision highp float;
25
+ uniform vec2 uRes; uniform float uTime; uniform float uRise; uniform vec2 uMouse;
26
+ uniform vec3 uSky; uniform vec3 uMid; uniform vec3 uAccent; uniform vec3 uCore;
27
+ uniform float uSpread; uniform float uIntensity; uniform float uShimmer; uniform float uBreath; uniform float uGrain;
28
+ uniform float uArc; uniform float uRays; uniform float uDust;
29
+
30
+ float h2(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
31
+ float n2(vec2 p){
32
+ vec2 i = floor(p); vec2 f = fract(p); vec2 u = f * f * (3.0 - 2.0 * f);
33
+ return mix(mix(h2(i), h2(i + vec2(1, 0)), u.x), mix(h2(i + vec2(0, 1)), h2(i + vec2(1, 1)), u.x), u.y);
34
+ }
35
+ float fbm(vec2 p){ float v = 0.0; float a = 0.5; for (int i = 0; i < 4; i++) { v += a * n2(p); p *= 2.02; a *= 0.5; } return v; }
36
+
37
+ // Drifting dust: one speck per cell, rising slowly and twinkling.
38
+ float dust(vec2 p, float scale, float speed, float seed){
39
+ vec2 q = p * scale + vec2(seed, -uTime * speed);
40
+ vec2 id = floor(q); vec2 f = fract(q) - 0.5;
41
+ vec2 o = vec2(h2(id + seed), h2(id + seed + 3.1)) - 0.5;
42
+ float r = length(f - o * 0.7);
43
+ float tw = 0.5 + 0.5 * sin(uTime * (1.5 + h2(id) * 2.5) + h2(id + 9.0) * 6.28);
44
+ return smoothstep(0.06, 0.0, r) * step(0.55, h2(id + seed + 7.7)) * tw;
45
+ }
46
+
47
+ void main(){
48
+ vec2 uv = gl_FragCoord.xy / uRes;
49
+ float asp = uRes.x / uRes.y;
50
+ // Work in height units; wide screens get a wider glow, phones a taller one.
51
+ vec2 p = vec2((uv.x - 0.5) * asp, uv.y);
52
+ float width = max(asp, 0.8) * 0.82;
53
+
54
+ float rise = uRise;
55
+ float breath = 1.0 + uBreath * 0.035 * sin(uTime * 0.55) + uBreath * 0.015 * sin(uTime * 1.3 + 1.7);
56
+ vec2 c = vec2(uMouse.x * 0.06 * asp, mix(-1.1, -0.06, rise) + uMouse.y * 0.025);
57
+
58
+ // Elliptical distance from the light source below the edge.
59
+ vec2 e = (p - c) / vec2(width, 1.0);
60
+ float d0 = length(e);
61
+ // A slow shimmer that bends the falloff a little.
62
+ float s = fbm(vec2(p.x * 1.4, p.y * 1.1 - uTime * 0.035) + uTime * 0.02) - 0.5;
63
+ float d = d0 + s * 0.12 * uShimmer * smoothstep(1.4, 0.3, d0);
64
+
65
+ float reach = uSpread * breath * mix(0.35, 1.0, rise);
66
+ float t = clamp(d / reach, 0.0, 1.0);
67
+
68
+ // Core -> accent band -> mid tone -> sky.
69
+ vec3 col = mix(uCore, uMid, smoothstep(0.0, 0.62, t));
70
+ col = mix(col, uSky, smoothstep(0.38, 1.0, t));
71
+ float band = exp(-pow((t - 0.3) / 0.13, 2.0));
72
+ col = mix(col, col * 0.55 + uAccent * 0.55, band * 0.45);
73
+ col += uCore * exp(-d * d * 9.0 / (reach * reach)) * 0.25 * uIntensity;
74
+
75
+ // Volumetric rays climbing out of the source.
76
+ float ang = atan(p.x - c.x, (p.y - c.y) * 1.6);
77
+ float beams = n2(vec2(ang * 8.0, uTime * 0.07)) * 0.65 + n2(vec2(ang * 21.0, -uTime * 0.05)) * 0.35;
78
+ col += mix(uAccent, uCore, 0.5) * smoothstep(0.5, 1.0, beams) * exp(-t * 2.4) * smoothstep(0.02, 0.2, t) * 0.28 * uRays;
79
+
80
+ // Horizon arc: a thin bright rim where the light meets the edge of the world.
81
+ float pr = 0.9;
82
+ vec2 pc = vec2(c.x, c.y + 0.24 - pr);
83
+ float rd = length((p - pc) / vec2(max(asp, 0.8) * 1.05, 1.0)) - pr;
84
+ float fade = exp(-abs(p.x - c.x) / (width * 0.9));
85
+ col += mix(uAccent, uCore, 0.7) * (exp(-abs(rd) * 140.0) * 0.9 + exp(-max(rd, 0.0) * 14.0) * 0.18) * fade * uArc * rise;
86
+ col *= mix(1.0, 0.82, smoothstep(0.0, -0.08, rd) * uArc);
87
+
88
+ // Dust lit by the glow.
89
+ float lit = exp(-t * 2.2);
90
+ col += uCore * (dust(p, 9.0, 0.05, 1.0) + dust(p, 16.0, 0.09, 7.0) * 0.7) * lit * 0.55 * uDust * rise;
91
+
92
+ col = mix(uSky, col, clamp(uIntensity * (0.25 + 0.75 * rise), 0.0, 1.4));
93
+ // Darker corners at the top keep text readable.
94
+ col *= 1.0 - 0.12 * smoothstep(0.45, 1.1, uv.y) * smoothstep(0.1, 0.9, abs(uv.x - 0.5) * 2.0);
95
+ // Filmic shoulder so the core rolls off instead of clipping.
96
+ col = col / (1.0 + max(col - 0.85, 0.0));
97
+
98
+ col += (h2(gl_FragCoord.xy + fract(uTime * 7.0) * 91.0) - 0.5) * (1.0 + 7.0 * uGrain) / 255.0;
99
+ gl_FragColor = vec4(col, 1.0);
100
+ }`;
101
+
102
+ const HorizonGlow = ({
103
+ preset = "lilac",
104
+ skyColor,
105
+ midColor,
106
+ accentColor,
107
+ coreColor,
108
+ spread = 1.3,
109
+ intensity = 1,
110
+ duration = 2.4,
111
+ autoRise = true,
112
+ breathe = 1,
113
+ shimmer = 1,
114
+ arc = 0.7,
115
+ rays = 1,
116
+ dust = 1,
117
+ grain = 0.4,
118
+ speed = 1,
119
+ interactive = true,
120
+ replay = 0,
121
+ className = "",
122
+ children,
123
+ }) => {
124
+ const wrap = useRef(null);
125
+ const p = PRESETS[preset] ?? PRESETS.lilac;
126
+ const live = useRef({});
127
+ live.current = { sky: skyColor ?? p.sky, mid: midColor ?? p.mid, accent: accentColor ?? p.accent, core: coreColor ?? p.core, spread, intensity, duration, autoRise, breathe, shimmer, arc, rays, dust, grain, speed, interactive };
128
+ const rise = useRef({ start: 0 });
129
+
130
+ useEffect(() => {
131
+ rise.current.start = performance.now();
132
+ }, [replay, autoRise]);
133
+
134
+ useEffect(() => {
135
+ const el = wrap.current;
136
+ const renderer = new WebGLRenderer({ antialias: false, alpha: false, powerPreference: "high-performance" });
137
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5));
138
+ Object.assign(renderer.domElement.style, { position: "absolute", inset: "0", width: "100%", height: "100%", display: "block" });
139
+ el.prepend(renderer.domElement);
140
+ const scene = new Scene();
141
+ const camera = new OrthographicCamera(-1, 1, 1, -1, 0, 1);
142
+ const u = {
143
+ uRes: { value: new Vector2(1, 1) },
144
+ uTime: { value: 0 },
145
+ uRise: { value: 0 },
146
+ uMouse: { value: new Vector2() },
147
+ uSky: { value: new Color() },
148
+ uMid: { value: new Color() },
149
+ uAccent: { value: new Color() },
150
+ uCore: { value: new Color() },
151
+ uSpread: { value: 1 },
152
+ uIntensity: { value: 1 },
153
+ uShimmer: { value: 1 },
154
+ uBreath: { value: 1 },
155
+ uGrain: { value: 0.4 },
156
+ uArc: { value: 0.7 },
157
+ uRays: { value: 1 },
158
+ uDust: { value: 1 },
159
+ };
160
+ const material = new ShaderMaterial({ vertexShader: VERT, fragmentShader: FRAG, uniforms: u, depthTest: false, depthWrite: false });
161
+ const geometry = new PlaneGeometry(2, 2);
162
+ scene.add(new Mesh(geometry, material));
163
+
164
+ const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
165
+ const mouse = { x: 0, y: 0, tx: 0, ty: 0 };
166
+ let raf = 0;
167
+ let last = 0;
168
+ let time = 0;
169
+ let visible = true;
170
+
171
+ const draw = (now) => {
172
+ const o = live.current;
173
+ const dt = last ? Math.min(0.05, (now - last) / 1000) : 1 / 60;
174
+ last = now;
175
+ time += dt * o.speed;
176
+ const t = o.autoRise && !reduced ? Math.min(1, (now - rise.current.start) / (Math.max(0.1, o.duration) * 1000)) : 1;
177
+ // Ease out with a touch of overshoot-free settle.
178
+ u.uRise.value = 1 - Math.pow(1 - t, 4);
179
+ const k = 1 - Math.exp(-dt / 0.6);
180
+ mouse.x += ((o.interactive ? mouse.tx : 0) - mouse.x) * k;
181
+ mouse.y += ((o.interactive ? mouse.ty : 0) - mouse.y) * k;
182
+ u.uMouse.value.set(mouse.x, mouse.y);
183
+ u.uTime.value = time;
184
+ u.uSky.value.set(o.sky);
185
+ u.uMid.value.set(o.mid);
186
+ u.uAccent.value.set(o.accent);
187
+ u.uCore.value.set(o.core);
188
+ u.uSpread.value = o.spread;
189
+ u.uIntensity.value = o.intensity;
190
+ u.uShimmer.value = reduced ? 0 : o.shimmer;
191
+ u.uBreath.value = reduced ? 0 : o.breathe;
192
+ u.uGrain.value = o.grain;
193
+ u.uArc.value = o.arc;
194
+ u.uRays.value = reduced ? o.rays * 0.6 : o.rays;
195
+ u.uDust.value = reduced ? 0 : o.dust;
196
+ renderer.render(scene, camera);
197
+ };
198
+ const loop = (now) => {
199
+ raf = 0;
200
+ draw(now);
201
+ if (visible && !reduced) raf = requestAnimationFrame(loop);
202
+ };
203
+ const start = () => {
204
+ if (!raf && visible && !reduced) {
205
+ last = 0;
206
+ raf = requestAnimationFrame(loop);
207
+ }
208
+ };
209
+ const fit = () => {
210
+ const { width, height } = el.getBoundingClientRect();
211
+ if (!width || !height) return;
212
+ renderer.setSize(width, height, false);
213
+ renderer.getDrawingBufferSize(u.uRes.value);
214
+ if (!raf) draw(performance.now());
215
+ };
216
+
217
+ const onMove = (e) => {
218
+ const r = el.getBoundingClientRect();
219
+ mouse.tx = ((e.clientX - r.left) / r.width) * 2 - 1;
220
+ mouse.ty = -(((e.clientY - r.top) / r.height) * 2 - 1);
221
+ };
222
+ const onLeave = () => {
223
+ mouse.tx = 0;
224
+ mouse.ty = 0;
225
+ };
226
+ el.addEventListener("pointermove", onMove);
227
+ el.addEventListener("pointerleave", onLeave);
228
+ const ro = new ResizeObserver(fit);
229
+ ro.observe(el);
230
+ const io = new IntersectionObserver(([entry]) => {
231
+ visible = entry.isIntersecting;
232
+ if (visible) start();
233
+ });
234
+ io.observe(el);
235
+ fit();
236
+ start();
237
+
238
+ return () => {
239
+ cancelAnimationFrame(raf);
240
+ ro.disconnect();
241
+ io.disconnect();
242
+ el.removeEventListener("pointermove", onMove);
243
+ el.removeEventListener("pointerleave", onLeave);
244
+ geometry.dispose();
245
+ material.dispose();
246
+ renderer.dispose();
247
+ renderer.forceContextLoss();
248
+ renderer.domElement.remove();
249
+ };
250
+ }, []);
251
+
252
+ return (
253
+ <div ref={wrap} className={`relative h-full w-full overflow-hidden bg-[#0d1438] ${className}`}>
254
+ {children && <div className="relative z-10 h-full w-full">{children}</div>}
255
+ </div>
256
+ );
257
+ };
258
+
259
+ export default HorizonGlow;
@@ -1,5 +1,6 @@
1
1
  import React, { useEffect, useRef, useState } from "react";
2
2
  import { AnimatePresence, motion, useAnimationControls, useReducedMotion } from "framer-motion";
3
+ import { usePageProgress } from "./usePageProgress.js";
3
4
 
4
5
  /*
5
6
  * Loading Screen: five ways to wait.
@@ -9,50 +10,27 @@ import { AnimatePresence, motion, useAnimationControls, useReducedMotion } from
9
10
  * catch – a mini game: tap the orb before its ring runs out and build a combo
10
11
  * runner – a mini game: jump the neon pillars (Space / ↑ / tap) while things load
11
12
  * racer – a mini game: steer through traffic (← → / tap a side) while things load
12
- * Pass `progress` (0–100) yourself, or leave it out and it runs on its own.
13
+ * By default it shows the real page load: the bar and the count follow the
14
+ * images, scripts, styles and fonts the page is actually loading, and it
15
+ * hides itself once everything has loaded (autoHide). Pass `progress`
16
+ * (0 to 100) to drive it yourself instead.
13
17
  */
14
18
 
15
- const MESSAGES = ["Warming up the engines", "Fetching your components", "Polishing pixels", "Almost there"];
19
+ const MESSAGES = ["Starting up", "Loading page", "Loading content", "Almost ready"];
16
20
  const messageFor = (v) => MESSAGES[Math.min(MESSAGES.length - 1, Math.floor(v / (100 / MESSAGES.length)))];
17
21
 
18
- // Simulated progress that eases toward 100 over `duration` ms, then calls onComplete.
19
- function useProgress(progress, duration, loop, onComplete) {
20
- const [auto, setAuto] = useState(0);
22
+ // Real progress: your own `progress` when given, otherwise the page's real load.
23
+ function useProgress(progress, onComplete) {
24
+ const controlled = typeof progress === "number";
25
+ const page = usePageProgress(!controlled);
26
+ const value = Math.round(Math.max(0, Math.min(100, controlled ? progress : page.progress)));
21
27
  const doneRef = useRef(onComplete);
22
28
  doneRef.current = onComplete;
23
- const controlled = typeof progress === "number";
24
-
25
- useEffect(() => {
26
- if (controlled) return undefined;
27
- let raf = 0;
28
- let start = performance.now();
29
- let fired = false;
30
- const tick = (now) => {
31
- const t = Math.min(1, Math.max(0, (now - start) / duration));
32
- // Fast start, slow middle, quick finish — the way real loads feel.
33
- const eased = t < 0.85 ? 0.92 * (1 - Math.pow(1 - t / 0.85, 2.2)) : 0.92 + 0.08 * ((t - 0.85) / 0.15);
34
- setAuto(Math.round(eased * 100));
35
- if (t >= 1) {
36
- if (!fired) {
37
- fired = true;
38
- doneRef.current?.();
39
- }
40
- if (loop) {
41
- start = now + 900;
42
- fired = false;
43
- } else return;
44
- }
45
- raf = requestAnimationFrame(tick);
46
- };
47
- raf = requestAnimationFrame(tick);
48
- return () => cancelAnimationFrame(raf);
49
- }, [controlled, duration, loop]);
50
-
29
+ const complete = value >= 100 && (controlled || page.done);
51
30
  useEffect(() => {
52
- if (controlled && progress >= 100) doneRef.current?.();
53
- }, [controlled, progress]);
54
-
55
- return Math.round(Math.max(0, Math.min(100, controlled ? progress : auto)));
31
+ if (complete) doneRef.current?.();
32
+ }, [complete]);
33
+ return { value, complete };
56
34
  }
57
35
 
58
36
  function Bar({ value, color, track }) {
@@ -108,8 +86,8 @@ function Orbit({ value, color, moving }) {
108
86
  );
109
87
  }
110
88
 
111
- function ProgressView({ value, color, track, moving }) {
112
- const message = messageFor(value);
89
+ function ProgressView({ value, color, track, moving, statusText }) {
90
+ const message = statusText ?? `${messageFor(value)}…`;
113
91
  return (
114
92
  <div className="w-[min(22rem,80vw)]">
115
93
  <p className="font-mono text-[clamp(3.5rem,12vw,5.5rem)] font-bold leading-none tabular-nums tracking-tight">
@@ -122,7 +100,7 @@ function ProgressView({ value, color, track, moving }) {
122
100
  <div className="mt-3 h-5 overflow-hidden text-sm opacity-70">
123
101
  <AnimatePresence mode="wait" initial={false}>
124
102
  <motion.p key={message} initial={{ y: 14, opacity: 0 }} animate={{ y: 0, opacity: 1 }} exit={{ y: -14, opacity: 0 }} transition={{ duration: moving ? 0.25 : 0 }}>
125
- {message}…
103
+ {message}
126
104
  </motion.p>
127
105
  </AnimatePresence>
128
106
  </div>
@@ -760,30 +738,39 @@ function RacerGame({ value, color, track, dark, statusText }) {
760
738
  const LoadingScreen = ({
761
739
  variant = "orbit",
762
740
  progress,
763
- duration = 6000,
764
- loop = true,
765
741
  accentColor = "#a3e635",
766
742
  theme = "dark",
767
743
  title = "Loading",
768
744
  fullScreen = false,
745
+ autoHide = true,
769
746
  onComplete,
770
747
  statusText,
771
748
  className = "",
772
749
  }) => {
773
750
  const moving = !useReducedMotion();
774
- const value = useProgress(progress, duration, loop, onComplete);
751
+ const { value, complete } = useProgress(progress, onComplete);
752
+ const [gone, setGone] = useState(false);
775
753
  const dark = theme !== "light";
776
754
  const track = dark ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.08)";
777
- const shared = { value, color: accentColor, track, moving, dark, statusText };
755
+ // Page-style status ("Loading page…", "Almost ready…"), or your own text.
756
+ const status = statusText ?? (complete ? "Page ready" : undefined);
757
+ const shared = { value, color: accentColor, track, moving, dark, statusText: status };
778
758
  const game = variant === "catch" || variant === "runner" || variant === "racer";
759
+ const hiding = autoHide && complete;
779
760
 
761
+ if (gone) return null;
780
762
  return (
781
- <div
763
+ <motion.div
782
764
  data-loading-screen=""
783
765
  role="status"
784
766
  aria-live="polite"
767
+ aria-busy={!complete}
785
768
  aria-label={`${title} ${value}%`}
786
- className={`flex flex-col items-center justify-center gap-6 p-6 sm:p-8 ${fullScreen ? "fixed inset-0 z-[100]" : "relative h-full w-full"} ${dark ? "bg-neutral-950 text-white" : "bg-white text-neutral-900"} ${className}`}
769
+ initial={false}
770
+ animate={{ opacity: hiding ? 0 : 1 }}
771
+ transition={{ duration: 0.45, delay: hiding ? 0.35 : 0 }}
772
+ onAnimationComplete={() => hiding && setGone(true)}
773
+ className={`flex flex-col items-center justify-center gap-6 p-6 sm:p-8 ${fullScreen ? "fixed inset-0 z-[100]" : "relative h-full w-full"} ${hiding ? "pointer-events-none" : ""} ${dark ? "bg-neutral-950 text-white" : "bg-white text-neutral-900"} ${className}`}
787
774
  style={dark ? { backgroundImage: `radial-gradient(60% 50% at 50% 40%, ${accentColor}14, transparent 70%)` } : undefined}
788
775
  >
789
776
  {game && (
@@ -798,8 +785,13 @@ const LoadingScreen = ({
798
785
  {variant === "catch" && <CatchGame {...shared} />}
799
786
  {variant === "runner" && <RunnerGame {...shared} />}
800
787
  {variant === "racer" && <RacerGame {...shared} />}
801
- {!game && variant !== "progress" && <p className="text-[0.7rem] font-semibold uppercase tracking-[0.3em] opacity-50">{title}</p>}
802
- </div>
788
+ {!game && variant !== "progress" && (
789
+ <p className="text-center">
790
+ <span className="block text-[0.7rem] font-semibold uppercase tracking-[0.3em] opacity-50">{title}</span>
791
+ <span className="mt-1.5 block text-[0.75rem] opacity-60">{status ?? `${messageFor(value)}…`}</span>
792
+ </p>
793
+ )}
794
+ </motion.div>
803
795
  );
804
796
  };
805
797
 
@@ -0,0 +1,135 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ /*
4
+ * Real page-load progress, used by <LoadingScreen /> when you don't pass
5
+ * your own `progress`. It counts what the page is actually loading:
6
+ *
7
+ * - every <img> still loading (including ones added later),
8
+ * - scripts, stylesheets, fonts and fetches as the browser finishes them,
9
+ * - web fonts (document.fonts.ready) and the window "load" event.
10
+ *
11
+ * `progress` is finished / total (0 to 100) and only reaches 100 once the
12
+ * page has fully loaded and nothing new has started for a moment, so the
13
+ * bar and the count always match what really happened.
14
+ */
15
+
16
+ const SETTLE_MS = 350;
17
+
18
+ export function usePageProgress(enabled = true) {
19
+ const [state, setState] = useState({ loaded: 0, total: 0, done: false });
20
+
21
+ useEffect(() => {
22
+ if (!enabled || typeof window === "undefined") return undefined;
23
+ let loaded = 0;
24
+ let total = 0;
25
+ let waitingForPage = document.readyState !== "complete";
26
+ let settle = 0;
27
+ let finished = false;
28
+ const seen = new WeakSet();
29
+ const cleanups = [];
30
+
31
+ const update = () => {
32
+ if (finished) return;
33
+ const idle = !waitingForPage && loaded >= total;
34
+ setState({ loaded, total, done: false });
35
+ clearTimeout(settle);
36
+ if (idle) {
37
+ // Wait a moment: a page often starts a few more requests right after load.
38
+ settle = setTimeout(() => {
39
+ if (!waitingForPage && loaded >= total) {
40
+ finished = true;
41
+ setState({ loaded, total: Math.max(total, loaded), done: true });
42
+ }
43
+ }, SETTLE_MS);
44
+ }
45
+ };
46
+ const add = (n = 1) => {
47
+ total += n;
48
+ update();
49
+ };
50
+ const finish = () => {
51
+ loaded += 1;
52
+ update();
53
+ };
54
+
55
+ // Only what is still loading (or starts) after the loader appears is counted,
56
+ // so the bar runs from 0 to 100 for this load.
57
+ // Images: pending ones count until they load or fail.
58
+ const trackImage = (img) => {
59
+ if (seen.has(img)) return;
60
+ seen.add(img);
61
+ if (img.complete) return;
62
+ add();
63
+ const end = () => {
64
+ img.removeEventListener("load", end);
65
+ img.removeEventListener("error", end);
66
+ finish();
67
+ };
68
+ img.addEventListener("load", end);
69
+ img.addEventListener("error", end);
70
+ cleanups.push(() => {
71
+ img.removeEventListener("load", end);
72
+ img.removeEventListener("error", end);
73
+ });
74
+ };
75
+ Array.from(document.images).forEach(trackImage);
76
+ const mo = new MutationObserver((list) => {
77
+ for (const m of list) {
78
+ m.addedNodes.forEach((n) => {
79
+ if (n.nodeType !== 1) return;
80
+ if (n.tagName === "IMG") trackImage(n);
81
+ else n.querySelectorAll?.("img").forEach(trackImage);
82
+ });
83
+ }
84
+ });
85
+ mo.observe(document.documentElement, { childList: true, subtree: true });
86
+
87
+ // Other files (scripts, CSS, fonts, fetch) are counted as the browser finishes them.
88
+ let po;
89
+ if (typeof PerformanceObserver !== "undefined") {
90
+ po = new PerformanceObserver((list) => {
91
+ const fresh = list.getEntries().filter((e) => e.initiatorType !== "img");
92
+ if (!fresh.length) return;
93
+ total += fresh.length;
94
+ loaded += fresh.length;
95
+ update();
96
+ });
97
+ try {
98
+ po.observe({ type: "resource", buffered: false });
99
+ } catch {
100
+ po = undefined;
101
+ }
102
+ }
103
+
104
+ // The page itself.
105
+ if (waitingForPage) {
106
+ total += 1;
107
+ const onLoad = () => {
108
+ waitingForPage = false;
109
+ finish();
110
+ };
111
+ window.addEventListener("load", onLoad, { once: true });
112
+ cleanups.push(() => window.removeEventListener("load", onLoad));
113
+ }
114
+ // Web fonts.
115
+ if (document.fonts && document.fonts.status !== "loaded") {
116
+ total += 1;
117
+ document.fonts.ready.then(finish, finish);
118
+ }
119
+ update();
120
+
121
+ return () => {
122
+ finished = true;
123
+ clearTimeout(settle);
124
+ mo.disconnect();
125
+ po?.disconnect();
126
+ cleanups.forEach((fn) => fn());
127
+ };
128
+ }, [enabled]);
129
+
130
+ const total = Math.max(1, state.total);
131
+ const progress = state.done ? 100 : Math.min(99, Math.round((state.loaded / total) * 100));
132
+ return { progress, loaded: state.loaded, total: state.total, done: state.done };
133
+ }
134
+
135
+ export default usePageProgress;
@@ -0,0 +1,69 @@
1
+ import React, { useRef } from "react";
2
+ import { motion, useMotionValue, useReducedMotion, useSpring, useTransform } from "framer-motion";
3
+
4
+ /*
5
+ * Magnetic Button: when the pointer comes near, the button leans toward it
6
+ * like a magnet, and its label follows a little further for a layered
7
+ * parallax. A soft glow tracks the pointer across the surface. Springs back
8
+ * when the pointer leaves. Renders a <button>, or a link with `as="a"`.
9
+ */
10
+
11
+ const MagneticButton = ({ as = "button", children = "Get started", strength = 0.35, radius = 1.6, accentColor = "#a3e635", theme = "dark", className = "", ...rest }) => {
12
+ const reduced = useReducedMotion();
13
+ const ref = useRef(null);
14
+ const mx = useMotionValue(0);
15
+ const my = useMotionValue(0);
16
+ const gx = useMotionValue(50);
17
+ const gy = useMotionValue(50);
18
+ const spring = { stiffness: 220, damping: 18, mass: 0.6 };
19
+ const x = useSpring(mx, spring);
20
+ const y = useSpring(my, spring);
21
+ // The label travels a bit further than the button: layered parallax.
22
+ const lx = useTransform(x, (v) => v * 0.45);
23
+ const ly = useTransform(y, (v) => v * 0.45);
24
+ const glow = useTransform([gx, gy], ([a, b]) => `radial-gradient(120px circle at ${a}% ${b}%, ${accentColor}55, transparent 70%)`);
25
+ const Tag = motion[as] ?? motion.button;
26
+ const dark = theme !== "light";
27
+
28
+ const onMove = (e) => {
29
+ const el = ref.current;
30
+ if (!el || reduced) return;
31
+ const r = el.getBoundingClientRect();
32
+ const cx = r.left + r.width / 2;
33
+ const cy = r.top + r.height / 2;
34
+ const dx = e.clientX - cx;
35
+ const dy = e.clientY - cy;
36
+ // Only pull while the pointer is within `radius` × the button size.
37
+ const reach = Math.max(r.width, r.height) * radius;
38
+ const pull = Math.max(0, 1 - Math.hypot(dx, dy) / reach);
39
+ mx.set(dx * strength * pull * 1.6);
40
+ my.set(dy * strength * pull * 1.6);
41
+ gx.set(((e.clientX - r.left) / r.width) * 100);
42
+ gy.set(((e.clientY - r.top) / r.height) * 100);
43
+ };
44
+ const reset = () => {
45
+ mx.set(0);
46
+ my.set(0);
47
+ };
48
+
49
+ return (
50
+ // The wrapper is larger than the button so the pull starts before you touch it.
51
+ <span className="inline-block p-6" onPointerMove={onMove} onPointerLeave={reset}>
52
+ <Tag
53
+ ref={ref}
54
+ {...(as === "button" ? { type: "button" } : {})}
55
+ {...rest}
56
+ style={{ x, y }}
57
+ whileTap={{ scale: 0.96 }}
58
+ className={`group relative inline-flex select-none items-center justify-center overflow-hidden rounded-full px-8 py-4 text-[0.95rem] font-semibold outline-none ring-1 transition-shadow focus-visible:ring-2 ${dark ? "bg-neutral-950 text-white ring-white/15 focus-visible:ring-white/40" : "bg-white text-neutral-900 ring-black/10 focus-visible:ring-black/30"} ${className}`}
59
+ >
60
+ <motion.span aria-hidden="true" className="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-300 group-hover:opacity-100" style={{ background: glow }} />
61
+ <motion.span className="relative inline-flex items-center gap-2" style={{ x: lx, y: ly }}>
62
+ {children}
63
+ </motion.span>
64
+ </Tag>
65
+ </span>
66
+ );
67
+ };
68
+
69
+ export default MagneticButton;