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.
@@ -1,45 +1,167 @@
1
- import React from "react";
1
+ import React, { useEffect, useRef } from "react";
2
2
 
3
3
  /*
4
- * Aurora Background: soft northern-light bands that drift and shimmer, built
5
- * from blurred CSS gradients only (no canvas, no WebGL). Works on light and
6
- * dark pages; reduced-motion visitors get the same picture without movement.
4
+ * Aurora Background: northern lights over a night sky, drawn in one small
5
+ * WebGL shader. Curtains of light hang from the top, ripple sideways and
6
+ * flicker in fine vertical rays; the colours run from the first (lower edge)
7
+ * to the last (upper fringe). Faint stars sit behind them. Reduced-motion
8
+ * visitors get a still frame. No library needed.
7
9
  */
8
10
 
9
- const AuroraBackground = ({
10
- children,
11
- colors = ["#22d3ee", "#a3e635", "#a78bfa", "#38bdf8"],
12
- theme = "dark",
13
- speed = 1,
14
- intensity = 1,
15
- className = "",
16
- containerClassName = "",
17
- }) => {
11
+ const VERT = `attribute vec2 p; void main(){ gl_Position = vec4(p, 0.0, 1.0); }`;
12
+
13
+ const FRAG = `
14
+ precision highp float;
15
+ uniform vec2 uRes; uniform float uTime; uniform float uIntensity; uniform float uLight;
16
+ uniform vec3 uC0; uniform vec3 uC1; uniform vec3 uC2; uniform vec3 uC3; uniform vec3 uSky;
17
+
18
+ float h(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
19
+ float n(vec2 p){
20
+ vec2 i = floor(p); vec2 f = fract(p); f = f * f * (3.0 - 2.0 * f);
21
+ return mix(mix(h(i), h(i + vec2(1, 0)), f.x), mix(h(i + vec2(0, 1)), h(i + vec2(1, 1)), f.x), f.y);
22
+ }
23
+ float fbm(vec2 p){ float v = 0.0; float a = 0.5; for (int i = 0; i < 4; i++) { v += a * n(p); p *= 2.1; a *= 0.5; } return v; }
24
+
25
+ vec3 ramp(float t){
26
+ t = clamp(t, 0.0, 1.0) * 3.0;
27
+ return t < 1.0 ? mix(uC0, uC1, t) : t < 2.0 ? mix(uC1, uC2, t - 1.0) : mix(uC2, uC3, t - 2.0);
28
+ }
29
+
30
+ // One curtain: a wavy band whose lower edge is bright and which fades upward.
31
+ vec3 curtain(vec2 uv, float t, float seed, float base, float amp){
32
+ float x = uv.x * 1.6 + seed;
33
+ float wave = (fbm(vec2(x * 0.9, t * 0.12 + seed)) - 0.5) * amp + sin(x * 1.7 + t * 0.35 + seed) * amp * 0.35;
34
+ float edge = base + wave; // lower edge of the curtain
35
+ float d = uv.y - edge; // height above the edge
36
+ if (d < -0.05) return vec3(0.0);
37
+ float rays = 0.55 + 0.45 * n(vec2(x * 38.0, t * 0.6 + seed)); // fine vertical rays
38
+ float body = exp(-max(d, 0.0) * 3.2) * smoothstep(-0.05, 0.015, d);
39
+ float glow = exp(-abs(d) * 26.0) * 0.9; // bright lower rim
40
+ float shimmer = 0.75 + 0.25 * sin(t * 1.3 + x * 6.0 + seed * 3.0);
41
+ return ramp(clamp(d * 1.9, 0.0, 1.0)) * (body * rays + glow) * shimmer;
42
+ }
43
+
44
+ void main(){
45
+ vec2 uv = gl_FragCoord.xy / uRes;
46
+ uv.x *= uRes.x / uRes.y;
47
+ float t = uTime;
48
+ vec3 col = uSky * (0.55 + 0.45 * (1.0 - gl_FragCoord.y / uRes.y));
49
+ // Stars (dark sky only).
50
+ vec2 cell = floor(gl_FragCoord.xy / 2.0);
51
+ col += step(0.9975, h(cell)) * (0.5 + 0.5 * sin(t * 2.0 + h(cell) * 40.0)) * (1.0 - uLight) * 0.8;
52
+ vec3 a = curtain(uv, t, 0.0, 0.42, 0.22) + curtain(uv, t * 0.8, 4.7, 0.52, 0.18) * 0.75 + curtain(uv, t * 1.15, 9.3, 0.33, 0.26) * 0.55;
53
+ a *= uIntensity;
54
+ col = uLight > 0.5 ? mix(col, col * (1.0 - min(a, 0.85)) + a * 0.55, 1.0) : col + a;
55
+ col += (h(gl_FragCoord.xy + t) - 0.5) / 255.0;
56
+ gl_FragColor = vec4(col, 1.0);
57
+ }`;
58
+
59
+ const toRgb = (hex) => {
60
+ const m = /^#?([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(String(hex).trim());
61
+ return m ? [parseInt(m[1], 16) / 255, parseInt(m[2], 16) / 255, parseInt(m[3], 16) / 255] : [0.4, 0.9, 0.6];
62
+ };
63
+
64
+ const AuroraBackground = ({ children, colors = ["#22c55e", "#2dd4bf", "#38bdf8", "#c084fc"], theme = "dark", speed = 1, intensity = 1, className = "", containerClassName = "" }) => {
65
+ const box = useRef(null);
66
+ const live = useRef({});
67
+ live.current = { colors, theme, speed, intensity };
18
68
  const dark = theme !== "light";
19
- const stops = colors.map((c, i) => `${c} ${Math.round((i / colors.length) * 100)}%`).join(", ");
20
- const duration = `${60 / Math.max(0.1, speed)}s`;
69
+
70
+ useEffect(() => {
71
+ const el = box.current;
72
+ const cv = document.createElement("canvas");
73
+ Object.assign(cv.style, { position: "absolute", inset: "0", width: "100%", height: "100%", display: "block" });
74
+ el.prepend(cv);
75
+ const gl = cv.getContext("webgl", { antialias: false, alpha: false, premultipliedAlpha: false });
76
+ if (!gl) {
77
+ cv.remove();
78
+ return undefined;
79
+ }
80
+ const shader = (type, src) => {
81
+ const s = gl.createShader(type);
82
+ gl.shaderSource(s, src);
83
+ gl.compileShader(s);
84
+ return s;
85
+ };
86
+ const prog = gl.createProgram();
87
+ gl.attachShader(prog, shader(gl.VERTEX_SHADER, VERT));
88
+ gl.attachShader(prog, shader(gl.FRAGMENT_SHADER, FRAG));
89
+ gl.linkProgram(prog);
90
+ gl.useProgram(prog);
91
+ const buf = gl.createBuffer();
92
+ gl.bindBuffer(gl.ARRAY_BUFFER, buf);
93
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
94
+ const loc = gl.getAttribLocation(prog, "p");
95
+ gl.enableVertexAttribArray(loc);
96
+ gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
97
+ const U = (name) => gl.getUniformLocation(prog, name);
98
+ const u = { res: U("uRes"), time: U("uTime"), int: U("uIntensity"), light: U("uLight"), sky: U("uSky"), c: [U("uC0"), U("uC1"), U("uC2"), U("uC3")] };
99
+
100
+ const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
101
+ let raf = 0;
102
+ let last = 0;
103
+ let time = 8;
104
+ let visible = true;
105
+ const scale = 0.75; // render a little under full size: the light is soft anyway
106
+
107
+ const fit = () => {
108
+ const r = el.getBoundingClientRect();
109
+ const dpr = Math.min(window.devicePixelRatio || 1, 1.5) * scale;
110
+ cv.width = Math.max(1, Math.round(r.width * dpr));
111
+ cv.height = Math.max(1, Math.round(r.height * dpr));
112
+ gl.viewport(0, 0, cv.width, cv.height);
113
+ };
114
+ const draw = (now) => {
115
+ const o = live.current;
116
+ const dt = last ? Math.min(0.05, (now - last) / 1000) : 0;
117
+ last = now;
118
+ time += dt * o.speed;
119
+ const cs = [0, 1, 2, 3].map((i) => toRgb(o.colors[Math.min(i, o.colors.length - 1)] ?? "#22c55e"));
120
+ const light = o.theme === "light";
121
+ gl.uniform2f(u.res, cv.width, cv.height);
122
+ gl.uniform1f(u.time, time);
123
+ gl.uniform1f(u.int, o.intensity);
124
+ gl.uniform1f(u.light, light ? 1 : 0);
125
+ gl.uniform3fv(u.sky, light ? [0.95, 0.97, 0.99] : [0.02, 0.03, 0.07]);
126
+ cs.forEach((c, i) => gl.uniform3fv(u.c[i], c));
127
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
128
+ };
129
+ const loop = (now) => {
130
+ raf = 0;
131
+ draw(now);
132
+ if (visible && !reduced) raf = requestAnimationFrame(loop);
133
+ };
134
+ const start = () => {
135
+ if (!raf && visible) {
136
+ last = 0;
137
+ raf = requestAnimationFrame(loop);
138
+ }
139
+ };
140
+ const ro = new ResizeObserver(() => {
141
+ fit();
142
+ start();
143
+ });
144
+ ro.observe(el);
145
+ const io = new IntersectionObserver(([entry]) => {
146
+ visible = entry.isIntersecting;
147
+ if (visible) start();
148
+ });
149
+ io.observe(el);
150
+ fit();
151
+ start();
152
+ return () => {
153
+ cancelAnimationFrame(raf);
154
+ ro.disconnect();
155
+ io.disconnect();
156
+ gl.deleteBuffer(buf);
157
+ gl.deleteProgram(prog);
158
+ gl.getExtension("WEBGL_lose_context")?.loseContext();
159
+ cv.remove();
160
+ };
161
+ }, []);
21
162
 
22
163
  return (
23
- <div className={`relative h-screen w-full overflow-hidden ${dark ? "bg-[#05060a]" : "bg-[#f8fafc]"} ${containerClassName}`}>
24
- <style>{`
25
- @keyframes apex-aurora { from { background-position: 50% 50%, 50% 50%; } to { background-position: 350% 50%, 350% 50%; } }
26
- @media (prefers-reduced-motion: reduce) { .apex-aurora, .apex-aurora::after { animation: none !important; } }
27
- `}</style>
28
- <div
29
- aria-hidden="true"
30
- className="apex-aurora pointer-events-none absolute -inset-[10px] will-change-transform after:absolute after:inset-0 after:content-['']"
31
- style={{
32
- opacity: Math.min(1, (dark ? 0.9 : 0.75) * intensity),
33
- filter: `blur(${dark ? 12 : 10}px) ${dark ? "" : "invert(0)"}`,
34
- backgroundImage: `repeating-linear-gradient(100deg, ${dark ? "#000" : "#fff"} 0%, ${dark ? "#000" : "#fff"} 7%, transparent 10%, transparent 12%, ${dark ? "#000" : "#fff"} 16%), repeating-linear-gradient(100deg, ${stops}, ${colors[0]} 100%)`,
35
- backgroundSize: "300%, 200%",
36
- backgroundPosition: "50% 50%, 50% 50%",
37
- maskImage: "radial-gradient(ellipse 120% 95% at 70% 0%, black 25%, transparent 78%)",
38
- WebkitMaskImage: "radial-gradient(ellipse 120% 95% at 70% 0%, black 25%, transparent 78%)",
39
- animation: `apex-aurora ${duration} linear infinite`,
40
- mixBlendMode: dark ? "normal" : "multiply",
41
- }}
42
- />
164
+ <div ref={box} className={`relative h-screen w-full overflow-hidden ${dark ? "bg-[#05070f]" : "bg-[#f1f5f9]"} ${containerClassName}`}>
43
165
  {children && <div className={`relative z-10 h-full w-full ${className}`}>{children}</div>}
44
166
  </div>
45
167
  );
@@ -1,145 +1,134 @@
1
- import React, { useRef, useEffect, useState } from "react";
2
- import { gsap } from "gsap";
3
-
4
- function BluePrintBackground({ color = '#9ae600', borderColor = 'rgb(98, 116, 142, 0.1)', bgColor, circleColor = 'rgb(154, 230, 0, 0.2)'}) {
5
- const gridRef = useRef(null);
6
- const [grid, setGrid] = useState({ cols: 0, rows: 0 });
7
- const boxSize = 40;
8
- const lastHighlighted = useRef(null);
9
- const colorRef = useRef(color);
10
-
11
- // Effect 1: calculate grid size and keep it updated on resize
12
- useEffect(() => {
13
- const calculateGrid = () => {
14
- const cols = Math.ceil(window.innerWidth / boxSize);
15
- const rows = Math.ceil(window.innerHeight / boxSize) * 2; // double rows for infinite scroll
16
- // kill any child tweens before we replace children to avoid visual flicker
17
- const el = gridRef.current;
18
- if (el) {
19
- try { gsap.killTweensOf(el); } catch { /* ignore */ }
20
- Array.from(el.children).forEach((c) => { try { gsap.killTweensOf(c); } catch { /* ignore */ } });
21
- lastHighlighted.current = null;
22
- }
23
- setGrid({ cols, rows });
24
- };
25
-
26
- calculateGrid();
27
- window.addEventListener("resize", calculateGrid);
28
- return () => window.removeEventListener("resize", calculateGrid);
29
- }, []);
30
-
31
- // keep colorRef up-to-date without forcing effect re-run
32
- useEffect(() => { colorRef.current = color; }, [color]);
33
-
34
- // Effect 2: initialize GSAP infinite scroll and mouse handlers once (do not recreate on grid changes)
35
- useEffect(() => {
36
- const el = gridRef.current;
37
- let scrollTween = null;
38
-
39
- if (el) {
40
- // make sure we start from current transform so GSAP doesn't jump
41
- // create a looping tween on the container only once
42
- scrollTween = gsap.to(el, {
43
- y: "-50%",
44
- duration: 15,
45
- repeat: -1,
46
- ease: "linear",
47
- overwrite: false,
48
- });
49
- }
50
-
51
- const handleMouseMove = (e) => {
52
- const el2 = gridRef.current;
53
- if (!el2) return;
54
-
55
- const target = document.elementFromPoint(e.clientX, e.clientY);
56
- if (!target || !el2.contains(target)) {
57
- if (lastHighlighted.current !== null) {
58
- const prev = el2.children[lastHighlighted.current];
59
- if (prev) gsap.to(prev, { backgroundColor: "transparent", boxShadow: "none", duration: 0.18 });
60
- lastHighlighted.current = null;
61
- }
62
- return;
63
- }
64
-
65
- const child = target.closest("div");
66
- const index = Array.prototype.indexOf.call(el2.children, child);
67
- if (index === -1) return;
68
-
69
- if (lastHighlighted.current !== index) {
70
- if (lastHighlighted.current !== null) {
71
- const prevBox = el2.children[lastHighlighted.current];
72
- if (prevBox) gsap.to(prevBox, { backgroundColor: "transparent", boxShadow: "none", duration: 0.18 });
73
- }
74
-
75
- const newBox = el2.children[index];
76
- if (newBox) {
77
- const c = colorRef.current || "#a3e635";
78
- gsap.to(newBox, { backgroundColor: c, boxShadow: `0 0 12px ${c}`, duration: 0.12 });
79
- }
80
-
81
- lastHighlighted.current = index;
82
- }
83
- };
84
-
85
- // Hit-test at most once per frame instead of on every mousemove event.
86
- let moveFrame = 0;
87
- let lastMove = null;
88
- const onMouseMove = (e) => {
89
- lastMove = e;
90
- if (moveFrame) return;
91
- moveFrame = requestAnimationFrame(() => {
92
- moveFrame = 0;
93
- handleMouseMove(lastMove);
94
- });
95
- };
96
- window.addEventListener("mousemove", onMouseMove, { passive: true });
97
-
98
- return () => {
99
- window.removeEventListener("mousemove", onMouseMove);
100
- cancelAnimationFrame(moveFrame);
101
- if (scrollTween && typeof scrollTween.kill === "function") {
102
- try { scrollTween.kill(); } catch { /* ignore */ }
103
- }
104
- if (el) {
105
- try { gsap.killTweensOf(el); } catch { /* ignore */ }
106
- Array.from(el.children).forEach((c) => { try { gsap.killTweensOf(c); } catch { /* ignore */ } });
107
- }
108
- lastHighlighted.current = null;
109
- };
110
- }, []);
111
-
112
- const total = grid.cols * grid.rows;
113
-
114
- return (
115
- <div className="absolute inset-0 z-0 w-full h-full overflow-hidden bg-slate-800" style={{ backgroundColor: `${bgColor}` }}>
116
- <div
117
- ref={gridRef}
118
- className="grid"
119
- style={{
120
- gridTemplateColumns: `repeat(${grid.cols}, ${boxSize}px)`,
121
- gridTemplateRows: `repeat(${grid.rows}, ${boxSize}px)`,
122
- }}
123
- >
124
- {Array.from({ length: total }).map((_, i) => (
125
- <div
126
- key={i}
127
- className={`border bg-transparent w-10 h-10`}
128
- style={{
129
- borderColor: borderColor
130
- }}
131
- />
132
- ))}
133
- </div>
134
- {/* Spotlight overlay - positioned after grid to not interfere with scroll */}
135
- <div
136
- className="absolute inset-0 z-10 pointer-events-none blur-3xl"
137
- style={{
138
- background: `radial-gradient(circle, ${circleColor} 0%, rgba(0, 0, 255, 0.1) 100%)`
139
- }}
140
- />
141
- </div >
142
- );
143
- }
144
-
145
- export default BluePrintBackground;
1
+ import React, { useEffect, useRef } from "react";
2
+
3
+ /*
4
+ * Blueprint Background: a drafting grid that drifts upward, with the cell
5
+ * under the pointer lighting up and fading out behind it like a trail. Drawn
6
+ * on one canvas (no per-cell elements), so it stays smooth on phones. Fills
7
+ * its nearest positioned parent; put content on top as children.
8
+ */
9
+
10
+ const BluePrintBackground = ({ color = "#9ae600", borderColor = "rgba(98, 116, 142, 0.22)", bgColor = "#1e293b", circleColor = "rgba(154, 230, 0, 0.2)", cellSize = 40, speed = 1, children, className = "" }) => {
11
+ const wrap = useRef(null);
12
+ const canvas = useRef(null);
13
+ const live = useRef({});
14
+ live.current = { color, borderColor, cellSize, speed };
15
+
16
+ useEffect(() => {
17
+ const el = wrap.current;
18
+ const cv = canvas.current;
19
+ const ctx = cv.getContext("2d");
20
+ const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
21
+ const lit = new Map(); // "col,row" -> glow 0..1
22
+ const pointer = { x: -1, y: -1, in: false };
23
+ let w = 0;
24
+ let h = 0;
25
+ let dpr = 1;
26
+ let total = 0; // how far the grid has scrolled, in px
27
+ let raf = 0;
28
+ let last = 0;
29
+ let visible = true;
30
+
31
+ const fit = () => {
32
+ const r = el.getBoundingClientRect();
33
+ dpr = Math.min(window.devicePixelRatio || 1, 2);
34
+ w = r.width;
35
+ h = r.height;
36
+ cv.width = Math.round(w * dpr);
37
+ cv.height = Math.round(h * dpr);
38
+ };
39
+
40
+ const frame = (now) => {
41
+ raf = 0;
42
+ const o = live.current;
43
+ const size = Math.max(12, o.cellSize);
44
+ const dt = last ? Math.min(0.05, (now - last) / 1000) : 1 / 60;
45
+ last = now;
46
+ if (!reduced) total += dt * 24 * o.speed;
47
+ const offset = total % size;
48
+
49
+ // Light the cell under the pointer (in grid space, so it scrolls with the grid).
50
+ if (pointer.in) {
51
+ lit.set(`${Math.floor(pointer.x / size)},${Math.floor((pointer.y + total) / size)}`, 1);
52
+ }
53
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
54
+ ctx.clearRect(0, 0, w, h);
55
+
56
+ // Glowing cells, fading out.
57
+ ctx.shadowColor = o.color;
58
+ for (const [key, a] of lit) {
59
+ const [c, row] = key.split(",").map(Number);
60
+ const y = row * size - total;
61
+ if (a <= 0.02 || y < -size || y > h + size) {
62
+ lit.delete(key);
63
+ continue;
64
+ }
65
+ ctx.globalAlpha = a;
66
+ ctx.shadowBlur = 14 * a;
67
+ ctx.fillStyle = o.color;
68
+ ctx.fillRect(c * size + 1, y + 1, size - 2, size - 2);
69
+ lit.set(key, a - dt * 1.6);
70
+ }
71
+ ctx.globalAlpha = 1;
72
+ ctx.shadowBlur = 0;
73
+
74
+ // Grid lines.
75
+ ctx.strokeStyle = o.borderColor;
76
+ ctx.lineWidth = 1;
77
+ ctx.beginPath();
78
+ for (let x = 0; x <= w; x += size) {
79
+ ctx.moveTo(Math.round(x) + 0.5, 0);
80
+ ctx.lineTo(Math.round(x) + 0.5, h);
81
+ }
82
+ for (let y = -offset; y <= h; y += size) {
83
+ ctx.moveTo(0, Math.round(y) + 0.5);
84
+ ctx.lineTo(w, Math.round(y) + 0.5);
85
+ }
86
+ ctx.stroke();
87
+
88
+ if (visible && (!reduced || lit.size)) raf = requestAnimationFrame(frame);
89
+ else last = 0;
90
+ };
91
+ const start = () => {
92
+ if (!raf && visible) raf = requestAnimationFrame(frame);
93
+ };
94
+
95
+ // Listen on the window so content placed on top doesn't block the effect.
96
+ const onMove = (e) => {
97
+ const r = el.getBoundingClientRect();
98
+ pointer.x = e.clientX - r.left;
99
+ pointer.y = e.clientY - r.top;
100
+ pointer.in = pointer.x >= 0 && pointer.y >= 0 && pointer.x <= r.width && pointer.y <= r.height;
101
+ if (pointer.in) start();
102
+ };
103
+ window.addEventListener("pointermove", onMove, { passive: true });
104
+ const ro = new ResizeObserver(() => {
105
+ fit();
106
+ start();
107
+ });
108
+ ro.observe(el);
109
+ const io = new IntersectionObserver(([entry]) => {
110
+ visible = entry.isIntersecting;
111
+ if (visible) start();
112
+ });
113
+ io.observe(el);
114
+ fit();
115
+ start();
116
+ return () => {
117
+ cancelAnimationFrame(raf);
118
+ ro.disconnect();
119
+ io.disconnect();
120
+ window.removeEventListener("pointermove", onMove);
121
+ };
122
+ }, []);
123
+
124
+ return (
125
+ <div ref={wrap} className={`absolute inset-0 z-0 h-full w-full overflow-hidden ${className}`} style={{ backgroundColor: bgColor }}>
126
+ <canvas ref={canvas} aria-hidden="true" className="absolute inset-0 h-full w-full" />
127
+ {/* Soft spotlight in the middle. */}
128
+ <div aria-hidden="true" className="pointer-events-none absolute inset-0 blur-3xl" style={{ background: `radial-gradient(circle, ${circleColor} 0%, transparent 70%)` }} />
129
+ {children && <div className="relative z-10 h-full w-full">{children}</div>}
130
+ </div>
131
+ );
132
+ };
133
+
134
+ export default BluePrintBackground;
@@ -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
  };