apex-ui-kit 2.7.0 → 2.9.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.
- package/ReadMe.md +21 -9
- package/dist/ApexUI-Kit/templates/AnimatedTabs/AnimatedTabs.jsx +1 -1
- package/dist/ApexUI-Kit/templates/ArcGallery/ArcGallery.jsx +29 -11
- package/dist/ApexUI-Kit/templates/CircularCarousel/CircularCarousel.jsx +0 -1
- package/dist/ApexUI-Kit/templates/CommandPalette/CommandPalette.jsx +250 -0
- package/dist/ApexUI-Kit/templates/CrossedMarquee/CrossedMarquee.jsx +158 -0
- package/dist/ApexUI-Kit/templates/DitherWaves/DitherWaves.jsx +207 -0
- package/dist/ApexUI-Kit/templates/FlipClock/FlipClock.jsx +3 -2
- package/dist/ApexUI-Kit/templates/GlareCard/GlareCard.jsx +25 -18
- package/dist/ApexUI-Kit/templates/GlassLens/GlassLens.jsx +273 -0
- package/dist/ApexUI-Kit/templates/GooeyNav/GooeyNav.jsx +1 -1
- package/dist/ApexUI-Kit/templates/ImageAccordion/ImageAccordion.jsx +116 -0
- package/dist/ApexUI-Kit/templates/InfiniteCanvas/InfiniteCanvas.jsx +230 -0
- package/dist/ApexUI-Kit/templates/LiquidGlass/LiquidGlass.jsx +444 -0
- package/dist/ApexUI-Kit/templates/OrbitIcons/OrbitIcons.jsx +169 -0
- package/dist/ApexUI-Kit/templates/RadialMenu/RadialMenu.jsx +20 -4
- package/dist/ApexUI-Kit/templates/ScrollStack/ScrollStack.jsx +3 -3
- package/dist/ApexUI-Kit/templates/SunriseBackground/SunriseBackground.jsx +68 -2
- package/dist/ApexUI-Kit/templates/TextRing3D/TextRing3D.jsx +18 -3
- package/dist/ApexUI-Kit/templates/ThemeSwitcher/ThemeSwitcher.jsx +215 -0
- package/dist/ApexUI-Kit/templates/ThemeSwitcher/themes.js +38 -0
- package/dist/ApexUI-Kit/templates/ToastStack/ToastStack.jsx +21 -13
- package/dist/cli/index.js +10 -0
- package/package.json +2 -2
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import React, { useEffect, useRef } from "react";
|
|
2
|
+
import { Color, Mesh, OrthographicCamera, PlaneGeometry, Scene, ShaderMaterial, SRGBColorSpace, TextureLoader, Vector2, WebGLRenderer } from "three";
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* Glass Lens: a drop of liquid glass that glides over an image, drawn in
|
|
6
|
+
* WebGL. It magnifies the middle, bends the picture hard at its rim, splits
|
|
7
|
+
* the colors a little at the edge, catches a specular highlight and casts a
|
|
8
|
+
* soft shadow. It follows the pointer on a spring and stretches along the
|
|
9
|
+
* way it moves, then rounds out again when it stops. When the pointer
|
|
10
|
+
* leaves it drifts on its own. Drag it on touch screens.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const DEFAULT_IMAGE = "https://ik.imagekit.io/ny6yinyut/apexui/assets/componentsimage/galaxy-3d.png?tr=f-auto,q-85,w-1600";
|
|
14
|
+
|
|
15
|
+
const VERT = `void main(){ gl_Position = vec4(position.xy, 0.0, 1.0); }`;
|
|
16
|
+
|
|
17
|
+
const FRAG = `
|
|
18
|
+
precision highp float;
|
|
19
|
+
uniform sampler2D uTex; uniform vec2 uRes; uniform vec2 uCover; uniform float uReady;
|
|
20
|
+
uniform vec2 uLens; uniform float uRadius; uniform vec2 uStretch; uniform float uAngle;
|
|
21
|
+
uniform float uRefract; uniform float uDisperse; uniform float uFrost; uniform vec3 uTint; uniform float uTintAmt;
|
|
22
|
+
|
|
23
|
+
vec3 img(vec2 uv){ return texture2D(uTex, (uv - 0.5) * uCover + 0.5).rgb; }
|
|
24
|
+
|
|
25
|
+
void main(){
|
|
26
|
+
vec2 px = gl_FragCoord.xy;
|
|
27
|
+
vec2 uv = px / uRes;
|
|
28
|
+
vec3 base = img(uv);
|
|
29
|
+
|
|
30
|
+
// Lens space: rotate into the direction of travel and squash by the stretch.
|
|
31
|
+
vec2 rel = px - uLens;
|
|
32
|
+
float c = cos(uAngle); float s = sin(uAngle);
|
|
33
|
+
vec2 q = vec2(c * rel.x + s * rel.y, -s * rel.x + c * rel.y) / (uRadius * uStretch);
|
|
34
|
+
float d = length(q);
|
|
35
|
+
|
|
36
|
+
// Soft drop shadow just outside the rim.
|
|
37
|
+
float shadow = smoothstep(1.45, 0.95, d) * (1.0 - smoothstep(0.98, 1.0, d));
|
|
38
|
+
vec3 col = base * (1.0 - 0.28 * shadow);
|
|
39
|
+
|
|
40
|
+
if (d < 1.0) {
|
|
41
|
+
float h = sqrt(1.0 - d * d); // dome height
|
|
42
|
+
vec2 dir = rel / max(length(rel), 1e-3);
|
|
43
|
+
// Like a convex lens: the middle is magnified, the rim squeezes in the
|
|
44
|
+
// picture from further out.
|
|
45
|
+
float rimK = (1.0 - h) * (1.0 - h);
|
|
46
|
+
float scaleK = mix(1.0, 0.7, uRefract) + rimK * 1.15 * uRefract;
|
|
47
|
+
vec2 baseUv = (uLens + rel * scaleK) / uRes;
|
|
48
|
+
float disp = uDisperse * rimK * 0.006;
|
|
49
|
+
vec3 g;
|
|
50
|
+
if (uFrost > 0.0) {
|
|
51
|
+
vec3 acc = vec3(0.0);
|
|
52
|
+
float k = uFrost * 0.012;
|
|
53
|
+
for (int i = 0; i < 8; i++) {
|
|
54
|
+
float a = float(i) * 0.785398;
|
|
55
|
+
vec2 o = vec2(cos(a), sin(a)) * k * (0.5 + 0.5 * fract(float(i) * 0.618));
|
|
56
|
+
acc += vec3(img(baseUv + o + dir * disp).r, img(baseUv + o).g, img(baseUv + o - dir * disp).b);
|
|
57
|
+
}
|
|
58
|
+
g = acc / 8.0;
|
|
59
|
+
} else {
|
|
60
|
+
g = vec3(img(baseUv + dir * disp).r, img(baseUv).g, img(baseUv - dir * disp).b);
|
|
61
|
+
}
|
|
62
|
+
g = mix(g, g * uTint + uTint * 0.08, uTintAmt);
|
|
63
|
+
// Light from the top left: a tight highlight and a broad sheen.
|
|
64
|
+
vec3 n = normalize(vec3(q, h * 1.4));
|
|
65
|
+
vec3 l = normalize(vec3(-0.45, 0.6, 0.65));
|
|
66
|
+
float spec = pow(max(dot(reflect(-l, n), vec3(0.0, 0.0, 1.0)), 0.0), 48.0);
|
|
67
|
+
float sheen = pow(max(dot(n, l), 0.0), 3.0) * 0.12;
|
|
68
|
+
float rim = smoothstep(0.86, 0.995, d);
|
|
69
|
+
float inner = smoothstep(0.995, 0.9, d) * smoothstep(0.75, 0.97, d);
|
|
70
|
+
g += vec3(1.0) * (spec * 0.9 + sheen) + vec3(1.0) * rim * 0.22 * (0.6 + 0.4 * q.y) - inner * 0.06;
|
|
71
|
+
float edge = smoothstep(1.0, 0.985, d);
|
|
72
|
+
col = mix(col, g, edge);
|
|
73
|
+
}
|
|
74
|
+
col = mix(vec3(0.07), col, uReady);
|
|
75
|
+
gl_FragColor = vec4(col, 1.0);
|
|
76
|
+
}`;
|
|
77
|
+
|
|
78
|
+
const GlassLens = ({ image = DEFAULT_IMAGE, size = 9, refraction = 0.7, dispersion = 1, frost = 0, tint = "#ffffff", tintAmount = 0, stretch = 1, idle = true, height = "28rem", className = "", children }) => {
|
|
79
|
+
const wrap = useRef(null);
|
|
80
|
+
const live = useRef({});
|
|
81
|
+
live.current = { size, refraction, dispersion, frost, tint, tintAmount, stretch, idle };
|
|
82
|
+
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
const el = wrap.current;
|
|
85
|
+
const renderer = new WebGLRenderer({ antialias: false, alpha: false, powerPreference: "high-performance" });
|
|
86
|
+
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
87
|
+
renderer.setPixelRatio(dpr);
|
|
88
|
+
renderer.outputColorSpace = SRGBColorSpace;
|
|
89
|
+
Object.assign(renderer.domElement.style, { position: "absolute", inset: "0", width: "100%", height: "100%", display: "block", touchAction: "none" });
|
|
90
|
+
el.prepend(renderer.domElement);
|
|
91
|
+
const scene = new Scene();
|
|
92
|
+
const camera = new OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
|
93
|
+
const u = {
|
|
94
|
+
uTex: { value: null },
|
|
95
|
+
uRes: { value: new Vector2(1, 1) },
|
|
96
|
+
uCover: { value: new Vector2(1, 1) },
|
|
97
|
+
uReady: { value: 0 },
|
|
98
|
+
uLens: { value: new Vector2() },
|
|
99
|
+
uRadius: { value: 100 },
|
|
100
|
+
uStretch: { value: new Vector2(1, 1) },
|
|
101
|
+
uAngle: { value: 0 },
|
|
102
|
+
uRefract: { value: 0.7 },
|
|
103
|
+
uDisperse: { value: 1 },
|
|
104
|
+
uFrost: { value: 0 },
|
|
105
|
+
uTint: { value: new Color(1, 1, 1) },
|
|
106
|
+
uTintAmt: { value: 0 },
|
|
107
|
+
};
|
|
108
|
+
const material = new ShaderMaterial({ vertexShader: VERT, fragmentShader: FRAG, uniforms: u, depthTest: false, depthWrite: false });
|
|
109
|
+
const geometry = new PlaneGeometry(2, 2);
|
|
110
|
+
scene.add(new Mesh(geometry, material));
|
|
111
|
+
|
|
112
|
+
const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
|
113
|
+
const view = { w: 1, h: 1, aspect: 1, imgAspect: 1 };
|
|
114
|
+
// Lens state in CSS pixels, y measured from the bottom like gl_FragCoord.
|
|
115
|
+
const lens = { x: 0, y: 0, vx: 0, vy: 0, tx: 0, ty: 0, sx: 1, sy: 1, angle: 0, placed: false };
|
|
116
|
+
const pointer = { inside: false, dragging: false, lastMove: 0 };
|
|
117
|
+
let raf = 0;
|
|
118
|
+
let last = 0;
|
|
119
|
+
let time = 0;
|
|
120
|
+
let visible = true;
|
|
121
|
+
let readyAt = 0;
|
|
122
|
+
|
|
123
|
+
const cover = () => {
|
|
124
|
+
const a = view.aspect;
|
|
125
|
+
const ia = view.imgAspect;
|
|
126
|
+
u.uCover.value.set(ia > a ? a / ia : 1, ia > a ? 1 : ia / a);
|
|
127
|
+
};
|
|
128
|
+
new TextureLoader().setCrossOrigin("anonymous").load(image, (t) => {
|
|
129
|
+
u.uTex.value = t;
|
|
130
|
+
view.imgAspect = t.image.width / t.image.height;
|
|
131
|
+
cover();
|
|
132
|
+
readyAt = performance.now();
|
|
133
|
+
start();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const fit = () => {
|
|
137
|
+
const { width, height: hh } = el.getBoundingClientRect();
|
|
138
|
+
if (!width || !hh) return;
|
|
139
|
+
view.w = width;
|
|
140
|
+
view.h = hh;
|
|
141
|
+
view.aspect = width / hh;
|
|
142
|
+
renderer.setSize(width, hh, false);
|
|
143
|
+
u.uRes.value.set(width * dpr, hh * dpr);
|
|
144
|
+
cover();
|
|
145
|
+
if (!lens.placed) {
|
|
146
|
+
lens.x = lens.tx = width * 0.5;
|
|
147
|
+
lens.y = lens.ty = hh * 0.5;
|
|
148
|
+
lens.placed = true;
|
|
149
|
+
}
|
|
150
|
+
start();
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const frame = (now) => {
|
|
154
|
+
raf = 0;
|
|
155
|
+
const o = live.current;
|
|
156
|
+
const dt = last ? Math.min(0.05, (now - last) / 1000) : 1 / 60;
|
|
157
|
+
last = now;
|
|
158
|
+
time += dt;
|
|
159
|
+
// Idle drift when nobody is steering it.
|
|
160
|
+
if (o.idle && !pointer.inside && !pointer.dragging && !reduced && now - pointer.lastMove > 900) {
|
|
161
|
+
lens.tx = view.w * (0.5 + 0.28 * Math.sin(time * 0.45));
|
|
162
|
+
lens.ty = view.h * (0.5 + 0.22 * Math.sin(time * 0.7 + 1.2));
|
|
163
|
+
}
|
|
164
|
+
// Spring toward the target (slightly underdamped for a liquid feel).
|
|
165
|
+
const k = reduced ? 400 : 140;
|
|
166
|
+
const c = 2 * Math.sqrt(k) * 0.82;
|
|
167
|
+
lens.vx += (k * (lens.tx - lens.x) - c * lens.vx) * dt;
|
|
168
|
+
lens.vy += (k * (lens.ty - lens.y) - c * lens.vy) * dt;
|
|
169
|
+
lens.x += lens.vx * dt;
|
|
170
|
+
lens.y += lens.vy * dt;
|
|
171
|
+
const speed = Math.hypot(lens.vx, lens.vy);
|
|
172
|
+
const goal = 1 + Math.min(0.32, speed / 2600) * o.stretch;
|
|
173
|
+
lens.sx += (goal - lens.sx) * (1 - Math.exp(-dt / 0.08));
|
|
174
|
+
lens.sy = 1 / Math.sqrt(lens.sx);
|
|
175
|
+
if (speed > 30) {
|
|
176
|
+
const want = Math.atan2(lens.vy, lens.vx);
|
|
177
|
+
let delta = want - lens.angle;
|
|
178
|
+
delta = Math.atan2(Math.sin(delta), Math.cos(delta));
|
|
179
|
+
lens.angle += delta * (1 - Math.exp(-dt / 0.06));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// size is in rem; never more than ~40% of the short side.
|
|
183
|
+
const r = Math.min(Math.min(view.w, view.h) * 0.4, o.size * 16);
|
|
184
|
+
u.uRadius.value = r * dpr;
|
|
185
|
+
u.uLens.value.set(lens.x * dpr, (view.h - lens.y) * dpr);
|
|
186
|
+
u.uStretch.value.set(lens.sx, lens.sy);
|
|
187
|
+
u.uAngle.value = -lens.angle;
|
|
188
|
+
u.uRefract.value = o.refraction;
|
|
189
|
+
u.uDisperse.value = o.dispersion;
|
|
190
|
+
u.uFrost.value = o.frost;
|
|
191
|
+
u.uTint.value.set(o.tint);
|
|
192
|
+
u.uTintAmt.value = o.tintAmount;
|
|
193
|
+
u.uReady.value = readyAt ? Math.min(1, (now - readyAt) / 500) : 0;
|
|
194
|
+
renderer.render(scene, camera);
|
|
195
|
+
|
|
196
|
+
const moving = speed > 0.5 || Math.abs(lens.tx - lens.x) > 0.3 || Math.abs(lens.ty - lens.y) > 0.3 || Math.abs(lens.sx - 1) > 0.002;
|
|
197
|
+
const idling = o.idle && !pointer.inside && !reduced;
|
|
198
|
+
if (visible && (moving || idling || u.uReady.value < 1)) raf = requestAnimationFrame(frame);
|
|
199
|
+
else last = 0;
|
|
200
|
+
};
|
|
201
|
+
function start() {
|
|
202
|
+
if (!raf && visible) raf = requestAnimationFrame(frame);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const local = (e) => {
|
|
206
|
+
const r = el.getBoundingClientRect();
|
|
207
|
+
return [e.clientX - r.left, e.clientY - r.top];
|
|
208
|
+
};
|
|
209
|
+
const onMove = (e) => {
|
|
210
|
+
if (e.pointerType === "touch" && !pointer.dragging) return;
|
|
211
|
+
[lens.tx, lens.ty] = local(e);
|
|
212
|
+
pointer.inside = true;
|
|
213
|
+
pointer.lastMove = performance.now();
|
|
214
|
+
start();
|
|
215
|
+
};
|
|
216
|
+
const onDown = (e) => {
|
|
217
|
+
if (e.pointerType !== "touch") return;
|
|
218
|
+
e.preventDefault();
|
|
219
|
+
pointer.dragging = true;
|
|
220
|
+
el.setPointerCapture(e.pointerId);
|
|
221
|
+
[lens.tx, lens.ty] = local(e);
|
|
222
|
+
start();
|
|
223
|
+
};
|
|
224
|
+
const onUp = () => {
|
|
225
|
+
pointer.dragging = false;
|
|
226
|
+
pointer.lastMove = performance.now();
|
|
227
|
+
};
|
|
228
|
+
const onLeave = (e) => {
|
|
229
|
+
if (e.pointerType === "touch") return;
|
|
230
|
+
pointer.inside = false;
|
|
231
|
+
pointer.lastMove = performance.now();
|
|
232
|
+
start();
|
|
233
|
+
};
|
|
234
|
+
el.addEventListener("pointermove", onMove);
|
|
235
|
+
el.addEventListener("pointerdown", onDown);
|
|
236
|
+
el.addEventListener("pointerup", onUp);
|
|
237
|
+
el.addEventListener("pointercancel", onUp);
|
|
238
|
+
el.addEventListener("pointerleave", onLeave);
|
|
239
|
+
const ro = new ResizeObserver(fit);
|
|
240
|
+
ro.observe(el);
|
|
241
|
+
const io = new IntersectionObserver(([entry]) => {
|
|
242
|
+
visible = entry.isIntersecting;
|
|
243
|
+
if (visible) start();
|
|
244
|
+
});
|
|
245
|
+
io.observe(el);
|
|
246
|
+
fit();
|
|
247
|
+
|
|
248
|
+
return () => {
|
|
249
|
+
cancelAnimationFrame(raf);
|
|
250
|
+
ro.disconnect();
|
|
251
|
+
io.disconnect();
|
|
252
|
+
el.removeEventListener("pointermove", onMove);
|
|
253
|
+
el.removeEventListener("pointerdown", onDown);
|
|
254
|
+
el.removeEventListener("pointerup", onUp);
|
|
255
|
+
el.removeEventListener("pointercancel", onUp);
|
|
256
|
+
el.removeEventListener("pointerleave", onLeave);
|
|
257
|
+
u.uTex.value?.dispose();
|
|
258
|
+
geometry.dispose();
|
|
259
|
+
material.dispose();
|
|
260
|
+
renderer.dispose();
|
|
261
|
+
renderer.forceContextLoss();
|
|
262
|
+
renderer.domElement.remove();
|
|
263
|
+
};
|
|
264
|
+
}, [image]);
|
|
265
|
+
|
|
266
|
+
return (
|
|
267
|
+
<div ref={wrap} className={`relative w-full cursor-none select-none overflow-hidden bg-[#111] ${className}`} style={{ height }}>
|
|
268
|
+
{children && <div className="pointer-events-none relative z-10 h-full w-full">{children}</div>}
|
|
269
|
+
</div>
|
|
270
|
+
);
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
export default GlassLens;
|
|
@@ -299,7 +299,7 @@ const GooeyNav = ({ items = DEFAULT_ITEMS, initialActive = 0, onChange, accentCo
|
|
|
299
299
|
href={item.href}
|
|
300
300
|
onClick={(e) => select(i, e)}
|
|
301
301
|
aria-current={i === active ? "page" : undefined}
|
|
302
|
-
className={`relative z-[1] shrink-0 rounded-full px-2.5 py-2 text-[0.82rem] font-medium sm:py-2.5 sm:text-[0.95rem] outline-none transition-colors duration-300 focus-visible:ring-2 focus-visible:ring-current/30 sm:px-5 ${i === active ? "text-neutral-950" : dark ? "text-white/65 hover:text-white" : "text-neutral-500 hover:text-neutral-900"}`}
|
|
302
|
+
className={`relative z-[1] shrink-0 rounded-full px-2.5 py-2 text-[0.82rem] font-medium max-[380px]:px-2 max-[380px]:text-[0.76rem] sm:py-2.5 sm:text-[0.95rem] outline-none transition-colors duration-300 focus-visible:ring-2 focus-visible:ring-current/30 sm:px-5 ${i === active ? "text-neutral-950" : dark ? "text-white/65 hover:text-white" : "text-neutral-500 hover:text-neutral-900"}`}
|
|
303
303
|
>
|
|
304
304
|
{item.label}
|
|
305
305
|
</a>
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* Image Accordion: a row of image panels where the active one opens wide and
|
|
5
|
+
* the rest fold into slim strips with their titles turned sideways. Hover,
|
|
6
|
+
* focus or tap a panel to open it; it can also cycle on its own. Below
|
|
7
|
+
* 520px of width it stacks vertically and the panels grow in height instead,
|
|
8
|
+
* so it fits phones. The panels animate flex-grow with one easing curve, so
|
|
9
|
+
* the open and close always move together.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const IK = "https://ik.imagekit.io/ny6yinyut/apexui/assets/componentsimage";
|
|
13
|
+
const DEFAULT_ITEMS = [
|
|
14
|
+
{ image: `${IK}/galaxy-3d.png?tr=f-auto,q-80,w-1100`, title: "Galaxy 3D", description: "A turning spiral galaxy for hero sections." },
|
|
15
|
+
{ image: `${IK}/sunrise-background.png?tr=f-auto,q-80,w-1100`, title: "Sunrise", description: "A sun rising between two mountain ranges." },
|
|
16
|
+
{ image: `${IK}/liquid-blob-3d.png?tr=f-auto,q-80,w-1100`, title: "Liquid Blob", description: "A glossy blob that ripples toward the pointer." },
|
|
17
|
+
{ image: `${IK}/warp-tunnel.png?tr=f-auto,q-80,w-1100`, title: "Warp Tunnel", description: "Hold to boost into hyperspace." },
|
|
18
|
+
{ image: `${IK}/horizon-glow-v2.png?tr=f-auto,q-80,w-1100`, title: "Horizon Glow", description: "Soft light rising from below the edge." },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const EASE = "cubic-bezier(0.22, 1, 0.36, 1)";
|
|
22
|
+
|
|
23
|
+
const ImageAccordion = ({ items = DEFAULT_ITEMS, defaultActive = 0, autoPlay = 0, openRatio = 4, radius = 1.25, gap = 0.6, duration = 700, height = "26rem", onChange, className = "" }) => {
|
|
24
|
+
const list = items.length ? items : DEFAULT_ITEMS;
|
|
25
|
+
const [active, setActive] = useState(Math.min(defaultActive, list.length - 1));
|
|
26
|
+
const [vertical, setVertical] = useState(false);
|
|
27
|
+
const root = useRef(null);
|
|
28
|
+
const hovering = useRef(false);
|
|
29
|
+
|
|
30
|
+
const open = (i) => {
|
|
31
|
+
setActive((cur) => {
|
|
32
|
+
if (cur !== i) onChange?.(i);
|
|
33
|
+
return i;
|
|
34
|
+
});
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Stack vertically when the box is narrow (a container check, not the viewport).
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
const el = root.current;
|
|
40
|
+
const ro = new ResizeObserver(([entry]) => setVertical(entry.contentRect.width < 520));
|
|
41
|
+
ro.observe(el);
|
|
42
|
+
return () => ro.disconnect();
|
|
43
|
+
}, []);
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (!autoPlay) return undefined;
|
|
47
|
+
const id = setInterval(() => {
|
|
48
|
+
if (!hovering.current) setActive((cur) => (cur + 1) % list.length);
|
|
49
|
+
}, autoPlay * 1000);
|
|
50
|
+
return () => clearInterval(id);
|
|
51
|
+
}, [autoPlay, list.length]);
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div
|
|
55
|
+
ref={root}
|
|
56
|
+
role="tablist"
|
|
57
|
+
aria-orientation={vertical ? "vertical" : "horizontal"}
|
|
58
|
+
onPointerEnter={() => (hovering.current = true)}
|
|
59
|
+
onPointerLeave={() => (hovering.current = false)}
|
|
60
|
+
className={`flex w-full ${vertical ? "flex-col" : "flex-row"} ${className}`}
|
|
61
|
+
style={{ height: vertical ? `max(${height}, ${list.length * 3.4 + openRatio * 3}rem)` : height, gap: `${gap}rem` }}
|
|
62
|
+
>
|
|
63
|
+
{list.map((item, i) => {
|
|
64
|
+
const on = i === active;
|
|
65
|
+
return (
|
|
66
|
+
<button
|
|
67
|
+
key={i}
|
|
68
|
+
type="button"
|
|
69
|
+
role="tab"
|
|
70
|
+
aria-selected={on}
|
|
71
|
+
aria-label={item.title}
|
|
72
|
+
onPointerEnter={(e) => e.pointerType === "mouse" && open(i)}
|
|
73
|
+
onFocus={() => open(i)}
|
|
74
|
+
onClick={() => open(i)}
|
|
75
|
+
className="group relative min-h-0 min-w-0 overflow-hidden text-left text-white outline-none focus-visible:ring-2 focus-visible:ring-white/70"
|
|
76
|
+
style={{ flexGrow: on ? openRatio : 1, flexBasis: 0, borderRadius: `${radius}rem`, transition: `flex-grow ${duration}ms ${EASE}` }}
|
|
77
|
+
>
|
|
78
|
+
<img
|
|
79
|
+
src={item.image}
|
|
80
|
+
alt=""
|
|
81
|
+
draggable={false}
|
|
82
|
+
className="absolute inset-0 h-full w-full object-cover"
|
|
83
|
+
style={{ transform: on ? "scale(1)" : "scale(1.12)", filter: on ? "none" : "saturate(0.6) brightness(0.75)", transition: `transform ${duration * 1.4}ms ${EASE}, filter ${duration}ms ${EASE}` }}
|
|
84
|
+
/>
|
|
85
|
+
<span aria-hidden="true" className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent" />
|
|
86
|
+
|
|
87
|
+
{/* Folded: the title runs along the strip. */}
|
|
88
|
+
<span
|
|
89
|
+
aria-hidden="true"
|
|
90
|
+
className="absolute whitespace-nowrap text-sm font-semibold tracking-wide"
|
|
91
|
+
style={{
|
|
92
|
+
...(vertical ? { left: "1rem", top: "50%", transform: "translateY(-50%)" } : { left: "50%", bottom: "1.25rem", transform: "translateX(-50%) rotate(-90deg)", transformOrigin: "center", translate: "0 -2.5rem" }),
|
|
93
|
+
opacity: on ? 0 : 0.9,
|
|
94
|
+
transition: `opacity ${duration * 0.5}ms ease`,
|
|
95
|
+
}}
|
|
96
|
+
>
|
|
97
|
+
{item.title}
|
|
98
|
+
</span>
|
|
99
|
+
|
|
100
|
+
{/* Open: title and text rise in once the panel has room. */}
|
|
101
|
+
<span
|
|
102
|
+
className="absolute inset-x-0 bottom-0 block p-[clamp(1rem,3cqw,1.75rem)]"
|
|
103
|
+
style={{ opacity: on ? 1 : 0, transform: on ? "none" : "translateY(0.75rem)", transition: `opacity ${duration * 0.6}ms ${EASE} ${on ? duration * 0.35 : 0}ms, transform ${duration * 0.8}ms ${EASE} ${on ? duration * 0.3 : 0}ms` }}
|
|
104
|
+
>
|
|
105
|
+
<span className="block font-mono text-[0.7rem] tracking-[0.2em] text-white/60">{String(i + 1).padStart(2, "0")}</span>
|
|
106
|
+
<span className="mt-1 block text-[clamp(1.2rem,2.4vw,1.9rem)] font-bold leading-tight">{item.title}</span>
|
|
107
|
+
{item.description && <span className="mt-1 block max-w-sm text-sm text-white/75">{item.description}</span>}
|
|
108
|
+
</span>
|
|
109
|
+
</button>
|
|
110
|
+
);
|
|
111
|
+
})}
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
export default ImageAccordion;
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* Infinite Canvas: an endless wall of image cards you can drag, flick or
|
|
5
|
+
* scroll in any direction. It glides to a stop with momentum, cards lean
|
|
6
|
+
* with the speed of the move and settle flat again, and the wall drifts on
|
|
7
|
+
* its own while nobody touches it. Only enough cards to cover the view are
|
|
8
|
+
* rendered; they are recycled as they leave an edge, so it stays light.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const IK = "https://ik.imagekit.io/ny6yinyut/apexui/assets/componentsimage";
|
|
12
|
+
const shot = (n, t) => ({ image: `${IK}/${n}.png?tr=f-auto,q-75,w-520`, title: t });
|
|
13
|
+
const DEFAULT_ITEMS = [
|
|
14
|
+
shot("galaxy-3d", "Galaxy 3D"),
|
|
15
|
+
shot("warp-tunnel", "Warp Tunnel"),
|
|
16
|
+
shot("liquid-blob-3d", "Liquid Blob 3D"),
|
|
17
|
+
shot("particle-text", "Particle Text"),
|
|
18
|
+
shot("retro-grid-3d", "Retro Grid 3D"),
|
|
19
|
+
shot("marquee-3d", "Marquee 3D"),
|
|
20
|
+
shot("sunrise-background", "Sunrise Background"),
|
|
21
|
+
shot("circular-carousel", "Circular Carousel"),
|
|
22
|
+
shot("wireframe-knot-3d", "Wireframe Knot 3D"),
|
|
23
|
+
shot("arc-gallery", "Arc Gallery"),
|
|
24
|
+
shot("particle-morph", "Particle Morph"),
|
|
25
|
+
shot("scroll-stack", "Scroll Stack"),
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
const InfiniteCanvas = ({ items = DEFAULT_ITEMS, cardWidth = 14, aspectRatio = 0.8, gap = 1.25, drift = 18, tilt = 1, friction = 0.93, showTitles = true, height = "34rem", onItemClick, className = "" }) => {
|
|
29
|
+
const wrap = useRef(null);
|
|
30
|
+
const tiles = useRef([]);
|
|
31
|
+
const [pool, setPool] = useState(0);
|
|
32
|
+
const live = useRef({});
|
|
33
|
+
live.current = { drift, tilt, friction, showTitles, onItemClick };
|
|
34
|
+
const list = items.length ? items : DEFAULT_ITEMS;
|
|
35
|
+
const listRef = useRef(list);
|
|
36
|
+
listRef.current = list;
|
|
37
|
+
const geo = useRef({ cw: 1, ch: 1, cols: 0, rows: 0 });
|
|
38
|
+
|
|
39
|
+
// Size the pool to cover the view (plus a ring of spares) whenever it resizes.
|
|
40
|
+
useLayoutEffect(() => {
|
|
41
|
+
const el = wrap.current;
|
|
42
|
+
const fit = () => {
|
|
43
|
+
const { width, height: hh } = el.getBoundingClientRect();
|
|
44
|
+
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
|
45
|
+
// Cards shrink a little on small screens so a few always fit.
|
|
46
|
+
const w = Math.min(cardWidth * rem, Math.max(width * 0.42, 120));
|
|
47
|
+
const cw = w + gap * rem;
|
|
48
|
+
const ch = w / aspectRatio + gap * rem;
|
|
49
|
+
const cols = Math.ceil(width / cw) + 2;
|
|
50
|
+
const rows = Math.ceil(hh / ch) + 3;
|
|
51
|
+
geo.current = { cw, ch, w, h: w / aspectRatio, cols, rows };
|
|
52
|
+
setPool(cols * rows);
|
|
53
|
+
};
|
|
54
|
+
fit();
|
|
55
|
+
const ro = new ResizeObserver(fit);
|
|
56
|
+
ro.observe(el);
|
|
57
|
+
return () => ro.disconnect();
|
|
58
|
+
}, [cardWidth, aspectRatio, gap]);
|
|
59
|
+
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
const el = wrap.current;
|
|
62
|
+
if (!pool) return undefined;
|
|
63
|
+
const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
|
64
|
+
const s = { ox: 0, oy: 0, vx: 0, vy: 0, lean: { x: 0, y: 0 }, drag: null, hover: false };
|
|
65
|
+
const slots = tiles.current.slice(0, pool).map(() => ({ key: "" }));
|
|
66
|
+
let raf = 0;
|
|
67
|
+
let last = 0;
|
|
68
|
+
let visible = true;
|
|
69
|
+
|
|
70
|
+
const paint = () => {
|
|
71
|
+
const { cw, ch, cols } = geo.current;
|
|
72
|
+
const items = listRef.current;
|
|
73
|
+
const n = items.length;
|
|
74
|
+
const c0 = Math.floor(-s.ox / cw) - 1;
|
|
75
|
+
const r0 = Math.floor(-s.oy / ch) - 2;
|
|
76
|
+
const rx = Math.max(-10, Math.min(10, -s.lean.y * 0.012 * live.current.tilt));
|
|
77
|
+
const ry = Math.max(-10, Math.min(10, s.lean.x * 0.012 * live.current.tilt));
|
|
78
|
+
const sc = 1 - Math.min(0.06, Math.hypot(s.lean.x, s.lean.y) / 30000);
|
|
79
|
+
for (let k = 0; k < slots.length; k++) {
|
|
80
|
+
const el = tiles.current[k];
|
|
81
|
+
if (!el) continue;
|
|
82
|
+
const i = c0 + (k % cols);
|
|
83
|
+
const j = r0 + Math.floor(k / cols);
|
|
84
|
+
// Odd columns sit half a card lower, like a masonry wall.
|
|
85
|
+
const x = i * cw + s.ox;
|
|
86
|
+
const y = j * ch + s.oy + (((i % 2) + 2) % 2) * ch * 0.5;
|
|
87
|
+
el.style.transform = `translate3d(${x.toFixed(2)}px, ${y.toFixed(2)}px, 0) rotateX(${rx.toFixed(2)}deg) rotateY(${ry.toFixed(2)}deg) scale(${sc.toFixed(4)})`;
|
|
88
|
+
const key = `${i},${j}`;
|
|
89
|
+
if (slots[k].key !== key) {
|
|
90
|
+
slots[k].key = key;
|
|
91
|
+
const idx = ((((i * 5 + j * 3) % n) + n) % n);
|
|
92
|
+
el.dataset.index = String(idx);
|
|
93
|
+
const img = el.querySelector("img");
|
|
94
|
+
if (img && img.getAttribute("src") !== items[idx].image) img.setAttribute("src", items[idx].image);
|
|
95
|
+
const label = el.querySelector("[data-title]");
|
|
96
|
+
if (label) label.textContent = items[idx].title ?? "";
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const frame = (now) => {
|
|
102
|
+
raf = 0;
|
|
103
|
+
const o = live.current;
|
|
104
|
+
const dt = last ? Math.min(0.05, (now - last) / 1000) : 1 / 60;
|
|
105
|
+
last = now;
|
|
106
|
+
if (!s.drag) {
|
|
107
|
+
const k = Math.pow(o.friction, dt * 60);
|
|
108
|
+
s.vx *= k;
|
|
109
|
+
s.vy *= k;
|
|
110
|
+
// Ease back into the idle drift once the throw has died down.
|
|
111
|
+
if (!s.hover && !reduced && o.drift) {
|
|
112
|
+
s.vx += (-o.drift * 0.6 - s.vx) * (1 - Math.exp(-dt / 1.6)) * (Math.abs(s.vx) < 120 ? 1 : 0);
|
|
113
|
+
s.vy += (-o.drift - s.vy) * (1 - Math.exp(-dt / 1.6)) * (Math.abs(s.vy) < 120 ? 1 : 0);
|
|
114
|
+
}
|
|
115
|
+
s.ox += s.vx * dt;
|
|
116
|
+
s.oy += s.vy * dt;
|
|
117
|
+
}
|
|
118
|
+
const lx = s.drag ? s.drag.vx : s.vx;
|
|
119
|
+
const ly = s.drag ? s.drag.vy : s.vy;
|
|
120
|
+
s.lean.x += (lx - s.lean.x) * (1 - Math.exp(-dt / 0.12));
|
|
121
|
+
s.lean.y += (ly - s.lean.y) * (1 - Math.exp(-dt / 0.12));
|
|
122
|
+
paint();
|
|
123
|
+
const moving = s.drag || Math.abs(s.vx) + Math.abs(s.vy) > 1 || Math.abs(s.lean.x) + Math.abs(s.lean.y) > 1;
|
|
124
|
+
if (visible && (moving || (o.drift && !s.hover && !reduced))) raf = requestAnimationFrame(frame);
|
|
125
|
+
else last = 0;
|
|
126
|
+
};
|
|
127
|
+
const start = () => {
|
|
128
|
+
if (!raf && visible) raf = requestAnimationFrame(frame);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const onDown = (e) => {
|
|
132
|
+
if (e.button !== 0) return;
|
|
133
|
+
e.preventDefault();
|
|
134
|
+
s.drag = { x: e.clientX, y: e.clientY, ox: s.ox, oy: s.oy, t: performance.now(), vx: 0, vy: 0, moved: false, target: e.target.closest("[data-index]") };
|
|
135
|
+
el.setPointerCapture(e.pointerId);
|
|
136
|
+
s.vx = s.vy = 0;
|
|
137
|
+
start();
|
|
138
|
+
};
|
|
139
|
+
const onMove = (e) => {
|
|
140
|
+
const g = s.drag;
|
|
141
|
+
if (!g) return;
|
|
142
|
+
const now = performance.now();
|
|
143
|
+
const dx = e.clientX - g.x;
|
|
144
|
+
const dy = e.clientY - g.y;
|
|
145
|
+
if (Math.hypot(dx, dy) > 5) g.moved = true;
|
|
146
|
+
const nx = g.ox + dx;
|
|
147
|
+
const ny = g.oy + dy;
|
|
148
|
+
const span = Math.max(8, now - g.t) / 1000;
|
|
149
|
+
g.vx += ((nx - s.ox) / span - g.vx) * 0.4;
|
|
150
|
+
g.vy += ((ny - s.oy) / span - g.vy) * 0.4;
|
|
151
|
+
g.t = now;
|
|
152
|
+
s.ox = nx;
|
|
153
|
+
s.oy = ny;
|
|
154
|
+
start();
|
|
155
|
+
};
|
|
156
|
+
const onUp = (e) => {
|
|
157
|
+
const g = s.drag;
|
|
158
|
+
if (!g) return;
|
|
159
|
+
s.drag = null;
|
|
160
|
+
if (g.moved) {
|
|
161
|
+
// Fling: keep the release speed (capped) and let friction take it.
|
|
162
|
+
s.vx = Math.max(-3000, Math.min(3000, g.vx));
|
|
163
|
+
s.vy = Math.max(-3000, Math.min(3000, g.vy));
|
|
164
|
+
} else if (g.target && e.type === "pointerup") {
|
|
165
|
+
const idx = Number(g.target.dataset.index);
|
|
166
|
+
live.current.onItemClick?.(listRef.current[idx], idx);
|
|
167
|
+
}
|
|
168
|
+
start();
|
|
169
|
+
};
|
|
170
|
+
const onWheel = (e) => {
|
|
171
|
+
e.preventDefault();
|
|
172
|
+
s.vx = -e.deltaX * 6;
|
|
173
|
+
s.vy = -e.deltaY * 6;
|
|
174
|
+
start();
|
|
175
|
+
};
|
|
176
|
+
const onEnter = () => {
|
|
177
|
+
s.hover = true;
|
|
178
|
+
};
|
|
179
|
+
const onLeave = () => {
|
|
180
|
+
s.hover = false;
|
|
181
|
+
start();
|
|
182
|
+
};
|
|
183
|
+
el.addEventListener("pointerdown", onDown);
|
|
184
|
+
el.addEventListener("pointermove", onMove);
|
|
185
|
+
el.addEventListener("pointerup", onUp);
|
|
186
|
+
el.addEventListener("pointercancel", onUp);
|
|
187
|
+
el.addEventListener("pointerenter", onEnter);
|
|
188
|
+
el.addEventListener("pointerleave", onLeave);
|
|
189
|
+
el.addEventListener("wheel", onWheel, { passive: false });
|
|
190
|
+
const io = new IntersectionObserver(([entry]) => {
|
|
191
|
+
visible = entry.isIntersecting;
|
|
192
|
+
if (visible) start();
|
|
193
|
+
});
|
|
194
|
+
io.observe(el);
|
|
195
|
+
paint();
|
|
196
|
+
start();
|
|
197
|
+
|
|
198
|
+
return () => {
|
|
199
|
+
cancelAnimationFrame(raf);
|
|
200
|
+
io.disconnect();
|
|
201
|
+
el.removeEventListener("pointerdown", onDown);
|
|
202
|
+
el.removeEventListener("pointermove", onMove);
|
|
203
|
+
el.removeEventListener("pointerup", onUp);
|
|
204
|
+
el.removeEventListener("pointercancel", onUp);
|
|
205
|
+
el.removeEventListener("pointerenter", onEnter);
|
|
206
|
+
el.removeEventListener("pointerleave", onLeave);
|
|
207
|
+
el.removeEventListener("wheel", onWheel);
|
|
208
|
+
};
|
|
209
|
+
}, [pool]);
|
|
210
|
+
|
|
211
|
+
const { w, h } = geo.current;
|
|
212
|
+
return (
|
|
213
|
+
<div
|
|
214
|
+
ref={wrap}
|
|
215
|
+
className={`relative w-full cursor-grab touch-none select-none overflow-hidden active:cursor-grabbing ${className}`}
|
|
216
|
+
style={{ height, perspective: "1200px", maskImage: "radial-gradient(ellipse 75% 75% at 50% 50%, #000 55%, transparent 100%)", WebkitMaskImage: "radial-gradient(ellipse 75% 75% at 50% 50%, #000 55%, transparent 100%)" }}
|
|
217
|
+
>
|
|
218
|
+
{Array.from({ length: pool }, (_, k) => (
|
|
219
|
+
<div key={k} ref={(e) => (tiles.current[k] = e)} className="group absolute left-0 top-0 will-change-transform" style={{ width: w, height: h }}>
|
|
220
|
+
<div className="h-full w-full overflow-hidden rounded-2xl bg-white/5 shadow-[0_18px_40px_-22px_rgba(0,0,0,0.7)] ring-1 ring-white/10">
|
|
221
|
+
<img alt="" draggable={false} loading="lazy" decoding="async" className="h-full w-full object-cover transition-transform duration-500 ease-out group-hover:scale-[1.06]" />
|
|
222
|
+
</div>
|
|
223
|
+
{showTitles && <p data-title="" className="pointer-events-none absolute inset-x-3 bottom-3 truncate rounded-full bg-black/45 px-3 py-1 text-xs font-semibold text-white opacity-0 backdrop-blur transition-opacity duration-300 group-hover:opacity-100" />}
|
|
224
|
+
</div>
|
|
225
|
+
))}
|
|
226
|
+
</div>
|
|
227
|
+
);
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
export default InfiniteCanvas;
|