apex-ui-kit 2.6.0 → 2.8.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 (33) hide show
  1. package/ReadMe.md +32 -10
  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/CommandPalette/CommandPalette.jsx +250 -0
  8. package/dist/ApexUI-Kit/templates/CrossedMarquee/CrossedMarquee.jsx +158 -0
  9. package/dist/ApexUI-Kit/templates/DitherWaves/DitherWaves.jsx +207 -0
  10. package/dist/ApexUI-Kit/templates/EdgeBlur/EdgeBlur.jsx +92 -0
  11. package/dist/ApexUI-Kit/templates/FlipClock/FlipClock.jsx +5 -1
  12. package/dist/ApexUI-Kit/templates/GlassLens/GlassLens.jsx +274 -0
  13. package/dist/ApexUI-Kit/templates/GooeyNav/GooeyNav.jsx +2 -2
  14. package/dist/ApexUI-Kit/templates/HorizonGlow/HorizonGlow.jsx +259 -0
  15. package/dist/ApexUI-Kit/templates/ImageAccordion/ImageAccordion.jsx +116 -0
  16. package/dist/ApexUI-Kit/templates/InfiniteCanvas/InfiniteCanvas.jsx +230 -0
  17. package/dist/ApexUI-Kit/templates/MagneticButton/MagneticButton.jsx +69 -0
  18. package/dist/ApexUI-Kit/templates/Marquee3D/Marquee3D.jsx +68 -0
  19. package/dist/ApexUI-Kit/templates/NavDropDown/NavDropDown.jsx +123 -1
  20. package/dist/ApexUI-Kit/templates/OrbitIcons/OrbitIcons.jsx +169 -0
  21. package/dist/ApexUI-Kit/templates/ParticleText/ParticleText.jsx +328 -0
  22. package/dist/ApexUI-Kit/templates/RadialMenu/RadialMenu.jsx +119 -0
  23. package/dist/ApexUI-Kit/templates/ScrollStack/ScrollStack.jsx +88 -0
  24. package/dist/ApexUI-Kit/templates/SidebarNav/SidebarNav.jsx +22 -1
  25. package/dist/ApexUI-Kit/templates/SunriseBackground/SunriseBackground.jsx +290 -0
  26. package/dist/ApexUI-Kit/templates/TextRing3D/TextRing3D.jsx +18 -3
  27. package/dist/ApexUI-Kit/templates/ThemeSwitcher/ThemeSwitcher.jsx +215 -0
  28. package/dist/ApexUI-Kit/templates/ThemeSwitcher/themes.js +38 -0
  29. package/dist/ApexUI-Kit/templates/ToastStack/ToastStack.jsx +21 -13
  30. package/dist/ApexUI-Kit/templates/WireframeKnot3D/WireframeKnot3D.jsx +112 -0
  31. package/dist/cli/index.js +20 -0
  32. package/package.json +2 -2
  33. package/dist/ApexUI-Kit/templates/AccordionMarquee/AccordionMarquee.css +0 -1
@@ -0,0 +1,158 @@
1
+ import React, { useEffect, useRef } from "react";
2
+ import { siExpress, siJavascript, siMongodb, siNextdotjs, siNodedotjs, siReact, siRedux, siTailwindcss, siTypescript, siVite } from "simple-icons";
3
+
4
+ /*
5
+ * Crossed Marquee: two tilted bands crossing like an "X" and scrolling in
6
+ * opposite directions. The front band shows brand tiles and big labels, the
7
+ * back band a quieter label row. Scrolling the page bursts the bands forward
8
+ * (or reverses them when you scroll up), then they settle back to a cruise.
9
+ *
10
+ * Props: items (strings or { label, icon }), duration = 45 (s per loop),
11
+ * tilt = 3 (deg), accentColor, bandInk, backColor, tileColor, showBackBand,
12
+ * reactToScroll, pauseOnHover, className. Colours can be CSS variables.
13
+ */
14
+
15
+ const ICONS = { mongodb: siMongodb, expressjs: siExpress, express: siExpress, react: siReact, nodejs: siNodedotjs, javascript: siJavascript, redux: siRedux, tailwindcss: siTailwindcss, tailwind: siTailwindcss, vite: siVite, nextjs: siNextdotjs, typescript: siTypescript };
16
+ const DEFAULT_ITEMS = ["MongoDB", "Express.js", "React", "Node.js", "JavaScript", "Redux", "Tailwind CSS", "Vite"];
17
+
18
+ const keyOf = (s) => String(s).toLowerCase().replace(/[^a-z0-9]/g, "");
19
+ const wrap = (v) => ((((v + 50) % 50) + 50) % 50) - 50;
20
+ const initials = (s) =>
21
+ String(s)
22
+ .split(/[\s.]+/)
23
+ .filter(Boolean)
24
+ .slice(0, 2)
25
+ .map((w) => w[0].toUpperCase())
26
+ .join("");
27
+
28
+ const normalise = (it) => (typeof it === "string" ? { label: it, icon: ICONS[keyOf(it)] } : { ...it, icon: it.icon ?? ICONS[keyOf(it.label)] });
29
+
30
+ const BrandIcon = ({ icon, label }) => {
31
+ if (icon && typeof icon === "object" && "path" in icon) {
32
+ return (
33
+ <svg viewBox="0 0 24 24" className="h-6 w-6 md:h-7 md:w-7" aria-hidden="true">
34
+ <path d={icon.path} fill={`#${icon.hex}`} />
35
+ </svg>
36
+ );
37
+ }
38
+ if (icon) return icon;
39
+ return <span className="text-sm font-black md:text-base">{initials(label)}</span>;
40
+ };
41
+
42
+ const CrossedMarquee = ({ items = DEFAULT_ITEMS, duration = 45, tilt = 3, accentColor = "#ff5b1f", bandInk = "#14110d", backColor = "#121110", tileColor = "#fffaf2", showBackBand = true, reactToScroll = true, pauseOnHover = false, className = "" }) => {
43
+ const root = useRef(null);
44
+ const front = useRef(null);
45
+ const back = useRef(null);
46
+ const live = useRef({});
47
+ live.current = { duration, reactToScroll, pauseOnHover };
48
+ const list = (items.length ? items : DEFAULT_ITEMS).map(normalise);
49
+ // Repeat the list so one copy is always wider than the screen.
50
+ const row = Array.from({ length: Math.max(2, Math.ceil(12 / list.length)) }, () => list).flat();
51
+
52
+ useEffect(() => {
53
+ const el = root.current;
54
+ const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
55
+ if (reduced) return undefined;
56
+ const s = { a: 0, b: -25, dir: 1, speed: 1, lastY: window.scrollY, lastT: performance.now(), hover: false };
57
+ let raf = 0;
58
+ let last = 0;
59
+ let visible = true;
60
+
61
+ const frame = (now) => {
62
+ raf = 0;
63
+ const o = live.current;
64
+ const dt = last ? Math.min(0.05, (now - last) / 1000) : 1 / 60;
65
+ last = now;
66
+ s.speed += (s.dir - s.speed) * 0.05;
67
+ const k = o.pauseOnHover && s.hover ? 0 : 1;
68
+ const d = Math.max(1, o.duration);
69
+ s.a = wrap(s.a - (50 / d) * s.speed * dt * k);
70
+ s.b = wrap(s.b + (50 / (d * 1.22)) * s.speed * dt * k);
71
+ if (front.current) front.current.style.transform = `translate3d(${s.a}%, 0, 0)`;
72
+ if (back.current) back.current.style.transform = `translate3d(${s.b}%, 0, 0)`;
73
+ if (visible) raf = requestAnimationFrame(frame);
74
+ else last = 0;
75
+ };
76
+ const start = () => {
77
+ if (!raf && visible) raf = requestAnimationFrame(frame);
78
+ };
79
+ const onScroll = () => {
80
+ if (!live.current.reactToScroll) return;
81
+ const now = performance.now();
82
+ const dy = window.scrollY - s.lastY;
83
+ const dtS = Math.max(0.016, (now - s.lastT) / 1000);
84
+ s.lastY = window.scrollY;
85
+ s.lastT = now;
86
+ if (!dy) return;
87
+ s.dir = Math.sign(dy);
88
+ s.speed = s.dir * (1 + Math.min(Math.abs(dy) / dtS / 350, 5));
89
+ };
90
+ const onEnter = () => (s.hover = true);
91
+ const onLeave = () => (s.hover = false);
92
+ window.addEventListener("scroll", onScroll, { passive: true });
93
+ el.addEventListener("pointerenter", onEnter);
94
+ el.addEventListener("pointerleave", onLeave);
95
+ const io = new IntersectionObserver(([entry]) => {
96
+ visible = entry.isIntersecting;
97
+ if (visible) start();
98
+ });
99
+ io.observe(el);
100
+ start();
101
+ return () => {
102
+ cancelAnimationFrame(raf);
103
+ io.disconnect();
104
+ window.removeEventListener("scroll", onScroll);
105
+ el.removeEventListener("pointerenter", onEnter);
106
+ el.removeEventListener("pointerleave", onLeave);
107
+ };
108
+ }, []);
109
+
110
+ const band = "absolute left-[-5%] right-[-5%] overflow-hidden";
111
+ return (
112
+ <div ref={root} aria-label={`Tech stack: ${list.map((i) => i.label).join(", ")}`} className={`relative w-full overflow-hidden pb-24 pt-14 md:pb-32 md:pt-20 ${className}`}>
113
+ {/* Spacer so the wrapper has the height of the front band. */}
114
+ <div aria-hidden="true" className="invisible flex items-center py-4 md:py-5">
115
+ <span className="block h-11 md:h-14" />
116
+ </div>
117
+ {showBackBand && (
118
+ <div aria-hidden="true" className={band} style={{ top: "60%", transform: `translateY(-50%) rotate(${tilt}deg)`, background: backColor, borderBlock: `2px solid ${bandInk}` }}>
119
+ <div ref={back} className="flex w-max will-change-transform" style={{ transform: "translate3d(-25%, 0, 0)" }}>
120
+ {[0, 1].map((copy) => (
121
+ <div key={copy} className="flex shrink-0 items-center py-3 md:py-4">
122
+ {row.map((it, i) => (
123
+ <span key={i} className="flex items-center">
124
+ <span className="whitespace-nowrap text-lg font-bold uppercase tracking-tight md:text-2xl" style={{ color: "rgba(255,255,255,.8)" }}>
125
+ {it.label}
126
+ </span>
127
+ <span className="mx-5 block h-2.5 w-2.5 rotate-45 md:mx-8 md:h-3 md:w-3" style={{ background: accentColor }} />
128
+ </span>
129
+ ))}
130
+ </div>
131
+ ))}
132
+ </div>
133
+ </div>
134
+ )}
135
+ <div aria-hidden="true" className={band} style={{ top: "50%", transform: `translateY(-50%) rotate(${-tilt}deg)`, background: accentColor, borderBlock: `2px solid ${bandInk}`, boxShadow: "0 10px 0 rgb(0 0 0 / .08)" }}>
136
+ <div ref={front} className="flex w-max will-change-transform">
137
+ {[0, 1].map((copy) => (
138
+ <div key={copy} className="flex shrink-0 items-center py-4 md:py-5">
139
+ {row.map((it, i) => (
140
+ <span key={i} className="flex items-center">
141
+ <span className="mr-3 grid h-11 w-11 shrink-0 place-items-center rounded-xl md:mr-4 md:h-14 md:w-14" style={{ background: tileColor, border: `2px solid ${bandInk}`, boxShadow: `4px 4px 0 ${bandInk}`, color: bandInk }}>
142
+ <BrandIcon icon={it.icon} label={it.label} />
143
+ </span>
144
+ <span className="whitespace-nowrap font-extrabold uppercase leading-none tracking-tight" style={{ fontSize: "clamp(1.5rem, 3.4vw, 3rem)", color: bandInk }}>
145
+ {it.label}
146
+ </span>
147
+ <span className="mx-5 block h-2.5 w-2.5 rotate-45 md:mx-8 md:h-3 md:w-3" style={{ background: bandInk }} />
148
+ </span>
149
+ ))}
150
+ </div>
151
+ ))}
152
+ </div>
153
+ </div>
154
+ </div>
155
+ );
156
+ };
157
+
158
+ export default CrossedMarquee;
@@ -0,0 +1,207 @@
1
+ import React, { useEffect, useRef } from "react";
2
+ import { Color, Mesh, OrthographicCamera, PlaneGeometry, Scene, ShaderMaterial, Vector2, Vector3, WebGLRenderer } from "three";
3
+
4
+ /*
5
+ * Dither Waves: slow flowing waves turned into crisp two-tone pixel art with
6
+ * ordered (Bayer) dithering, drawn in one WebGL shader. The pointer drops
7
+ * ripples that spread through the pattern. Pick the two colors, the pixel
8
+ * size and the matrix (4x4 is chunkier, 8x8 smoother).
9
+ */
10
+
11
+ const VERT = `void main(){ gl_Position = vec4(position.xy, 0.0, 1.0); }`;
12
+
13
+ const FRAG = `
14
+ precision highp float;
15
+ uniform vec2 uRes; uniform float uTime; uniform float uPixel; uniform float uScale; uniform float uContrast;
16
+ uniform vec3 uInk; uniform vec3 uPaper; uniform float uMatrix;
17
+ uniform vec3 uRipples[6]; // x, y in pixels, start time
18
+ uniform float uRipple; uniform vec2 uMouse; uniform float uHover;
19
+
20
+ float bayer2(vec2 a){ a = floor(a); return fract(a.x / 2.0 + a.y * a.y * 0.75); }
21
+ float bayer4(vec2 a){ return bayer2(0.5 * a) * 0.25 + bayer2(a); }
22
+ float bayer8(vec2 a){ return bayer4(0.5 * a) * 0.25 + bayer2(a); }
23
+
24
+ float h2(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
25
+ float n2(vec2 p){
26
+ vec2 i = floor(p); vec2 f = fract(p); vec2 u = f * f * (3.0 - 2.0 * f);
27
+ 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);
28
+ }
29
+ float fbm(vec2 p){ float v = 0.0; float a = 0.5; for (int i = 0; i < 5; i++) { v += a * n2(p); p = p * 2.03 + 11.7; a *= 0.5; } return v; }
30
+
31
+ void main(){
32
+ // Snap to the pixel grid first so the whole field reads as chunky pixels.
33
+ vec2 cell = floor(gl_FragCoord.xy / uPixel);
34
+ vec2 px = (cell + 0.5) * uPixel;
35
+ vec2 p = px / uRes.y * uScale;
36
+ float t = uTime;
37
+
38
+ // Domain-warped waves drifting diagonally.
39
+ vec2 w = vec2(fbm(p * 0.9 + vec2(t * 0.06, -t * 0.04)), fbm(p * 0.9 + vec2(-t * 0.05, t * 0.07) + 5.2));
40
+ float v = fbm(p + w * 1.6 + vec2(t * 0.03, 0.0));
41
+ v += 0.18 * sin(p.x * 1.3 + p.y * 0.7 - t * 0.6 + w.x * 4.0);
42
+
43
+ // Ripples from the pointer.
44
+ float rip = 0.0;
45
+ for (int i = 0; i < 6; i++) {
46
+ vec3 r = uRipples[i];
47
+ float age = t - r.z;
48
+ if (r.z > 0.0 && age < 3.5) {
49
+ float dist = length(px - r.xy) / uRes.y;
50
+ float front = age * 0.45;
51
+ rip += sin((dist - front) * 38.0) * exp(-abs(dist - front) * 14.0) * exp(-age * 0.9);
52
+ }
53
+ }
54
+ v += rip * 0.22 * uRipple;
55
+ // A soft glow under the pointer.
56
+ v += uHover * 0.18 * exp(-length(px - uMouse) / uRes.y * 7.0);
57
+
58
+ v = clamp((v - 0.5) * uContrast + 0.5, 0.0, 1.0);
59
+ float threshold = uMatrix > 6.0 ? bayer8(cell) : bayer4(cell);
60
+ float on = step(threshold, v);
61
+ gl_FragColor = vec4(mix(uPaper, uInk, on), 1.0);
62
+ }`;
63
+
64
+ const DitherWaves = ({ ink = "#c8f560", paper = "#0b0d0a", pixelSize = 3, matrix = 8, scale = 2.4, contrast = 1.35, speed = 1, ripple = 1, interactive = true, className = "", children }) => {
65
+ const wrap = useRef(null);
66
+ const live = useRef({});
67
+ live.current = { ink, paper, pixelSize, matrix, scale, contrast, speed, ripple, interactive };
68
+
69
+ useEffect(() => {
70
+ const el = wrap.current;
71
+ const renderer = new WebGLRenderer({ antialias: false, alpha: false, powerPreference: "high-performance" });
72
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
73
+ renderer.setPixelRatio(dpr);
74
+ Object.assign(renderer.domElement.style, { position: "absolute", inset: "0", width: "100%", height: "100%", display: "block", imageRendering: "pixelated" });
75
+ el.prepend(renderer.domElement);
76
+ const scene = new Scene();
77
+ const camera = new OrthographicCamera(-1, 1, 1, -1, 0, 1);
78
+ const u = {
79
+ uRes: { value: new Vector2(1, 1) },
80
+ uTime: { value: 0 },
81
+ uPixel: { value: 3 },
82
+ uScale: { value: 2.4 },
83
+ uContrast: { value: 1.35 },
84
+ uInk: { value: new Color() },
85
+ uPaper: { value: new Color() },
86
+ uMatrix: { value: 8 },
87
+ uRipples: { value: Array.from({ length: 6 }, () => new Vector3()) },
88
+ uRipple: { value: 1 },
89
+ uMouse: { value: new Vector2(-9999, -9999) },
90
+ uHover: { value: 0 },
91
+ };
92
+ const material = new ShaderMaterial({ vertexShader: VERT, fragmentShader: FRAG, uniforms: u, depthTest: false, depthWrite: false });
93
+ const geometry = new PlaneGeometry(2, 2);
94
+ scene.add(new Mesh(geometry, material));
95
+
96
+ const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
97
+ const mouse = { x: -9999, y: -9999, hover: 0, in: false, lastDrop: 0, lx: 0, ly: 0 };
98
+ let slot = 0;
99
+ let raf = 0;
100
+ let last = 0;
101
+ let time = 1;
102
+ let visible = true;
103
+ let h = 1;
104
+
105
+ const fit = () => {
106
+ const { width, height } = el.getBoundingClientRect();
107
+ if (!width || !height) return;
108
+ h = height;
109
+ renderer.setSize(width, height, false);
110
+ u.uRes.value.set(width * dpr, height * dpr);
111
+ if (!raf) draw(performance.now());
112
+ };
113
+ const drop = (x, y) => {
114
+ const r = u.uRipples.value[slot];
115
+ r.set(x * dpr, (h - y) * dpr, time);
116
+ slot = (slot + 1) % 6;
117
+ };
118
+ const draw = (now) => {
119
+ const o = live.current;
120
+ const dt = last ? Math.min(0.05, (now - last) / 1000) : 1 / 60;
121
+ last = now;
122
+ time += dt * o.speed;
123
+ mouse.hover += ((o.interactive && mouse.in ? 1 : 0) - mouse.hover) * (1 - Math.exp(-dt / 0.25));
124
+ u.uTime.value = time;
125
+ u.uPixel.value = Math.max(1, o.pixelSize) * dpr;
126
+ u.uScale.value = o.scale;
127
+ u.uContrast.value = o.contrast;
128
+ // The shader writes these straight to the screen, so keep them in sRGB.
129
+ u.uInk.value.set(o.ink).convertLinearToSRGB();
130
+ u.uPaper.value.set(o.paper).convertLinearToSRGB();
131
+ u.uMatrix.value = o.matrix;
132
+ u.uRipple.value = o.interactive ? o.ripple : 0;
133
+ u.uMouse.value.set(mouse.x * dpr, (h - mouse.y) * dpr);
134
+ u.uHover.value = mouse.hover;
135
+ renderer.render(scene, camera);
136
+ };
137
+ const loop = (now) => {
138
+ raf = 0;
139
+ draw(now);
140
+ if (visible && !reduced) raf = requestAnimationFrame(loop);
141
+ };
142
+ const start = () => {
143
+ if (!raf && visible && !reduced) {
144
+ last = 0;
145
+ raf = requestAnimationFrame(loop);
146
+ }
147
+ };
148
+
149
+ const local = (e) => {
150
+ const r = el.getBoundingClientRect();
151
+ return [e.clientX - r.left, e.clientY - r.top];
152
+ };
153
+ const onMove = (e) => {
154
+ [mouse.x, mouse.y] = local(e);
155
+ mouse.in = true;
156
+ // Moving drops a ripple every so often, spaced by distance.
157
+ if (live.current.interactive && Math.hypot(mouse.x - mouse.lx, mouse.y - mouse.ly) > 90 && time - mouse.lastDrop > 0.25) {
158
+ drop(mouse.x, mouse.y);
159
+ mouse.lastDrop = time;
160
+ mouse.lx = mouse.x;
161
+ mouse.ly = mouse.y;
162
+ }
163
+ };
164
+ const onDown = (e) => {
165
+ if (!live.current.interactive) return;
166
+ const [x, y] = local(e);
167
+ drop(x, y);
168
+ };
169
+ const onLeave = () => {
170
+ mouse.in = false;
171
+ };
172
+ el.addEventListener("pointermove", onMove);
173
+ el.addEventListener("pointerdown", onDown);
174
+ el.addEventListener("pointerleave", onLeave);
175
+ const ro = new ResizeObserver(fit);
176
+ ro.observe(el);
177
+ const io = new IntersectionObserver(([entry]) => {
178
+ visible = entry.isIntersecting;
179
+ if (visible) start();
180
+ });
181
+ io.observe(el);
182
+ fit();
183
+ start();
184
+
185
+ return () => {
186
+ cancelAnimationFrame(raf);
187
+ ro.disconnect();
188
+ io.disconnect();
189
+ el.removeEventListener("pointermove", onMove);
190
+ el.removeEventListener("pointerdown", onDown);
191
+ el.removeEventListener("pointerleave", onLeave);
192
+ geometry.dispose();
193
+ material.dispose();
194
+ renderer.dispose();
195
+ renderer.forceContextLoss();
196
+ renderer.domElement.remove();
197
+ };
198
+ }, []);
199
+
200
+ return (
201
+ <div ref={wrap} className={`relative h-full w-full overflow-hidden ${className}`} style={{ background: paper }}>
202
+ {children && <div className="relative z-10 h-full w-full">{children}</div>}
203
+ </div>
204
+ );
205
+ };
206
+
207
+ export default DitherWaves;
@@ -0,0 +1,92 @@
1
+ import React, { useEffect, useMemo, useRef } from "react";
2
+
3
+ /*
4
+ * Edge Blur: a soft, progressive blur along one edge of a scrolling area,
5
+ * the way content melts under an app's top bar or fades out at the end of a
6
+ * list. It stacks a few backdrop-blur layers; each one doubles the blur of
7
+ * the last and is masked to an overlapping band, so the blur grows smoothly
8
+ * toward the edge instead of in steps. Optionally fades into a tint, and can
9
+ * hide itself when the scroll reaches that edge.
10
+ */
11
+
12
+ const DIR = { top: "to top", bottom: "to bottom", left: "to left", right: "to right" };
13
+ const len = (v) => (typeof v === "number" ? `${v}px` : v);
14
+
15
+ // The nearest thing that scrolls along this axis: a sibling, or the parent.
16
+ function findScroller(el, vertical) {
17
+ const scrolls = (node) => {
18
+ const s = getComputedStyle(node);
19
+ return /(auto|scroll)/.test(vertical ? s.overflowY : s.overflowX);
20
+ };
21
+ const parent = el.parentElement;
22
+ if (!parent) return null;
23
+ return [...parent.children].find((c) => c !== el && scrolls(c)) || (scrolls(parent) ? parent : null);
24
+ }
25
+
26
+ const EdgeBlur = ({ edge = "bottom", size = "6rem", blur = 16, steps = 6, tint = "transparent", hideAtEnd = false, scroller, fixed = false, zIndex = 10, className = "" }) => {
27
+ const ref = useRef(null);
28
+ const side = DIR[edge] ? edge : "bottom";
29
+ const vertical = side === "top" || side === "bottom";
30
+ const count = Math.max(2, Math.min(10, Math.round(steps)));
31
+
32
+ // Layer i covers the band [i, i + 2] / count and blurs blur / 2^(count - 1 - i).
33
+ const layers = useMemo(
34
+ () =>
35
+ Array.from({ length: count }, (_, i) => {
36
+ const a = (i / count) * 100;
37
+ const b = ((i + 1) / count) * 100;
38
+ const c = ((i + 2) / count) * 100;
39
+ const amount = blur / Math.pow(2, count - 1 - i);
40
+ const last = i === count - 1;
41
+ const mask = `linear-gradient(${DIR[side]}, transparent ${a.toFixed(1)}%, #000 ${b.toFixed(1)}%${last ? "" : `, #000 ${Math.min(100, c).toFixed(1)}%, transparent ${Math.min(100, c + 100 / count).toFixed(1)}%`})`;
42
+ return { filter: `blur(${amount.toFixed(2)}px)`, mask };
43
+ }),
44
+ [count, blur, side],
45
+ );
46
+
47
+ useEffect(() => {
48
+ const el = ref.current;
49
+ if (!hideAtEnd || !el) {
50
+ if (el) el.style.opacity = "";
51
+ return undefined;
52
+ }
53
+ const target = scroller?.current ?? scroller ?? findScroller(el, vertical);
54
+ if (!target) return undefined;
55
+ const start = side === "top" || side === "left";
56
+ const update = () => {
57
+ const pos = vertical ? target.scrollTop : target.scrollLeft;
58
+ const max = vertical ? target.scrollHeight - target.clientHeight : target.scrollWidth - target.clientWidth;
59
+ const room = start ? pos : max - pos;
60
+ const reach = Math.max(1, vertical ? el.offsetHeight : el.offsetWidth);
61
+ el.style.opacity = String(Math.max(0, Math.min(1, room / reach)));
62
+ };
63
+ update();
64
+ target.addEventListener("scroll", update, { passive: true });
65
+ const ro = new ResizeObserver(update);
66
+ ro.observe(target);
67
+ return () => {
68
+ target.removeEventListener("scroll", update);
69
+ ro.disconnect();
70
+ };
71
+ }, [hideAtEnd, scroller, side, vertical]);
72
+
73
+ const box = {
74
+ position: fixed ? "fixed" : "absolute",
75
+ zIndex,
76
+ pointerEvents: "none",
77
+ transition: "opacity 0.25s ease-out",
78
+ [side]: 0,
79
+ ...(vertical ? { left: 0, right: 0, height: len(size) } : { top: 0, bottom: 0, width: len(size) }),
80
+ };
81
+
82
+ return (
83
+ <div ref={ref} aria-hidden="true" className={className} style={box}>
84
+ {layers.map((l, i) => (
85
+ <div key={i} className="absolute inset-0" style={{ backdropFilter: l.filter, WebkitBackdropFilter: l.filter, maskImage: l.mask, WebkitMaskImage: l.mask }} />
86
+ ))}
87
+ {tint !== "transparent" && <div className="absolute inset-0" style={{ background: `linear-gradient(${DIR[side]}, transparent, ${tint})` }} />}
88
+ </div>
89
+ );
90
+ };
91
+
92
+ export default EdgeBlur;
@@ -91,7 +91,10 @@ const FlipClock = ({ mode = "seconds", target, seconds = 3 * 86400 + 4 * 3600 +
91
91
  const ink = dark ? "#fafafa" : "#18181b";
92
92
 
93
93
  return (
94
- <div role="timer" aria-live="off" aria-label={parts.map(([l, v]) => `${v} ${l}`).join(", ")} className={`inline-flex items-start gap-[0.35em] font-mono font-bold ${className}`} style={{ fontSize: `${size}rem` }}>
94
+ // The clock is sized in em; on narrow screens the font shrinks with the
95
+ // container (cqw) so every unit fits instead of being cut off.
96
+ <div className="flex w-full justify-center" style={{ containerType: "inline-size" }}>
97
+ <div role="timer" aria-live="off" aria-label={parts.map(([l, v]) => `${v} ${l}`).join(", ")} className={`inline-flex items-start gap-[0.35em] font-mono font-bold ${className}`} style={{ fontSize: `min(${size}rem, ${(100 / (parts.length * 2.55)).toFixed(2)}cqw)` }}>
95
98
  {parts.map(([label, value], i) => (
96
99
  <React.Fragment key={label}>
97
100
  {i > 0 && (
@@ -115,6 +118,7 @@ const FlipClock = ({ mode = "seconds", target, seconds = 3 * 86400 + 4 * 3600 +
115
118
  </React.Fragment>
116
119
  ))}
117
120
  </div>
121
+ </div>
118
122
  );
119
123
  };
120
124