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.
@@ -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 diagonally into the distance in 3D. On a
7
- * timer the front card drops away and slides to the back of the stack
8
- * while the rest step forward. Pauses while hovered; click to swap now.
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 = 5, pauseOnHover = true, accentColor = "#a3e635", theme = "dark", width = 18, className = "" }) => {
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
- className={`relative [perspective:1000px] ${className}`}
43
- style={{ width: `min(${width}rem, 70vw)`, height: `${width * 0.68}rem`, marginTop: `${distanceY * (cards.length - 1)}rem`, marginRight: `${distanceX * (cards.length - 1)}rem` }}
44
- onPointerEnter={() => pauseOnHover && setPaused(true)}
45
- onPointerLeave={() => setPaused(false)}
46
- onClick={swap}
47
- >
48
- {cards.map((card, i) => {
49
- const slot = order.indexOf(i);
50
- const isDropping = dropping === i;
51
- const Icon = card.icon ?? Sparkles;
52
- return (
53
- <motion.div
54
- key={i}
55
- className={`absolute inset-0 cursor-pointer overflow-hidden rounded-2xl border p-6 [backface-visibility:hidden] ${dark ? "border-white/10 bg-neutral-950 text-white" : "border-black/10 bg-white text-neutral-900"}`}
56
- style={{ zIndex: isDropping ? 60 : cards.length - slot, transformStyle: "preserve-3d", boxShadow: "0 25px 50px -25px rgba(0,0,0,0.6)" }}
57
- initial={false}
58
- animate={
59
- isDropping
60
- ? { x: "0rem", y: "14rem", z: 0, skewY: skew, opacity: 0.0 }
61
- : { x: `${slot * distanceX}rem`, y: `${-slot * distanceY}rem`, z: -slot * 60, skewY: skew, opacity: slot > 4 ? 0 : 1 }
62
- }
63
- transition={isDropping ? { duration: 0.5, ease: [0.55, 0, 0.8, 0.2] } : { type: "spring", stiffness: 140, damping: 20 }}
64
- >
65
- <div className="flex items-center gap-2.5">
66
- <span className="grid h-9 w-9 place-items-center rounded-xl text-black" style={{ background: accentColor }}>
67
- <Icon className="h-4.5 w-4.5" />
68
- </span>
69
- <p className="text-lg font-semibold">{card.title}</p>
70
- </div>
71
- <p className={`mt-3 text-[0.92rem] leading-relaxed ${dark ? "text-white/60" : "text-neutral-500"}`}>{card.body}</p>
72
- <div className="pointer-events-none absolute -bottom-10 -right-10 h-32 w-32 rounded-full opacity-20 blur-2xl" style={{ background: accentColor }} />
73
- </motion.div>
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 < landDots.length; i++) {
145
- const [lat, lon] = landDots[i];
146
- const [x, y, z] = project(toVec(lat, lon), R, c);
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, 92vw)` }}>
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 [lean, setLean] = useState([]);
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: `${size}rem` }}>
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
- const spot = SPOTS[papers.length === 1 ? 1 : papers.length === 2 ? i * 2 : i];
61
- const l = lean[i] ?? { x: 0, y: 0 };
62
- return (
63
- <motion.span
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 p-1 sm:gap-0.5 sm:p-1.5 ${dark ? "bg-neutral-950 ring-1 ring-white/10" : "bg-white shadow-sm ring-1 ring-black/10"}`}>
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 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"}`}
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]) => setVertical(entry.contentRect.width < 520));
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)", filter: on ? "none" : "saturate(0.6) brightness(0.75)", transition: `transform ${duration * 1.4}ms ${EASE}, filter ${duration}ms ${EASE}` }}
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 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` }}
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-[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>}
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 shadow-[0_18px_40px_-22px_rgba(0,0,0,0.7)] ring-1 ring-white/10">
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
- className={`relative flex items-center justify-center ${className}`}
28
- style={{ height: `${size * 0.6 + n * gap * 1.6 + 6}rem`, width: "100%" }}
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: "rotateX(58deg) rotateZ(-45deg)", transformStyle: "preserve-3d" }}>
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, -18px 18px 30px -12px rgba(0,0,0,${dark ? 0.6 : 0.25})`,
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="pointer-events-none absolute right-2 top-1/2 flex -translate-y-1/2 flex-col gap-2 sm:right-6">
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-right backdrop-blur ${dark ? "border-white/10 bg-black/50 text-white" : "border-black/10 bg-white/80 text-neutral-900"}`}
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
- particles.forEach((particle, i) => {
43
- const { x, z } = particle;
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
- instancedMeshRef.current.setMatrixAt(i, dummy.matrix);
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
- <sphereGeometry args={[0.2, 19, 19]} />
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
- // A fixed-size card (image on top, content below) so it looks the same on every screen.
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="flex flex-wrap justify-center gap-6">
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-[20rem] max-w-full bg-slate-800/80 p-0.5 rounded-2xl moving-border-card">
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 h-[13.75rem] w-full rounded-xl overflow-hidden z-10 ring-1 ring-white/10">
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-2xl font-bold mb-2 text-white tracking-tight">{item.title}</h3>
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>