apex-ui-kit 2.10.0 → 2.11.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.
@@ -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
  }
@@ -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
@@ -80,14 +80,16 @@ const ImageAccordion = ({ items = DEFAULT_ITEMS, defaultActive = 0, autoPlay = 0
80
80
  alt=""
81
81
  draggable={false}
82
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}` }}
83
+ style={{ transform: on ? "scale(1)" : "scale(1.12)", transition: `transform ${duration * 1.4}ms ${EASE}`, willChange: "transform" }}
84
84
  />
85
+ {/* Dim folded panels with an overlay (cheap) instead of a filter on the image (repaints every frame). */}
86
+ <span aria-hidden="true" className="absolute inset-0 bg-black" style={{ opacity: on ? 0 : 0.32, transition: `opacity ${duration}ms ${EASE}` }} />
85
87
  <span aria-hidden="true" className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent" />
86
88
 
87
89
  {/* Folded: the title runs along the strip. */}
88
90
  <span
89
91
  aria-hidden="true"
90
- className="absolute whitespace-nowrap text-sm font-semibold tracking-wide"
92
+ className="absolute whitespace-nowrap text-sm font-semibold tracking-wide [backface-visibility:hidden]"
91
93
  style={{
92
94
  ...(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
95
  opacity: on ? 0 : 0.9,
@@ -97,14 +99,14 @@ const ImageAccordion = ({ items = DEFAULT_ITEMS, defaultActive = 0, autoPlay = 0
97
99
  {item.title}
98
100
  </span>
99
101
 
100
- {/* Open: title and text rise in once the panel has room. */}
102
+ {/* Open: title and text rise in once the panel has room. A fixed width keeps them from re-wrapping as it grows. */}
101
103
  <span
102
- className="absolute inset-x-0 bottom-0 block p-[clamp(1rem,3cqw,1.75rem)]"
104
+ className="absolute bottom-0 left-0 block w-[min(26rem,85vw)] p-[clamp(1rem,3cqw,1.75rem)] [backface-visibility:hidden]"
103
105
  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
106
  >
105
107
  <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 className="mt-1 block truncate text-[1.6rem] font-bold leading-tight">{item.title}</span>
109
+ {item.description && <span className="mt-1 block max-w-[20rem] text-sm text-white/75">{item.description}</span>}
108
110
  </span>
109
111
  </button>
110
112
  );
@@ -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>
@@ -148,7 +148,7 @@ const OrbitIcons = ({ rings = DEFAULT_RINGS, size = 28, tilt = 0.55, speed = 1,
148
148
  return (
149
149
  <div key={idx} ref={(e) => (nodes.current[idx] = e)} className="group absolute left-1/2 top-1/2 will-change-transform">
150
150
  <div
151
- className="grid place-items-center rounded-2xl border border-white/10 bg-neutral-900/90 text-white shadow-[0_10px_30px_-12px_rgba(0,0,0,0.7)] backdrop-blur transition-colors duration-300 group-hover:border-[var(--orbit-accent)]"
151
+ className="grid place-items-center rounded-2xl border border-white/10 bg-neutral-900/90 text-white transition-colors duration-300 group-hover:border-[var(--orbit-accent)]"
152
152
  style={{ width: "var(--orbit-item, 2.75rem)", aspectRatio: "1" }}
153
153
  title={it.label}
154
154
  >
@@ -25,53 +25,54 @@ const ParallaxCard = ({ cardData = [], tiltEnable, glareEnable, scale, perspecti
25
25
 
26
26
  if (!cardElement) return;
27
27
 
28
+ // quickTo builds each tween once and just retargets it, instead of
29
+ // creating six new tweens on every mouse move.
30
+ const to = (el, prop, duration) => (el ? gsap.quickTo(el, prop, { duration, ease: "power3.out" }) : () => {});
31
+ const card = { rx: to(cardElement, "rotationX", 0.8), ry: to(cardElement, "rotationY", 0.8), s: to(cardElement, "scale", 0.8) };
32
+ const layers = [
33
+ [titleElement, 0.8, 0.6],
34
+ [imageElement, 1.5, 0.7],
35
+ [descElement, 1.2, 0.8],
36
+ [buttonElement, 1.1, 0.9],
37
+ [linkElement, 1.1, 0.9],
38
+ ]
39
+ .filter(([el]) => el)
40
+ .map(([el, k, d]) => ({ k, x: to(el, "x", d), y: to(el, "y", d) }));
41
+ let rect = null;
42
+
43
+ const handleMouseEnter = () => {
44
+ rect = cardElement.getBoundingClientRect();
45
+ };
28
46
  const handleMouseMove = (e) => {
29
47
  if (!tiltEnable) return;
30
- const { left, top, width, height } = cardElement.getBoundingClientRect();
31
- const x = e.clientX - left;
32
- const y = e.clientY - top;
33
- const rotateX = (y / height - 0.5) * -30;
34
- const rotateY = (x / width - 0.5) * 30;
35
-
36
- gsap.to(cardElement, {
37
- duration: 1,
38
- rotationX: rotateX,
39
- rotationY: rotateY,
40
- scale: scale,
41
- ease: "power3.out",
48
+ const r = rect ?? (rect = cardElement.getBoundingClientRect());
49
+ const rotateX = ((e.clientY - r.top) / r.height - 0.5) * -30;
50
+ const rotateY = ((e.clientX - r.left) / r.width - 0.5) * 30;
51
+ card.rx(rotateX);
52
+ card.ry(rotateY);
53
+ card.s(scale);
54
+ layers.forEach((l) => {
55
+ l.x(rotateY * l.k);
56
+ l.y(-rotateX * l.k);
42
57
  });
43
-
44
- if (titleElement) gsap.to(titleElement, { duration: 0.7, x: rotateY * 0.8, y: -rotateX * 0.8 });
45
- if (imageElement) gsap.to(imageElement, { duration: 0.8, x: rotateY * 1.5, y: -rotateX * 1.5 });
46
- if (descElement) gsap.to(descElement, { duration: 0.9, x: rotateY * 1.2, y: -rotateX * 1.2 });
47
- if (buttonElement) gsap.to(buttonElement, { duration: 1, x: rotateY * 1.1, y: -rotateX * 1.1 });
48
- if (linkElement) gsap.to(linkElement, { duration: 1, x: rotateY * 1.1, y: -rotateX * 1.1 });
49
58
  };
50
59
 
51
60
  const handleMouseLeave = () => {
52
- if (tiltEnable) {
53
- gsap.to(cardElement, {
54
- duration: 1.2,
55
- rotationX: 0,
56
- rotationY: 0,
57
- scale: 1,
58
- ease: "elastic.out(1, 0.5)",
59
- });
60
- gsap.to([titleElement, imageElement, descElement, buttonElement, linkElement].filter(Boolean), {
61
- duration: 1.2,
62
- x: 0,
63
- y: 0,
64
- ease: "elastic.out(1, 0.5)",
65
- });
66
- }
61
+ rect = null;
62
+ if (!tiltEnable) return;
63
+ gsap.to(cardElement, { duration: 1.2, rotationX: 0, rotationY: 0, scale: 1, ease: "elastic.out(1, 0.5)", overwrite: "auto" });
64
+ gsap.to([titleElement, imageElement, descElement, buttonElement, linkElement].filter(Boolean), { duration: 1.2, x: 0, y: 0, ease: "elastic.out(1, 0.5)", overwrite: "auto" });
67
65
  };
68
66
 
67
+ cardElement.addEventListener("mouseenter", handleMouseEnter);
69
68
  cardElement.addEventListener("mousemove", handleMouseMove, { passive: true });
70
69
  cardElement.addEventListener("mouseleave", handleMouseLeave);
71
70
 
72
71
  cleanups.push(() => {
72
+ cardElement.removeEventListener("mouseenter", handleMouseEnter);
73
73
  cardElement.removeEventListener("mousemove", handleMouseMove);
74
74
  cardElement.removeEventListener("mouseleave", handleMouseLeave);
75
+ gsap.killTweensOf([cardElement, titleElement, imageElement, descElement, buttonElement, linkElement].filter(Boolean));
75
76
  });
76
77
  });
77
78
  return () => cleanups.forEach((fn) => fn());