apex-ui-kit 2.10.0 → 2.12.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 +2 -3
- package/dist/ApexUI-Kit/templates/AnimatedTabs/AnimatedTabs.jsx +17 -3
- package/dist/ApexUI-Kit/templates/AuroraBackground/AuroraBackground.jsx +157 -35
- package/dist/ApexUI-Kit/templates/BluePrintBackground/BluePrintBackground.jsx +134 -145
- package/dist/ApexUI-Kit/templates/CardSwap3D/CardSwap3D.jsx +64 -40
- package/dist/ApexUI-Kit/templates/DotGlobe/DotGlobe.jsx +6 -3
- package/dist/ApexUI-Kit/templates/FlipClock/FlipClock.jsx +1 -1
- package/dist/ApexUI-Kit/templates/Folder/Folder.jsx +58 -36
- package/dist/ApexUI-Kit/templates/GooeyNav/GooeyNav.jsx +16 -2
- package/dist/ApexUI-Kit/templates/ImageAccordion/ImageAccordion.jsx +20 -10
- package/dist/ApexUI-Kit/templates/InfiniteCanvas/InfiniteCanvas.jsx +1 -1
- package/dist/ApexUI-Kit/templates/IsoStack/IsoStack.jsx +28 -10
- package/dist/ApexUI-Kit/templates/LuminousParticleOcean/LuminousParticleOcean.jsx +8 -6
- package/dist/ApexUI-Kit/templates/MeteorCard/MeteorCard.jsx +5 -5
- package/dist/ApexUI-Kit/templates/NavMenu/NavMenu.jsx +175 -158
- package/dist/ApexUI-Kit/templates/OrbitIcons/OrbitIcons.jsx +1 -1
- package/dist/ApexUI-Kit/templates/ParallaxCard/ParallaxCard.jsx +34 -33
- package/dist/ApexUI-Kit/templates/SmokeBackground/SmokeBackground.jsx +237 -235
- package/dist/ApexUI-Kit/templates/Testimonials/Testimonials.jsx +140 -120
- package/dist/cli/index.js +40 -21
- package/package.json +8 -2
- package/dist/ApexUI-Kit/templates/SmartGridCard/SmartGridCard.jsx +0 -243
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import React, { useEffect, useState } from "react";
|
|
1
|
+
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
2
2
|
import { motion } from "framer-motion";
|
|
3
3
|
import { Code2, Layers, Sparkles, Zap } from "lucide-react";
|
|
4
4
|
|
|
5
5
|
/*
|
|
6
|
-
* Card Swap 3D: cards stacked
|
|
7
|
-
* timer the front card drops away and
|
|
8
|
-
* while the rest step forward. Pauses while hovered;
|
|
6
|
+
* Card Swap 3D: cards stacked in real 3D, turned toward the viewer and
|
|
7
|
+
* receding into the distance. On a timer the front card drops away and
|
|
8
|
+
* returns to the back while the rest step forward. Pauses while hovered;
|
|
9
|
+
* click to swap now. The whole stack scales down to fit narrow containers.
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
const DEFAULT_CARDS = [
|
|
@@ -15,12 +16,19 @@ const DEFAULT_CARDS = [
|
|
|
15
16
|
{ icon: Zap, title: "Fast", body: "Transforms and opacity only, 60fps on phones." },
|
|
16
17
|
];
|
|
17
18
|
|
|
18
|
-
const CardSwap3D = ({ cards = DEFAULT_CARDS, delay = 3200, distanceX = 3.6, distanceY = 3.2, skew =
|
|
19
|
+
const CardSwap3D = ({ cards = DEFAULT_CARDS, delay = 3200, distanceX = 3.6, distanceY = 3.2, depth = 70, tilt = 18, skew = 0, pauseOnHover = true, accentColor = "#a3e635", theme = "dark", width = 18, className = "" }) => {
|
|
19
20
|
// order[0] is the card in front.
|
|
20
21
|
const [order, setOrder] = useState(() => cards.map((_, i) => i));
|
|
21
22
|
const [dropping, setDropping] = useState(null);
|
|
22
23
|
const [paused, setPaused] = useState(false);
|
|
24
|
+
const [fit, setFit] = useState(1);
|
|
25
|
+
const outer = useRef(null);
|
|
23
26
|
const dark = theme !== "light";
|
|
27
|
+
const n = cards.length;
|
|
28
|
+
// Footprint of the whole stack in rem (card plus the steps behind it).
|
|
29
|
+
// (+5: the 3D turn makes the cards project a little wider than they are.)
|
|
30
|
+
const footW = width + distanceX * (n - 1) + 5;
|
|
31
|
+
const footH = width * 0.68 + distanceY * (n - 1) + 2;
|
|
24
32
|
|
|
25
33
|
const swap = () => {
|
|
26
34
|
if (dropping !== null) return;
|
|
@@ -37,42 +45,58 @@ const CardSwap3D = ({ cards = DEFAULT_CARDS, delay = 3200, distanceX = 3.6, dist
|
|
|
37
45
|
return () => clearInterval(id);
|
|
38
46
|
});
|
|
39
47
|
|
|
48
|
+
// Scale the stack down when its container is narrower than its footprint.
|
|
49
|
+
useLayoutEffect(() => {
|
|
50
|
+
const el = outer.current?.parentElement;
|
|
51
|
+
if (!el) return undefined;
|
|
52
|
+
const ro = new ResizeObserver(() => {
|
|
53
|
+
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
|
54
|
+
setFit(Math.min(1, el.clientWidth / (footW * rem)));
|
|
55
|
+
});
|
|
56
|
+
ro.observe(el);
|
|
57
|
+
return () => ro.disconnect();
|
|
58
|
+
}, [footW]);
|
|
59
|
+
|
|
40
60
|
return (
|
|
41
|
-
<div
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
<
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
61
|
+
<div ref={outer} className={`relative mx-auto ${className}`} style={{ width: `${footW * fit}rem`, height: `${footH * fit}rem` }}>
|
|
62
|
+
<div
|
|
63
|
+
className="absolute left-0 top-0 origin-top-left [perspective:1200px]"
|
|
64
|
+
style={{ width: `${footW}rem`, height: `${footH}rem`, transform: `scale(${fit})` }}
|
|
65
|
+
onPointerEnter={() => pauseOnHover && setPaused(true)}
|
|
66
|
+
onPointerLeave={() => setPaused(false)}
|
|
67
|
+
onClick={swap}
|
|
68
|
+
>
|
|
69
|
+
<div className="absolute [transform-style:preserve-3d]" style={{ left: "2.5rem", bottom: "1rem", width: `${width}rem`, height: `${width * 0.68}rem`, transform: `rotateY(${-tilt}deg) rotateX(${tilt * 0.35}deg)` }}>
|
|
70
|
+
{cards.map((card, i) => {
|
|
71
|
+
const slot = order.indexOf(i);
|
|
72
|
+
const isDropping = dropping === i;
|
|
73
|
+
const Icon = card.icon ?? Sparkles;
|
|
74
|
+
return (
|
|
75
|
+
<motion.div
|
|
76
|
+
key={i}
|
|
77
|
+
className={`absolute inset-0 cursor-pointer overflow-hidden rounded-2xl border p-6 ${dark ? "border-white/10 bg-neutral-950 text-white" : "border-black/10 bg-white text-neutral-900"}`}
|
|
78
|
+
style={{ zIndex: isDropping ? 60 : n - slot, boxShadow: "0 30px 60px -28px rgba(0,0,0,0.65)" }}
|
|
79
|
+
initial={false}
|
|
80
|
+
animate={
|
|
81
|
+
isDropping
|
|
82
|
+
? { x: "-2rem", y: "16rem", z: 120, rotateZ: -8, skewY: skew, opacity: 0 }
|
|
83
|
+
: { x: `${slot * distanceX}rem`, y: `${-slot * distanceY}rem`, z: -slot * depth, rotateZ: 0, skewY: skew, opacity: slot > 4 ? 0 : 1 - slot * 0.12 }
|
|
84
|
+
}
|
|
85
|
+
transition={isDropping ? { duration: 0.5, ease: [0.55, 0, 0.8, 0.2] } : { type: "spring", stiffness: 150, damping: 21 }}
|
|
86
|
+
>
|
|
87
|
+
<div className="flex items-center gap-2.5">
|
|
88
|
+
<span className="grid h-9 w-9 place-items-center rounded-xl text-black" style={{ background: accentColor }}>
|
|
89
|
+
<Icon className="h-[18px] w-[18px]" />
|
|
90
|
+
</span>
|
|
91
|
+
<p className="text-lg font-semibold">{card.title}</p>
|
|
92
|
+
</div>
|
|
93
|
+
<p className={`mt-3 text-[0.92rem] leading-relaxed ${dark ? "text-white/60" : "text-neutral-500"}`}>{card.body}</p>
|
|
94
|
+
<div className="pointer-events-none absolute -bottom-10 -right-10 h-32 w-32 rounded-full opacity-20 blur-2xl" style={{ background: accentColor }} />
|
|
95
|
+
</motion.div>
|
|
96
|
+
);
|
|
97
|
+
})}
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
76
100
|
</div>
|
|
77
101
|
);
|
|
78
102
|
};
|
|
@@ -49,6 +49,8 @@ const DEFAULT_ARCS = [
|
|
|
49
49
|
|
|
50
50
|
const rad = (d) => (d * Math.PI) / 180;
|
|
51
51
|
const toVec = (lat, lon) => [Math.cos(rad(lat)) * Math.sin(rad(lon)), Math.sin(rad(lat)), Math.cos(rad(lat)) * Math.cos(rad(lon))];
|
|
52
|
+
// Land dot directions never change, so work them out once instead of every frame.
|
|
53
|
+
const landVecs = landDots.map(([lat, lon]) => toVec(lat, lon));
|
|
52
54
|
|
|
53
55
|
// Spherical interpolation between two unit vectors.
|
|
54
56
|
const slerp = (a, b, t) => {
|
|
@@ -141,9 +143,10 @@ const DotGlobe = ({
|
|
|
141
143
|
// Land dots: front bright, back faint.
|
|
142
144
|
const dr = Math.max(0.8, R / 190);
|
|
143
145
|
ctx.fillStyle = o.dotColor;
|
|
144
|
-
for (let i = 0; i <
|
|
145
|
-
const [
|
|
146
|
-
|
|
146
|
+
for (let i = 0; i < landVecs.length; i++) {
|
|
147
|
+
const [x, y, z] = project(landVecs[i], R, c);
|
|
148
|
+
// Dots well behind the globe are nearly invisible; skip them.
|
|
149
|
+
if (z < -0.25) continue;
|
|
147
150
|
ctx.globalAlpha = z > 0 ? 0.35 + 0.65 * z : 0.06;
|
|
148
151
|
ctx.fillRect(x - dr, y - dr, dr * 2, dr * 2);
|
|
149
152
|
}
|
|
@@ -94,7 +94,7 @@ const FlipClock = ({ mode = "seconds", target, seconds = 3 * 86400 + 4 * 3600 +
|
|
|
94
94
|
// The clock is sized in em; on narrow screens the font shrinks with the
|
|
95
95
|
// container (cqw) so every unit fits instead of being cut off. The min
|
|
96
96
|
// width keeps it from collapsing inside a shrink-to-fit parent.
|
|
97
|
-
<div className="flex w-full justify-center" style={{ containerType: "inline-size", minWidth: `min(${(size * parts.length * 2.55).toFixed(2)}rem,
|
|
97
|
+
<div className="flex w-full justify-center" style={{ containerType: "inline-size", minWidth: `min(${(size * parts.length * 2.55).toFixed(2)}rem, 100%)` }}>
|
|
98
98
|
<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)` }}>
|
|
99
99
|
{parts.map(([label, value], i) => (
|
|
100
100
|
<React.Fragment key={label}>
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import React, { useState } from "react";
|
|
2
|
-
import { motion } from "framer-motion";
|
|
1
|
+
import React, { useLayoutEffect, useRef, useState } from "react";
|
|
2
|
+
import { motion, useMotionValue, useSpring } from "framer-motion";
|
|
3
3
|
|
|
4
4
|
/*
|
|
5
5
|
* Folder: a little folder that opens on click — the front flap tilts down
|
|
6
6
|
* and up to three papers fan out of it. While open, each paper leans toward
|
|
7
|
-
* the pointer a little. Click again to tuck everything back in.
|
|
7
|
+
* the pointer a little. Click again to tuck everything back in. It shrinks
|
|
8
|
+
* to fit narrow containers so the open fan never gets cut off.
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
const DEFAULT_ITEMS = [
|
|
@@ -32,23 +33,66 @@ const SPOTS = [
|
|
|
32
33
|
{ x: 0, y: -4.2, r: 3 },
|
|
33
34
|
{ x: 3.9, y: -3.3, r: 15 },
|
|
34
35
|
];
|
|
36
|
+
// Width of the open fan in em (papers plus their spread), used to fit the container.
|
|
37
|
+
const FAN_WIDTH = 15.5;
|
|
38
|
+
|
|
39
|
+
// One paper. Its lean follows the pointer through motion values, so moving the
|
|
40
|
+
// mouse never re-renders the folder.
|
|
41
|
+
function Paper({ children, spot, index, open }) {
|
|
42
|
+
const lx = useMotionValue(0);
|
|
43
|
+
const ly = useMotionValue(0);
|
|
44
|
+
const x = useSpring(lx, { stiffness: 260, damping: 20 });
|
|
45
|
+
const y = useSpring(ly, { stiffness: 260, damping: 20 });
|
|
46
|
+
return (
|
|
47
|
+
<motion.span
|
|
48
|
+
className="absolute bottom-[10%] left-[10%] h-[80%] w-[80%]"
|
|
49
|
+
initial={false}
|
|
50
|
+
animate={open ? { x: `${spot.x}em`, y: `${spot.y}em`, rotate: spot.r, scale: 1 } : { x: "0em", y: `${-0.3 - index * 0.2}em`, rotate: 0, scale: 0.92 - (2 - index) * 0.03 }}
|
|
51
|
+
transition={{ type: "spring", stiffness: 300, damping: 22, delay: open ? index * 0.04 : 0 }}
|
|
52
|
+
onPointerMove={(e) => {
|
|
53
|
+
if (!open) return;
|
|
54
|
+
const r = e.currentTarget.getBoundingClientRect();
|
|
55
|
+
lx.set(((e.clientX - (r.left + r.width / 2)) / r.width) * 10);
|
|
56
|
+
ly.set(((e.clientY - (r.top + r.height / 2)) / r.height) * 10);
|
|
57
|
+
}}
|
|
58
|
+
onPointerLeave={() => {
|
|
59
|
+
lx.set(0);
|
|
60
|
+
ly.set(0);
|
|
61
|
+
}}
|
|
62
|
+
>
|
|
63
|
+
<motion.span className="block h-full w-full overflow-hidden rounded-[0.4em] bg-white text-left shadow-md" style={{ x, y }}>
|
|
64
|
+
{children}
|
|
65
|
+
</motion.span>
|
|
66
|
+
</motion.span>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
35
69
|
|
|
36
70
|
const Folder = ({ color = "#a3e635", size = 1, items = DEFAULT_ITEMS, label = "Projects", className = "" }) => {
|
|
37
71
|
const [open, setOpen] = useState(false);
|
|
38
|
-
const [
|
|
72
|
+
const [fit, setFit] = useState(size);
|
|
73
|
+
const root = useRef(null);
|
|
39
74
|
const papers = items.slice(0, 3);
|
|
40
75
|
const back = `color-mix(in srgb, ${color} 75%, #000)`;
|
|
41
76
|
|
|
77
|
+
// Shrink when the container can't hold the open fan.
|
|
78
|
+
useLayoutEffect(() => {
|
|
79
|
+
const el = root.current?.parentElement;
|
|
80
|
+
if (!el) return undefined;
|
|
81
|
+
const ro = new ResizeObserver(() => {
|
|
82
|
+
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
|
83
|
+
setFit(Math.min(size, el.clientWidth / (FAN_WIDTH * rem)));
|
|
84
|
+
});
|
|
85
|
+
ro.observe(el);
|
|
86
|
+
return () => ro.disconnect();
|
|
87
|
+
}, [size]);
|
|
88
|
+
|
|
42
89
|
return (
|
|
43
|
-
<div className={`flex flex-col items-center gap-4 ${className}`} style={{ fontSize: `${
|
|
90
|
+
<div ref={root} className={`flex flex-col items-center gap-4 pt-[4.5em] ${className}`} style={{ fontSize: `${fit}rem` }}>
|
|
44
91
|
<button
|
|
45
92
|
type="button"
|
|
46
93
|
aria-expanded={open}
|
|
47
94
|
aria-label={`${label} folder`}
|
|
48
|
-
onClick={() =>
|
|
49
|
-
setOpen((o) => !o);
|
|
50
|
-
setLean([]);
|
|
51
|
-
}}
|
|
95
|
+
onClick={() => setOpen((o) => !o)}
|
|
52
96
|
className="group relative h-[5.5em] w-[7em] outline-none transition-transform duration-200 hover:-translate-y-1"
|
|
53
97
|
style={{ perspective: "600px" }}
|
|
54
98
|
>
|
|
@@ -56,33 +100,11 @@ const Folder = ({ color = "#a3e635", size = 1, items = DEFAULT_ITEMS, label = "P
|
|
|
56
100
|
<span className="absolute inset-0 rounded-[0.6em] rounded-tl-none" style={{ background: back }} />
|
|
57
101
|
<span className="absolute -top-[0.7em] left-0 h-[0.9em] w-[2.6em] rounded-t-[0.45em]" style={{ background: back }} />
|
|
58
102
|
|
|
59
|
-
{papers.map((p, i) =>
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
key={i}
|
|
65
|
-
className="absolute bottom-[10%] left-[10%] h-[80%] w-[80%] overflow-hidden rounded-[0.4em] bg-white text-left shadow-md"
|
|
66
|
-
initial={false}
|
|
67
|
-
animate={open ? { x: `${spot.x + l.x}em`, y: `${spot.y + l.y}em`, rotate: spot.r, scale: 1 } : { x: "0em", y: `${-0.3 - i * 0.2}em`, rotate: 0, scale: 0.92 - (2 - i) * 0.03 }}
|
|
68
|
-
transition={{ type: "spring", stiffness: 300, damping: 22, delay: open ? i * 0.04 : 0 }}
|
|
69
|
-
onPointerMove={(e) => {
|
|
70
|
-
if (!open) return;
|
|
71
|
-
const r = e.currentTarget.getBoundingClientRect();
|
|
72
|
-
const next = [...lean];
|
|
73
|
-
next[i] = { x: ((e.clientX - (r.left + r.width / 2)) / r.width) * 0.8, y: ((e.clientY - (r.top + r.height / 2)) / r.height) * 0.8 };
|
|
74
|
-
setLean(next);
|
|
75
|
-
}}
|
|
76
|
-
onPointerLeave={() => {
|
|
77
|
-
const next = [...lean];
|
|
78
|
-
next[i] = { x: 0, y: 0 };
|
|
79
|
-
setLean(next);
|
|
80
|
-
}}
|
|
81
|
-
>
|
|
82
|
-
{p}
|
|
83
|
-
</motion.span>
|
|
84
|
-
);
|
|
85
|
-
})}
|
|
103
|
+
{papers.map((p, i) => (
|
|
104
|
+
<Paper key={i} index={i} open={open} spot={SPOTS[papers.length === 1 ? 1 : papers.length === 2 ? i * 2 : i]}>
|
|
105
|
+
{p}
|
|
106
|
+
</Paper>
|
|
107
|
+
))}
|
|
86
108
|
|
|
87
109
|
{/* Front flap */}
|
|
88
110
|
<motion.span
|
|
@@ -123,6 +123,20 @@ const GooeyNav = ({ items = DEFAULT_ITEMS, initialActive = 0, onChange, accentCo
|
|
|
123
123
|
const gooRef = useRef(goo);
|
|
124
124
|
const glowRef = useRef(theme === "light" ? 0 : 0.22);
|
|
125
125
|
const dark = theme !== "light";
|
|
126
|
+
// Sized from the space it sits in (not the screen), so it fits narrow columns too.
|
|
127
|
+
const [size, setSize] = useState("lg");
|
|
128
|
+
|
|
129
|
+
useLayoutEffect(() => {
|
|
130
|
+
const el = bar.current?.parentElement?.parentElement;
|
|
131
|
+
if (!el) return undefined;
|
|
132
|
+
const ro = new ResizeObserver(() => {
|
|
133
|
+
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
|
134
|
+
const w = el.clientWidth / rem;
|
|
135
|
+
setSize(w < 22 ? "xs" : w < 34 ? "sm" : "lg");
|
|
136
|
+
});
|
|
137
|
+
ro.observe(el);
|
|
138
|
+
return () => ro.disconnect();
|
|
139
|
+
}, []);
|
|
126
140
|
|
|
127
141
|
useEffect(() => {
|
|
128
142
|
color.current = hexToRgb(accentColor);
|
|
@@ -289,7 +303,7 @@ const GooeyNav = ({ items = DEFAULT_ITEMS, initialActive = 0, onChange, accentCo
|
|
|
289
303
|
|
|
290
304
|
return (
|
|
291
305
|
<nav className={`relative inline-flex max-w-full ${className}`} aria-label="Main">
|
|
292
|
-
<div ref={bar} className={`relative flex items-center gap-0 rounded-full
|
|
306
|
+
<div ref={bar} className={`relative flex items-center gap-0 rounded-full ${size === "lg" ? "gap-0.5 p-1.5" : "p-1"} ${dark ? "bg-neutral-950 ring-1 ring-white/10" : "bg-white shadow-sm ring-1 ring-black/10"}`}>
|
|
293
307
|
<canvas ref={canvas} aria-hidden="true" className="pointer-events-none absolute" style={{ left: -PAD, top: -PAD, display: fallback ? "none" : "block" }} />
|
|
294
308
|
{fallback && <span ref={fallbackPill} aria-hidden="true" className="pointer-events-none absolute rounded-full" style={{ background: accentColor }} />}
|
|
295
309
|
{items.map((item, i) => (
|
|
@@ -299,7 +313,7 @@ const GooeyNav = ({ items = DEFAULT_ITEMS, initialActive = 0, onChange, accentCo
|
|
|
299
313
|
href={item.href}
|
|
300
314
|
onClick={(e) => select(i, e)}
|
|
301
315
|
aria-current={i === active ? "page" : undefined}
|
|
302
|
-
className={`relative z-[1] shrink-0 rounded-full
|
|
316
|
+
className={`relative z-[1] shrink-0 whitespace-nowrap rounded-full font-medium outline-none transition-colors duration-300 focus-visible:ring-2 focus-visible:ring-current/30 ${{ lg: "px-5 py-2.5 text-[0.95rem]", sm: "px-2.5 py-2 text-[0.82rem]", xs: "px-2 py-2 text-[0.76rem]" }[size]} ${i === active ? "text-neutral-950" : dark ? "text-white/65 hover:text-white" : "text-neutral-500 hover:text-neutral-900"}`}
|
|
303
317
|
>
|
|
304
318
|
{item.label}
|
|
305
319
|
</a>
|
|
@@ -24,6 +24,7 @@ const ImageAccordion = ({ items = DEFAULT_ITEMS, defaultActive = 0, autoPlay = 0
|
|
|
24
24
|
const list = items.length ? items : DEFAULT_ITEMS;
|
|
25
25
|
const [active, setActive] = useState(Math.min(defaultActive, list.length - 1));
|
|
26
26
|
const [vertical, setVertical] = useState(false);
|
|
27
|
+
const [panelW, setPanelW] = useState(416);
|
|
27
28
|
const root = useRef(null);
|
|
28
29
|
const hovering = useRef(false);
|
|
29
30
|
|
|
@@ -34,13 +35,20 @@ const ImageAccordion = ({ items = DEFAULT_ITEMS, defaultActive = 0, autoPlay = 0
|
|
|
34
35
|
});
|
|
35
36
|
};
|
|
36
37
|
|
|
37
|
-
// Stack vertically when the box is narrow (a container check, not the viewport)
|
|
38
|
+
// Stack vertically when the box is narrow (a container check, not the viewport),
|
|
39
|
+
// and work out how wide the open panel ends up so its text never overflows it.
|
|
38
40
|
useEffect(() => {
|
|
39
41
|
const el = root.current;
|
|
40
|
-
const ro = new ResizeObserver(([entry]) =>
|
|
42
|
+
const ro = new ResizeObserver(([entry]) => {
|
|
43
|
+
const w = entry.contentRect.width;
|
|
44
|
+
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
|
45
|
+
const stacked = w < 520;
|
|
46
|
+
setVertical(stacked);
|
|
47
|
+
setPanelW(stacked ? w : ((w - gap * rem * (list.length - 1)) * openRatio) / (openRatio + list.length - 1));
|
|
48
|
+
});
|
|
41
49
|
ro.observe(el);
|
|
42
50
|
return () => ro.disconnect();
|
|
43
|
-
}, []);
|
|
51
|
+
}, [gap, openRatio, list.length]);
|
|
44
52
|
|
|
45
53
|
useEffect(() => {
|
|
46
54
|
if (!autoPlay) return undefined;
|
|
@@ -80,14 +88,16 @@ const ImageAccordion = ({ items = DEFAULT_ITEMS, defaultActive = 0, autoPlay = 0
|
|
|
80
88
|
alt=""
|
|
81
89
|
draggable={false}
|
|
82
90
|
className="absolute inset-0 h-full w-full object-cover"
|
|
83
|
-
style={{ transform: on ? "scale(1)" : "scale(1.12)",
|
|
91
|
+
style={{ transform: on ? "scale(1)" : "scale(1.12)", transition: `transform ${duration * 1.4}ms ${EASE}`, willChange: "transform" }}
|
|
84
92
|
/>
|
|
93
|
+
{/* Dim folded panels with an overlay (cheap) instead of a filter on the image (repaints every frame). */}
|
|
94
|
+
<span aria-hidden="true" className="absolute inset-0 bg-black" style={{ opacity: on ? 0 : 0.32, transition: `opacity ${duration}ms ${EASE}` }} />
|
|
85
95
|
<span aria-hidden="true" className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent" />
|
|
86
96
|
|
|
87
97
|
{/* Folded: the title runs along the strip. */}
|
|
88
98
|
<span
|
|
89
99
|
aria-hidden="true"
|
|
90
|
-
className="absolute whitespace-nowrap text-sm font-semibold tracking-wide"
|
|
100
|
+
className="absolute whitespace-nowrap text-sm font-semibold tracking-wide [backface-visibility:hidden]"
|
|
91
101
|
style={{
|
|
92
102
|
...(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
103
|
opacity: on ? 0 : 0.9,
|
|
@@ -97,14 +107,14 @@ const ImageAccordion = ({ items = DEFAULT_ITEMS, defaultActive = 0, autoPlay = 0
|
|
|
97
107
|
{item.title}
|
|
98
108
|
</span>
|
|
99
109
|
|
|
100
|
-
{/* Open: title and text rise in once the panel has room. */}
|
|
110
|
+
{/* Open: title and text rise in once the panel has room. A fixed width keeps them from re-wrapping as it grows. */}
|
|
101
111
|
<span
|
|
102
|
-
className="absolute
|
|
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` }}
|
|
112
|
+
className="absolute bottom-0 left-0 block p-[clamp(1rem,3cqw,1.75rem)] [backface-visibility:hidden]"
|
|
113
|
+
style={{ width: `min(26rem, ${Math.round(panelW)}px)`, 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
114
|
>
|
|
105
115
|
<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-[
|
|
107
|
-
{item.description && <span className="mt-1 block max-w-
|
|
116
|
+
<span className="mt-1 block truncate text-[1.6rem] font-bold leading-tight">{item.title}</span>
|
|
117
|
+
{item.description && <span className="mt-1 block max-w-[20rem] text-sm text-white/75">{item.description}</span>}
|
|
108
118
|
</span>
|
|
109
119
|
</button>
|
|
110
120
|
);
|
|
@@ -217,7 +217,7 @@ const InfiniteCanvas = ({ items = DEFAULT_ITEMS, cardWidth = 14, aspectRatio = 0
|
|
|
217
217
|
>
|
|
218
218
|
{Array.from({ length: pool }, (_, k) => (
|
|
219
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
|
|
220
|
+
<div className="h-full w-full overflow-hidden rounded-2xl bg-white/5 ring-1 ring-white/10">
|
|
221
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
222
|
</div>
|
|
223
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" />}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import React, { useState } from "react";
|
|
1
|
+
import React, { useLayoutEffect, useRef, useState } from "react";
|
|
2
2
|
import { motion } from "framer-motion";
|
|
3
3
|
import { Cloud, Database, Layout, Server, Shield } from "lucide-react";
|
|
4
4
|
|
|
5
5
|
/*
|
|
6
6
|
* Iso Stack: layers drawn in isometric 3D, stacked like a sandwich. Hover
|
|
7
7
|
* (or tap) and the stack fans open so each layer's label shows; hover one
|
|
8
|
-
* layer to lift it out. Great for architecture or feature diagrams.
|
|
8
|
+
* layer to lift it out. Great for architecture or feature diagrams. The stack
|
|
9
|
+
* scales to fit narrow containers, and labels move below it on phones.
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
const DEFAULT_LAYERS = [
|
|
@@ -19,13 +20,30 @@ const DEFAULT_LAYERS = [
|
|
|
19
20
|
const IsoStack = ({ layers = DEFAULT_LAYERS, size = 13, gap = 1.6, theme = "dark", className = "" }) => {
|
|
20
21
|
const [open, setOpen] = useState(false);
|
|
21
22
|
const [hover, setHover] = useState(-1);
|
|
23
|
+
const [fit, setFit] = useState(1);
|
|
24
|
+
const [narrow, setNarrow] = useState(false);
|
|
25
|
+
const root = useRef(null);
|
|
22
26
|
const dark = theme !== "light";
|
|
23
27
|
const n = layers.length;
|
|
24
28
|
|
|
29
|
+
// The rotated stack is about 1.45x its size wide; scale it down to fit.
|
|
30
|
+
useLayoutEffect(() => {
|
|
31
|
+
const el = root.current;
|
|
32
|
+
const ro = new ResizeObserver(() => {
|
|
33
|
+
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
|
34
|
+
const w = el.clientWidth;
|
|
35
|
+
setNarrow(w < 560);
|
|
36
|
+
setFit(Math.min(1, (w - (w < 560 ? 16 : 200)) / (size * 1.45 * rem)));
|
|
37
|
+
});
|
|
38
|
+
ro.observe(el);
|
|
39
|
+
return () => ro.disconnect();
|
|
40
|
+
}, [size]);
|
|
41
|
+
|
|
25
42
|
return (
|
|
26
43
|
<div
|
|
27
|
-
|
|
28
|
-
|
|
44
|
+
ref={root}
|
|
45
|
+
className={`relative flex flex-col items-center justify-center ${className}`}
|
|
46
|
+
style={{ minHeight: `${(size * 0.6 + n * gap * 1.6 + 6) * fit}rem`, width: "100%" }}
|
|
29
47
|
onPointerEnter={(e) => e.pointerType === "mouse" && setOpen(true)}
|
|
30
48
|
onPointerLeave={() => {
|
|
31
49
|
setOpen(false);
|
|
@@ -34,7 +52,7 @@ const IsoStack = ({ layers = DEFAULT_LAYERS, size = 13, gap = 1.6, theme = "dark
|
|
|
34
52
|
onClick={() => setOpen((o) => !o)}
|
|
35
53
|
>
|
|
36
54
|
{/* Shifted down so the fully opened stack stays inside the frame. */}
|
|
37
|
-
<div className="relative" style={{ width: `${size}rem`, height: `${size}rem`, marginTop: `${n * gap * 0.9}rem`, transform:
|
|
55
|
+
<div className="relative" style={{ width: `${size}rem`, height: `${size}rem`, marginTop: `${n * gap * 0.9 * fit}rem`, marginBottom: `${-size * (1 - fit) * 0.6}rem`, transform: `scale(${fit}) rotateX(58deg) rotateZ(-45deg)`, transformStyle: "preserve-3d" }}>
|
|
38
56
|
{layers.map((layer, i) => {
|
|
39
57
|
const Icon = layer.icon;
|
|
40
58
|
const fromTop = i;
|
|
@@ -42,12 +60,12 @@ const IsoStack = ({ layers = DEFAULT_LAYERS, size = 13, gap = 1.6, theme = "dark
|
|
|
42
60
|
return (
|
|
43
61
|
<motion.div
|
|
44
62
|
key={layer.label}
|
|
45
|
-
className="absolute inset-0 rounded-2xl border"
|
|
63
|
+
className="absolute inset-0 rounded-2xl border will-change-transform"
|
|
46
64
|
style={{
|
|
47
65
|
zIndex: n - fromTop,
|
|
48
66
|
background: dark ? `linear-gradient(135deg, color-mix(in srgb, ${layer.color} 22%, #0a0a0a), #0a0a0a)` : `linear-gradient(135deg, color-mix(in srgb, ${layer.color} 30%, #fff), #fff)`,
|
|
49
67
|
borderColor: `color-mix(in srgb, ${layer.color} 60%, transparent)`,
|
|
50
|
-
boxShadow: `0 0 0 1px ${layer.color}22, -
|
|
68
|
+
boxShadow: `0 0 0 1px ${layer.color}22, -10px 10px 16px -8px rgba(0,0,0,${dark ? 0.55 : 0.2})`,
|
|
51
69
|
}}
|
|
52
70
|
animate={{ z: `${lift}rem` }}
|
|
53
71
|
transition={{ type: "spring", stiffness: 200, damping: 22, delay: open ? (n - fromTop) * 0.03 : 0 }}
|
|
@@ -64,12 +82,12 @@ const IsoStack = ({ layers = DEFAULT_LAYERS, size = 13, gap = 1.6, theme = "dark
|
|
|
64
82
|
</div>
|
|
65
83
|
|
|
66
84
|
{/* Labels sit outside the 3D plane so they stay readable. */}
|
|
67
|
-
<div className=
|
|
85
|
+
<div className={`pointer-events-none flex gap-2 ${narrow ? "mt-4 flex-wrap justify-center px-2" : "absolute right-6 top-1/2 -translate-y-1/2 flex-col"}`}>
|
|
68
86
|
{layers.map((layer, i) => (
|
|
69
87
|
<motion.div
|
|
70
88
|
key={layer.label}
|
|
71
|
-
className={`rounded-lg border px-2.5 py-1.5 text-
|
|
72
|
-
animate={{ opacity: open ? 1 : 0, x: open ? 0 : 12, scale: hover === i ? 1.05 : 1 }}
|
|
89
|
+
className={`rounded-lg border px-2.5 py-1.5 ${narrow ? "text-center" : "text-right"} ${dark ? "border-white/10 bg-neutral-950/85 text-white" : "border-black/10 bg-white/90 text-neutral-900"}`}
|
|
90
|
+
animate={{ opacity: open || narrow ? 1 : 0, x: open || narrow ? 0 : 12, scale: hover === i ? 1.05 : 1 }}
|
|
73
91
|
transition={{ delay: open ? i * 0.05 : 0 }}
|
|
74
92
|
>
|
|
75
93
|
<p className="text-[0.78rem] font-semibold" style={{ color: hover === i ? layer.color : undefined }}>
|
|
@@ -39,11 +39,12 @@ function FlowingSpheres({ count, sep, color, pushStrength }) {
|
|
|
39
39
|
const mouseZWorld = -mouse.y * (count * sep / 4);
|
|
40
40
|
|
|
41
41
|
// Animate each sphere's y-position and update its matrix
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
const timeFactor = Math.sin(elapsedTime * 0.5);
|
|
43
|
+
const mesh = instancedMeshRef.current;
|
|
44
|
+
for (let i = 0; i < particles.length; i++) {
|
|
45
|
+
const { x, z } = particles[i];
|
|
44
46
|
|
|
45
47
|
// --- 1. Wave Animation ---
|
|
46
|
-
const timeFactor = Math.sin(elapsedTime * 0.5);
|
|
47
48
|
const wave1 = Math.sin(x * 0.15 + timeFactor * Math.PI);
|
|
48
49
|
const wave2 = Math.sin(z * 1 + timeFactor * Math.PI);
|
|
49
50
|
let y = (wave1 + wave2) * 7;
|
|
@@ -63,8 +64,8 @@ function FlowingSpheres({ count, sep, color, pushStrength }) {
|
|
|
63
64
|
dummy.position.set(x, y, z);
|
|
64
65
|
dummy.updateMatrix();
|
|
65
66
|
|
|
66
|
-
|
|
67
|
-
}
|
|
67
|
+
mesh.setMatrixAt(i, dummy.matrix);
|
|
68
|
+
}
|
|
68
69
|
|
|
69
70
|
instancedMeshRef.current.instanceMatrix.needsUpdate = true;
|
|
70
71
|
});
|
|
@@ -75,7 +76,8 @@ function FlowingSpheres({ count, sep, color, pushStrength }) {
|
|
|
75
76
|
ref={instancedMeshRef}
|
|
76
77
|
args={[null, null, count * count]}
|
|
77
78
|
>
|
|
78
|
-
|
|
79
|
+
{/* 8x6 segments look the same as 19x19 at this size, with ~7x fewer triangles. */}
|
|
80
|
+
<sphereGeometry args={[0.2, 8, 6]} />
|
|
79
81
|
<meshStandardMaterial
|
|
80
82
|
color={color}
|
|
81
83
|
emissive={color}
|
|
@@ -37,12 +37,12 @@ const Particles = ({ number = 50, color = '#ffffff', speed = 1 }) => {
|
|
|
37
37
|
};
|
|
38
38
|
|
|
39
39
|
// Main MeteorCard Component
|
|
40
|
-
//
|
|
40
|
+
// Cards share a responsive grid: up to 20rem wide, shrinking to fit narrow screens.
|
|
41
41
|
const MeteorCard = ({ data, particleNumber, particleColor, particleSpeed }) => {
|
|
42
42
|
return (
|
|
43
|
-
<div className="
|
|
43
|
+
<div className="grid w-full justify-center gap-6" style={{ gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 16rem), 20rem))" }}>
|
|
44
44
|
{data.map((item, index) => (
|
|
45
|
-
<div key={index} className="relative w-
|
|
45
|
+
<div key={index} className="relative w-full min-w-0 bg-slate-800/80 p-0.5 rounded-2xl moving-border-card">
|
|
46
46
|
{/* Card ka content container */}
|
|
47
47
|
<div className="relative bg-slate-900 rounded-[calc(1rem-2px)] p-3 flex flex-col overflow-hidden">
|
|
48
48
|
{/* Background Effects */}
|
|
@@ -59,13 +59,13 @@ const MeteorCard = ({ data, particleNumber, particleColor, particleSpeed }) => {
|
|
|
59
59
|
<div className="absolute -top-1/4 -left-1/4 w-3/4 h-1/2 bg-blue-400/60 rounded-full blur-[90px] pointer-events-none z-0"></div>
|
|
60
60
|
|
|
61
61
|
{/* Image */}
|
|
62
|
-
<div className="relative
|
|
62
|
+
<div className="relative aspect-[4/3] w-full rounded-xl overflow-hidden z-10 ring-1 ring-white/10">
|
|
63
63
|
<img src={item.imageUrl} alt={item.title} className="w-full h-full object-cover object-[center_25%]" />
|
|
64
64
|
</div>
|
|
65
65
|
|
|
66
66
|
{/* Content */}
|
|
67
67
|
<div className="relative z-10 px-2 pt-4 pb-2 text-center">
|
|
68
|
-
<h3 className="text-
|
|
68
|
+
<h3 className="text-xl font-bold sm:text-2xl mb-2 text-white tracking-tight">{item.title}</h3>
|
|
69
69
|
<p className="text-sm leading-relaxed text-gray-400 mb-5">{item.description}</p>
|
|
70
70
|
<button className="group relative inline-flex w-full items-center justify-center whitespace-nowrap px-6 py-2.5 bg-lime-600 text-white font-semibold rounded-lg overflow-hidden transition-all duration-300 hover:bg-lime-700">
|
|
71
71
|
<span>{item.buttonText}</span>
|