apex-ui-kit 2.11.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/FlipClock/FlipClock.jsx +1 -1
- package/dist/ApexUI-Kit/templates/GooeyNav/GooeyNav.jsx +16 -2
- package/dist/ApexUI-Kit/templates/ImageAccordion/ImageAccordion.jsx +13 -5
- package/dist/ApexUI-Kit/templates/NavMenu/NavMenu.jsx +175 -158
- package/dist/ApexUI-Kit/templates/Testimonials/Testimonials.jsx +140 -120
- package/dist/cli/index.js +0 -1
- package/package.json +2 -2
- package/dist/ApexUI-Kit/templates/SmartGridCard/SmartGridCard.jsx +0 -243
package/ReadMe.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
[](https://www.npmjs.com/package/apex-ui-kit)
|
|
5
5
|
[](./Licence.md)
|
|
6
6
|
|
|
7
|
-
**
|
|
7
|
+
**117 free, animated UI components for React.** Pick one, add it with a single command, and the source lands in your project so you can read it and change anything.
|
|
8
8
|
|
|
9
9
|
Built with React, Tailwind CSS and Framer Motion, plus GSAP, Canvas and WebGL where a component needs them.
|
|
10
10
|
|
|
@@ -15,7 +15,7 @@ Built with React, Tailwind CSS and Framer Motion, plus GSAP, Canvas and WebGL wh
|
|
|
15
15
|
|
|
16
16
|
## Why ApexUI
|
|
17
17
|
|
|
18
|
-
- **
|
|
18
|
+
- **117 components** across backgrounds, cards, text effects, navigation, buttons, interactive UI, AI interfaces, loaders and maps.
|
|
19
19
|
- **You own the code.** The CLI copies plain `.jsx` files into your project, no runtime package to keep in sync.
|
|
20
20
|
- **Every docs page has** a live preview, a playground to tweak props, a Desktop / Tablet / Mobile preview, the full props table and a ready prompt you can paste into ChatGPT or Claude.
|
|
21
21
|
- **Smooth on real devices.** Animations stick to transform and opacity, respect reduced motion, and work with mouse, touch and keyboard.
|
|
@@ -141,7 +141,6 @@ Every component's props, variants and copy-ready code are on its docs page.
|
|
|
141
141
|
| [Pricing Cards](https://apex-ui.in/components/pricing-cards) | `pricing-cards` |
|
|
142
142
|
| [Profile](https://apex-ui.in/components/profile) | `flip-profile`, `parallax-profile`, `profile-card` |
|
|
143
143
|
| [Scroll Stack](https://apex-ui.in/components/scroll-stack) | `scroll-stack` |
|
|
144
|
-
| [Smart Grid Card](https://apex-ui.in/components/smart-grid-card) | `smart-grid-card` |
|
|
145
144
|
| [Spotlight Grid](https://apex-ui.in/components/spotlight-grid) | `spotlight-grid` |
|
|
146
145
|
| [Swipe Cards](https://apex-ui.in/components/swipe-cards) | `swipe-cards` |
|
|
147
146
|
| [Testimonials](https://apex-ui.in/components/testimonials) | `testimonials` |
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useId, useRef, useState } from "react";
|
|
1
|
+
import React, { useId, useLayoutEffect, useRef, useState } from "react";
|
|
2
2
|
import { AnimatePresence, motion } from "framer-motion";
|
|
3
3
|
|
|
4
4
|
/*
|
|
@@ -19,7 +19,21 @@ const AnimatedTabs = ({ tabs = DEFAULT_TABS, defaultTab, variant = "pill", accen
|
|
|
19
19
|
const [active, setActive] = useState(defaultTab ?? tabs[0]?.id);
|
|
20
20
|
const [dir, setDir] = useState(1);
|
|
21
21
|
const refs = useRef([]);
|
|
22
|
+
const root = useRef(null);
|
|
22
23
|
const dark = theme !== "light";
|
|
24
|
+
// Compact tabs when the box itself is narrow (not the screen), so labels never get cut.
|
|
25
|
+
const [compact, setCompact] = useState(false);
|
|
26
|
+
|
|
27
|
+
useLayoutEffect(() => {
|
|
28
|
+
const el = root.current;
|
|
29
|
+
if (!el) return undefined;
|
|
30
|
+
const ro = new ResizeObserver(() => {
|
|
31
|
+
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
|
32
|
+
setCompact(el.clientWidth / rem < 30);
|
|
33
|
+
});
|
|
34
|
+
ro.observe(el);
|
|
35
|
+
return () => ro.disconnect();
|
|
36
|
+
}, []);
|
|
23
37
|
const index = tabs.findIndex((t) => t.id === active);
|
|
24
38
|
const current = tabs[index];
|
|
25
39
|
|
|
@@ -44,7 +58,7 @@ const AnimatedTabs = ({ tabs = DEFAULT_TABS, defaultTab, variant = "pill", accen
|
|
|
44
58
|
}[variant];
|
|
45
59
|
|
|
46
60
|
return (
|
|
47
|
-
<div className={`w-full max-w-xl ${dark ? "text-white" : "text-neutral-900"} ${className}`}>
|
|
61
|
+
<div ref={root} className={`w-full max-w-xl ${dark ? "text-white" : "text-neutral-900"} ${className}`}>
|
|
48
62
|
<div role="tablist" aria-orientation="horizontal" onKeyDown={onKeyDown} className={`relative flex overflow-x-auto [scrollbar-width:none] ${list}`}>
|
|
49
63
|
{tabs.map((t, i) => {
|
|
50
64
|
const on = t.id === active;
|
|
@@ -58,7 +72,7 @@ const AnimatedTabs = ({ tabs = DEFAULT_TABS, defaultTab, variant = "pill", accen
|
|
|
58
72
|
aria-controls={`${id}-panel`}
|
|
59
73
|
tabIndex={on ? 0 : -1}
|
|
60
74
|
onClick={() => select(i)}
|
|
61
|
-
className={`relative flex-1 whitespace-nowrap
|
|
75
|
+
className={`relative flex-1 whitespace-nowrap py-2 ${compact ? "px-2 text-[0.76rem]" : "px-4 text-[0.88rem]"} font-medium outline-none transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-current/40 ${variant === "underline" ? "pb-3" : variant === "pill" ? "rounded-full" : "rounded-lg"} ${on ? (variant === "pill" ? "text-neutral-950" : "") : dark ? "text-white/55 hover:text-white" : "text-neutral-500 hover:text-neutral-900"}`}
|
|
62
76
|
>
|
|
63
77
|
{on && (
|
|
64
78
|
<motion.span
|
|
@@ -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}>
|
|
@@ -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;
|
|
@@ -101,8 +109,8 @@ const ImageAccordion = ({ items = DEFAULT_ITEMS, defaultActive = 0, autoPlay = 0
|
|
|
101
109
|
|
|
102
110
|
{/* Open: title and text rise in once the panel has room. A fixed width keeps them from re-wrapping as it grows. */}
|
|
103
111
|
<span
|
|
104
|
-
className="absolute bottom-0 left-0 block
|
|
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` }}
|
|
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` }}
|
|
106
114
|
>
|
|
107
115
|
<span className="block font-mono text-[0.7rem] tracking-[0.2em] text-white/60">{String(i + 1).padStart(2, "0")}</span>
|
|
108
116
|
<span className="mt-1 block truncate text-[1.6rem] font-bold leading-tight">{item.title}</span>
|
|
@@ -1,159 +1,176 @@
|
|
|
1
|
-
import React, { useState, useRef, useEffect } from 'react';
|
|
2
|
-
import { gsap } from "gsap";
|
|
3
|
-
const NavMenu = ({
|
|
4
|
-
indicatorColor = "#a3e635",
|
|
5
|
-
backgroundColor = "rgba(17, 24, 39, 0.5)",
|
|
6
|
-
activeColor = "#ffffff",
|
|
7
|
-
indicatorAnimation = "elastic",
|
|
8
|
-
onNavItemClick
|
|
9
|
-
}) => {
|
|
10
|
-
const [activeIdx, setActiveIdx] = useState(0);
|
|
11
|
-
const navItemsRef = useRef([]);
|
|
12
|
-
const indicatorRef = useRef(null);
|
|
13
|
-
const navContainerRef = useRef(null);
|
|
14
|
-
useEffect(() => {
|
|
15
|
-
if (!navContainerRef.current) return;
|
|
16
|
-
gsap.fromTo(
|
|
17
|
-
navContainerRef.current,
|
|
18
|
-
{ width: 0, opacity: 0 },
|
|
19
|
-
{ width: "auto", opacity: 1, duration: 0.8, ease: "power2.out", delay: 0.5 }
|
|
20
|
-
);
|
|
21
|
-
}, []);
|
|
22
|
-
|
|
23
|
-
useEffect(() => {
|
|
24
|
-
const indicator = indicatorRef.current;
|
|
25
|
-
const el = navItemsRef.current[activeIdx];
|
|
26
|
-
|
|
27
|
-
if (!indicator || !el) return;
|
|
28
|
-
|
|
29
|
-
const getEase = () => {
|
|
30
|
-
switch (indicatorAnimation) {
|
|
31
|
-
case "spring": return "bounce.out";
|
|
32
|
-
case "power": return "power3.out";
|
|
33
|
-
case "elastic":
|
|
34
|
-
default: return "elastic.out(1, 0.6)";
|
|
35
|
-
}
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
const moveWithGSAP = () => {
|
|
39
|
-
const rect = el.getBoundingClientRect();
|
|
40
|
-
const parentRect = el.parentElement.getBoundingClientRect();
|
|
41
|
-
const left = rect.left - parentRect.left;
|
|
42
|
-
const width = rect.width;
|
|
43
|
-
|
|
44
|
-
gsap.to(indicator, {
|
|
45
|
-
x: left,
|
|
46
|
-
width,
|
|
47
|
-
duration: 0.5,
|
|
48
|
-
ease: getEase(),
|
|
49
|
-
});
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
const timeoutId = setTimeout(moveWithGSAP, 50);
|
|
53
|
-
return () => clearTimeout(timeoutId);
|
|
54
|
-
|
|
55
|
-
}, [activeIdx, indicatorAnimation]);
|
|
56
|
-
|
|
57
|
-
// Handle resize
|
|
58
|
-
useEffect(() => {
|
|
59
|
-
const handleResize = () => {
|
|
60
|
-
const el = navItemsRef.current[activeIdx];
|
|
61
|
-
if (el) {
|
|
62
|
-
const rect = el.getBoundingClientRect();
|
|
63
|
-
const parentRect = el.parentElement.getBoundingClientRect();
|
|
64
|
-
const left = rect.left - parentRect.left;
|
|
65
|
-
const width = rect.width;
|
|
66
|
-
|
|
67
|
-
gsap.set(indicatorRef.current, {
|
|
68
|
-
x: left,
|
|
69
|
-
width: width
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
window.addEventListener("resize", handleResize);
|
|
75
|
-
return () => window.removeEventListener("resize", handleResize);
|
|
76
|
-
}, [activeIdx]);
|
|
77
|
-
|
|
78
|
-
const navItems = ['Home', 'Docs', 'UI Kit', 'Contact'];
|
|
79
|
-
|
|
80
|
-
return (
|
|
81
|
-
<nav
|
|
82
|
-
ref={navContainerRef}
|
|
83
|
-
className="rounded px-3 py-2 shadow-lg border border-white/10 overflow-hidden"
|
|
84
|
-
style={{ backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)', background: backgroundColor , }}
|
|
85
|
-
>
|
|
86
|
-
<div
|
|
87
|
-
ref={indicatorRef}
|
|
88
|
-
className="absolute top-1.5 h-[calc(100%-0.75rem)] rounded-lg z-0 "
|
|
89
|
-
style={{ background: indicatorColor, opacity: 0.3 }}
|
|
90
|
-
/>
|
|
91
|
-
<div className="relative flex gap-2 z-10 whitespace-nowrap">
|
|
92
|
-
{navItems.map((label, i) => (
|
|
93
|
-
<button
|
|
94
|
-
key={label}
|
|
95
|
-
ref={(el) => (navItemsRef.current[i] = el)}
|
|
96
|
-
onClick={() => {
|
|
97
|
-
setActiveIdx(i);
|
|
98
|
-
if (onNavItemClick) onNavItemClick();
|
|
99
|
-
}}
|
|
100
|
-
className={`nav-item px-6 py-2 text-base font-semibold rounded-full relative z-10 transition-colors duration-300 ${i === activeIdx ? '' : 'text-gray-400 hover:text-white'}`}
|
|
101
|
-
style={{ color: i === activeIdx ? activeColor : undefined }}
|
|
102
|
-
>
|
|
103
|
-
{label}
|
|
104
|
-
</button>
|
|
105
|
-
))}
|
|
106
|
-
</div>
|
|
107
|
-
</nav>
|
|
108
|
-
);
|
|
109
|
-
};
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
// Responsive Wrapper Component
|
|
113
|
-
const ResponsiveNavMenu = (props) => {
|
|
114
|
-
const [isOpen, setIsOpen] = useState(false);
|
|
115
|
-
const navItems = ['Home', 'Docs', 'UI Kit', 'Contact'];
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
1
|
+
import React, { useState, useRef, useEffect, useLayoutEffect } from 'react';
|
|
2
|
+
import { gsap } from "gsap";
|
|
3
|
+
const NavMenu = ({
|
|
4
|
+
indicatorColor = "#a3e635",
|
|
5
|
+
backgroundColor = "rgba(17, 24, 39, 0.5)",
|
|
6
|
+
activeColor = "#ffffff",
|
|
7
|
+
indicatorAnimation = "elastic",
|
|
8
|
+
onNavItemClick
|
|
9
|
+
}) => {
|
|
10
|
+
const [activeIdx, setActiveIdx] = useState(0);
|
|
11
|
+
const navItemsRef = useRef([]);
|
|
12
|
+
const indicatorRef = useRef(null);
|
|
13
|
+
const navContainerRef = useRef(null);
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
if (!navContainerRef.current) return;
|
|
16
|
+
gsap.fromTo(
|
|
17
|
+
navContainerRef.current,
|
|
18
|
+
{ width: 0, opacity: 0 },
|
|
19
|
+
{ width: "auto", opacity: 1, duration: 0.8, ease: "power2.out", delay: 0.5 }
|
|
20
|
+
);
|
|
21
|
+
}, []);
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
const indicator = indicatorRef.current;
|
|
25
|
+
const el = navItemsRef.current[activeIdx];
|
|
26
|
+
|
|
27
|
+
if (!indicator || !el) return;
|
|
28
|
+
|
|
29
|
+
const getEase = () => {
|
|
30
|
+
switch (indicatorAnimation) {
|
|
31
|
+
case "spring": return "bounce.out";
|
|
32
|
+
case "power": return "power3.out";
|
|
33
|
+
case "elastic":
|
|
34
|
+
default: return "elastic.out(1, 0.6)";
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const moveWithGSAP = () => {
|
|
39
|
+
const rect = el.getBoundingClientRect();
|
|
40
|
+
const parentRect = el.parentElement.getBoundingClientRect();
|
|
41
|
+
const left = rect.left - parentRect.left;
|
|
42
|
+
const width = rect.width;
|
|
43
|
+
|
|
44
|
+
gsap.to(indicator, {
|
|
45
|
+
x: left,
|
|
46
|
+
width,
|
|
47
|
+
duration: 0.5,
|
|
48
|
+
ease: getEase(),
|
|
49
|
+
});
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const timeoutId = setTimeout(moveWithGSAP, 50);
|
|
53
|
+
return () => clearTimeout(timeoutId);
|
|
54
|
+
|
|
55
|
+
}, [activeIdx, indicatorAnimation]);
|
|
56
|
+
|
|
57
|
+
// Handle resize
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
const handleResize = () => {
|
|
60
|
+
const el = navItemsRef.current[activeIdx];
|
|
61
|
+
if (el) {
|
|
62
|
+
const rect = el.getBoundingClientRect();
|
|
63
|
+
const parentRect = el.parentElement.getBoundingClientRect();
|
|
64
|
+
const left = rect.left - parentRect.left;
|
|
65
|
+
const width = rect.width;
|
|
66
|
+
|
|
67
|
+
gsap.set(indicatorRef.current, {
|
|
68
|
+
x: left,
|
|
69
|
+
width: width
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
window.addEventListener("resize", handleResize);
|
|
75
|
+
return () => window.removeEventListener("resize", handleResize);
|
|
76
|
+
}, [activeIdx]);
|
|
77
|
+
|
|
78
|
+
const navItems = ['Home', 'Docs', 'UI Kit', 'Contact'];
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<nav
|
|
82
|
+
ref={navContainerRef}
|
|
83
|
+
className="relative rounded px-3 py-2 shadow-lg border border-white/10 overflow-hidden"
|
|
84
|
+
style={{ backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)', background: backgroundColor , }}
|
|
85
|
+
>
|
|
86
|
+
<div
|
|
87
|
+
ref={indicatorRef}
|
|
88
|
+
className="absolute top-1.5 h-[calc(100%-0.75rem)] rounded-lg z-0 "
|
|
89
|
+
style={{ background: indicatorColor, opacity: 0.3 }}
|
|
90
|
+
/>
|
|
91
|
+
<div className="relative flex gap-2 z-10 whitespace-nowrap">
|
|
92
|
+
{navItems.map((label, i) => (
|
|
93
|
+
<button
|
|
94
|
+
key={label}
|
|
95
|
+
ref={(el) => (navItemsRef.current[i] = el)}
|
|
96
|
+
onClick={() => {
|
|
97
|
+
setActiveIdx(i);
|
|
98
|
+
if (onNavItemClick) onNavItemClick();
|
|
99
|
+
}}
|
|
100
|
+
className={`nav-item px-6 py-2 text-base font-semibold rounded-full relative z-10 transition-colors duration-300 ${i === activeIdx ? '' : 'text-gray-400 hover:text-white'}`}
|
|
101
|
+
style={{ color: i === activeIdx ? activeColor : undefined }}
|
|
102
|
+
>
|
|
103
|
+
{label}
|
|
104
|
+
</button>
|
|
105
|
+
))}
|
|
106
|
+
</div>
|
|
107
|
+
</nav>
|
|
108
|
+
);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
// Responsive Wrapper Component
|
|
113
|
+
const ResponsiveNavMenu = (props) => {
|
|
114
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
115
|
+
const navItems = ['Home', 'Docs', 'UI Kit', 'Contact'];
|
|
116
|
+
// Switch to the menu button when the space it sits in is narrow (not the
|
|
117
|
+
// screen), so it also fits sidebars, preview frames and small columns.
|
|
118
|
+
const wrap = useRef(null);
|
|
119
|
+
const [narrow, setNarrow] = useState(false);
|
|
120
|
+
|
|
121
|
+
useLayoutEffect(() => {
|
|
122
|
+
const el = wrap.current;
|
|
123
|
+
if (!el) return undefined;
|
|
124
|
+
const ro = new ResizeObserver(() => {
|
|
125
|
+
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
|
126
|
+
setNarrow(el.clientWidth / rem < 34);
|
|
127
|
+
});
|
|
128
|
+
ro.observe(el);
|
|
129
|
+
return () => ro.disconnect();
|
|
130
|
+
}, []);
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<nav ref={wrap} className="flex items-center justify-center p-4 sticky top-0 left-0 w-full z-50 bg-transparent">
|
|
134
|
+
{!narrow ? (
|
|
135
|
+
<div className="flex w-full justify-center">
|
|
136
|
+
<NavMenu {...props} />
|
|
137
|
+
</div>
|
|
138
|
+
) : (
|
|
139
|
+
<div className="flex w-full justify-end">
|
|
140
|
+
{/* Same backdrop as the desktop bar, so the icon stays visible on light pages too. */}
|
|
141
|
+
<button
|
|
142
|
+
onClick={() => setIsOpen(!isOpen)}
|
|
143
|
+
aria-label={isOpen ? "Close menu" : "Open menu"}
|
|
144
|
+
className="rounded-full border border-white/10 p-2.5 text-white shadow-lg focus:outline-none"
|
|
145
|
+
style={{ backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)', background: props.backgroundColor ?? 'rgba(17, 24, 39, 0.7)' }}
|
|
146
|
+
>
|
|
147
|
+
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
|
148
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={isOpen ? "M6 18L18 6M6 6l12 12" : "M4 6h16M4 12h16m-7 6h7"} />
|
|
149
|
+
</svg>
|
|
150
|
+
</button>
|
|
151
|
+
</div>
|
|
152
|
+
)}
|
|
153
|
+
|
|
154
|
+
{narrow && isOpen && (
|
|
155
|
+
<div className="absolute top-20 left-4 right-4 rounded-xl shadow-lg border border-white/10"
|
|
156
|
+
style={{ backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)', background: 'rgba(17, 24, 39, 0.7)' }}>
|
|
157
|
+
<div className="flex flex-col items-center gap-2 p-4">
|
|
158
|
+
{navItems.map((label) => (
|
|
159
|
+
<button
|
|
160
|
+
key={label}
|
|
161
|
+
onClick={() => {
|
|
162
|
+
setIsOpen(false);
|
|
163
|
+
props.onNavItemClick?.();
|
|
164
|
+
}}
|
|
165
|
+
className="w-full text-center px-6 py-3 text-lg font-semibold rounded-full text-gray-300 hover:text-white hover:bg-white/10 transition-colors duration-300"
|
|
166
|
+
>
|
|
167
|
+
{label}
|
|
168
|
+
</button>
|
|
169
|
+
))}
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
)}
|
|
173
|
+
</nav>
|
|
174
|
+
);
|
|
175
|
+
};
|
|
159
176
|
export default ResponsiveNavMenu;
|
|
@@ -1,120 +1,140 @@
|
|
|
1
|
-
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
1
|
+
import React, { useLayoutEffect, useRef, useState } from 'react';
|
|
2
|
+
import { motion, AnimatePresence } from "framer-motion";
|
|
3
|
+
import { ArrowLeft, ArrowRight } from 'lucide-react';
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
// --- Testimonials Component ---
|
|
7
|
+
const Testimonials = ({ testimonials }) => {
|
|
8
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
9
|
+
// The layout follows the card's own width (not the screen), so it also
|
|
10
|
+
// fits sidebars, preview frames and narrow columns.
|
|
11
|
+
const root = useRef(null);
|
|
12
|
+
const [size, setSize] = useState("lg");
|
|
13
|
+
|
|
14
|
+
useLayoutEffect(() => {
|
|
15
|
+
const el = root.current;
|
|
16
|
+
if (!el) return undefined;
|
|
17
|
+
const ro = new ResizeObserver(() => {
|
|
18
|
+
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
|
19
|
+
const w = el.clientWidth / rem;
|
|
20
|
+
setSize(w < 26 ? "sm" : w < 44 ? "md" : "lg");
|
|
21
|
+
});
|
|
22
|
+
ro.observe(el);
|
|
23
|
+
return () => ro.disconnect();
|
|
24
|
+
}, []);
|
|
25
|
+
|
|
26
|
+
const wide = size === "lg";
|
|
27
|
+
const small = size === "sm";
|
|
28
|
+
|
|
29
|
+
const handleNext = () => {
|
|
30
|
+
setActiveIndex((prevIndex) => (prevIndex + 1) % testimonials.length);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const handlePrev = () => {
|
|
34
|
+
setActiveIndex((prevIndex) => (prevIndex - 1 + testimonials.length) % testimonials.length);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Animation variants for the text content
|
|
38
|
+
const textVariants = {
|
|
39
|
+
enter: { opacity: 0, y: 20, filter: "blur(5px)" },
|
|
40
|
+
center: { opacity: 1, y: 0, filter: "blur(0px)" },
|
|
41
|
+
exit: { opacity: 0, y: -20, filter: "blur(5px)" },
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div ref={root} className={`w-full max-w-4xl mx-auto bg-slate-950 rounded-2xl border border-slate-800 grid items-center overflow-hidden ${wide ? "grid-cols-2 gap-12 p-12" : small ? "gap-6 p-5" : "gap-8 p-8"}`}>
|
|
46
|
+
|
|
47
|
+
{/* Left Part: Text Content and Navigation */}
|
|
48
|
+
<div className={`flex flex-col justify-center h-full ${wide ? "text-left order-1" : "text-center order-2"}`}>
|
|
49
|
+
{/* Title, subtitle and quote with animation; the min height keeps the buttons from jumping. */}
|
|
50
|
+
<div className={`relative ${small ? "min-h-[15rem]" : "min-h-[12rem]"}`}>
|
|
51
|
+
<AnimatePresence initial={false} mode="wait">
|
|
52
|
+
<motion.div
|
|
53
|
+
key={activeIndex}
|
|
54
|
+
variants={textVariants}
|
|
55
|
+
initial="enter"
|
|
56
|
+
animate="center"
|
|
57
|
+
exit="exit"
|
|
58
|
+
transition={{ duration: 0.4, ease: "easeInOut" }}
|
|
59
|
+
className="w-full"
|
|
60
|
+
>
|
|
61
|
+
<h2 className={`${small ? "text-2xl" : "text-3xl"} font-bold text-white tracking-tight`}>{testimonials[activeIndex].author}</h2>
|
|
62
|
+
<p className="mt-2 text-lime-400 font-medium">{testimonials[activeIndex].title}</p>
|
|
63
|
+
<p className="mt-4 text-slate-400 text-sm leading-relaxed">"{testimonials[activeIndex].text}"</p>
|
|
64
|
+
</motion.div>
|
|
65
|
+
</AnimatePresence>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
{/* Navigation Buttons with Microinteractions */}
|
|
69
|
+
<div className={`flex items-center gap-4 ${wide ? "justify-start mt-8" : "justify-center mt-5"}`}>
|
|
70
|
+
<motion.button
|
|
71
|
+
onClick={handlePrev}
|
|
72
|
+
aria-label="Previous testimonial"
|
|
73
|
+
className="bg-slate-800/80 hover:bg-slate-800 rounded-full p-3 transition-colors duration-300 text-slate-400 hover:text-lime-400 focus:outline-none"
|
|
74
|
+
whileHover={{ scale: 1.1 }}
|
|
75
|
+
whileTap={{ scale: 0.95 }}
|
|
76
|
+
transition={{ type: 'spring', stiffness: 400, damping: 17 }}
|
|
77
|
+
>
|
|
78
|
+
<ArrowLeft size={20} />
|
|
79
|
+
</motion.button>
|
|
80
|
+
<motion.button
|
|
81
|
+
onClick={handleNext}
|
|
82
|
+
aria-label="Next testimonial"
|
|
83
|
+
className="bg-slate-800/80 hover:bg-slate-800 rounded-full p-3 transition-colors duration-300 text-slate-400 hover:text-lime-400 focus:outline-none"
|
|
84
|
+
whileHover={{ scale: 1.1 }}
|
|
85
|
+
whileTap={{ scale: 0.95 }}
|
|
86
|
+
transition={{ type: 'spring', stiffness: 400, damping: 17 }}
|
|
87
|
+
>
|
|
88
|
+
<ArrowRight size={20} />
|
|
89
|
+
</motion.button>
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
{/* Right Part: Stacked Images */}
|
|
94
|
+
<div className={`flex w-full items-center justify-center ${wide ? "order-2" : "order-1"}`}>
|
|
95
|
+
{/* Sized to the card plus the fan, so the stack stays centred and inside the panel. */}
|
|
96
|
+
<div className={`relative ${small ? "h-[calc(14rem+30px)] w-[calc(10.5rem+60px)]" : "h-[calc(20rem+30px)] w-[calc(16rem+60px)]"}`}>
|
|
97
|
+
{testimonials.map((testimonial, index) => {
|
|
98
|
+
// Determine the position of the card in the stack
|
|
99
|
+
const position = (index - activeIndex + testimonials.length) % testimonials.length;
|
|
100
|
+
|
|
101
|
+
// Only render the top 4 cards for performance and visual clarity
|
|
102
|
+
if (position > 3) return null;
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<motion.div
|
|
106
|
+
key={testimonial.id || index}
|
|
107
|
+
className={`absolute ${small ? "w-[10.5rem] h-56" : "w-64 h-80"} bg-slate-800 rounded-xl shadow-2xl overflow-hidden border-2 border-slate-700`}
|
|
108
|
+
initial={false}
|
|
109
|
+
animate={{
|
|
110
|
+
scale: 1 - position * 0.08,
|
|
111
|
+
rotate: -4 + position * 4, // Add a fanned rotation effect
|
|
112
|
+
left: `${position * 20}px`, // Fan out horizontally
|
|
113
|
+
top: `${position * 10}px`, // Stagger vertically
|
|
114
|
+
zIndex: testimonials.length - position,
|
|
115
|
+
opacity: position === 0 ? 1 : 0.5, // Only top image is fully opaque
|
|
116
|
+
}}
|
|
117
|
+
transition={{
|
|
118
|
+
type: 'spring',
|
|
119
|
+
stiffness: 260,
|
|
120
|
+
damping: 25,
|
|
121
|
+
}}
|
|
122
|
+
>
|
|
123
|
+
<img
|
|
124
|
+
src={testimonial.image}
|
|
125
|
+
alt={testimonial.author}
|
|
126
|
+
className="w-full h-full object-cover"
|
|
127
|
+
onError={(e) => { e.target.onerror = null; e.target.src = `https://placehold.co/256x320/0f172a/a3e635?text=${testimonial.author.split(' ')[0]}`; }}
|
|
128
|
+
/>
|
|
129
|
+
</motion.div>
|
|
130
|
+
);
|
|
131
|
+
})}
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
export default Testimonials;
|
package/dist/cli/index.js
CHANGED
|
@@ -40,7 +40,6 @@ const componentMap = {
|
|
|
40
40
|
"rain-background": "RainBackground",
|
|
41
41
|
"ripple-background": "RippleBackground",
|
|
42
42
|
"scroll-marquee": "ScrollMarquee",
|
|
43
|
-
"smart-grid-card": "SmartGridCard",
|
|
44
43
|
"smoke-background": "SmokeBackground",
|
|
45
44
|
"tooltip": "ToolTip",
|
|
46
45
|
"tool-tip": "ToolTip",
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "apex-ui-kit",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.12.0",
|
|
4
4
|
"license": "MIT",
|
|
5
|
-
"description": "
|
|
5
|
+
"description": "117 free, animated UI components for React, styled with Tailwind CSS. Add any of them with one CLI command.",
|
|
6
6
|
"homepage": "https://apex-ui.in",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
@@ -1,243 +0,0 @@
|
|
|
1
|
-
import React, { useEffect, useRef, useState } from "react";
|
|
2
|
-
import { gsap } from "gsap";
|
|
3
|
-
import {
|
|
4
|
-
Box,
|
|
5
|
-
Settings,
|
|
6
|
-
Lock,
|
|
7
|
-
Sparkles,
|
|
8
|
-
Search,
|
|
9
|
-
LaptopMinimal,
|
|
10
|
-
} from "lucide-react";
|
|
11
|
-
|
|
12
|
-
const CARD_HEIGHT = 264;
|
|
13
|
-
const CARD_EXPANDED_HEIGHT = 2 * CARD_HEIGHT + 10; // two rows plus the grid gap
|
|
14
|
-
|
|
15
|
-
const SmartGridCard = ({
|
|
16
|
-
cards = defaultCards(),
|
|
17
|
-
mergeMap = {}, // Example: { 5: 2 }
|
|
18
|
-
borderColor = "cyan"
|
|
19
|
-
}) => {
|
|
20
|
-
// Track screen width for responsive mergeMap logic
|
|
21
|
-
const [screenWidth, setScreenWidth] = useState(() => typeof window !== 'undefined' ? window.innerWidth : 1200);
|
|
22
|
-
useEffect(() => {
|
|
23
|
-
const handleResize = () => setScreenWidth(window.innerWidth);
|
|
24
|
-
window.addEventListener('resize', handleResize);
|
|
25
|
-
return () => window.removeEventListener('resize', handleResize);
|
|
26
|
-
}, []);
|
|
27
|
-
const isLargeScreen = screenWidth >= 1080;
|
|
28
|
-
const isThreeCol = screenWidth >= 1024;
|
|
29
|
-
const isTwoCol = screenWidth >= 768 && screenWidth < 1024;
|
|
30
|
-
const cardRefs = useRef({});
|
|
31
|
-
const [mousePos, setMousePos] = useState({});
|
|
32
|
-
|
|
33
|
-
useEffect(() => {
|
|
34
|
-
if (!isLargeScreen) return; // Only run mergeMap effect on large screens
|
|
35
|
-
// Defensive: never trigger a reload, only animate
|
|
36
|
-
if (Object.keys(mergeMap).length === 0) return; // No merges to process
|
|
37
|
-
// Animate cards based on mergeMap
|
|
38
|
-
Object.entries(mergeMap).forEach(([sourceId, targetId]) => {
|
|
39
|
-
const sourceEl = cardRefs.current[sourceId];
|
|
40
|
-
const targetEl = cardRefs.current[targetId];
|
|
41
|
-
|
|
42
|
-
if (targetEl) {
|
|
43
|
-
gsap.to(targetEl, {
|
|
44
|
-
height: CARD_EXPANDED_HEIGHT,
|
|
45
|
-
duration: 0.5,
|
|
46
|
-
ease: "power2.out",
|
|
47
|
-
});
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
if (sourceEl) {
|
|
51
|
-
gsap.to(sourceEl, {
|
|
52
|
-
opacity: 0,
|
|
53
|
-
scale: 0.9,
|
|
54
|
-
duration: 0.3,
|
|
55
|
-
onComplete: () => {
|
|
56
|
-
if (sourceEl) sourceEl.style.display = "none";
|
|
57
|
-
},
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
});
|
|
61
|
-
// No reload, no navigation, no window.location
|
|
62
|
-
return () => { };
|
|
63
|
-
}, [mergeMap, isLargeScreen]);
|
|
64
|
-
|
|
65
|
-
const handleMouseMove = (e, id) => {
|
|
66
|
-
const bounds = e.currentTarget.getBoundingClientRect();
|
|
67
|
-
const x = e.clientX - bounds.left;
|
|
68
|
-
const y = e.clientY - bounds.top;
|
|
69
|
-
setMousePos((prev) => ({
|
|
70
|
-
...prev,
|
|
71
|
-
[id]: { x, y },
|
|
72
|
-
}));
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
const getCardStyle = (id) => {
|
|
76
|
-
// <768px: 1 column
|
|
77
|
-
if (screenWidth < 768) {
|
|
78
|
-
return {
|
|
79
|
-
display: 'block',
|
|
80
|
-
height: CARD_HEIGHT,
|
|
81
|
-
gridColumnStart: 'auto',
|
|
82
|
-
gridRowStart: 'auto',
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
// 768px - 1023px: 2 columns
|
|
86
|
-
if (isTwoCol) {
|
|
87
|
-
const col = ((id - 1) % 2) + 1;
|
|
88
|
-
const row = Math.floor((id - 1) / 2) + 1;
|
|
89
|
-
return {
|
|
90
|
-
display: 'block',
|
|
91
|
-
height: CARD_HEIGHT,
|
|
92
|
-
gridColumnStart: col,
|
|
93
|
-
gridRowStart: row,
|
|
94
|
-
};
|
|
95
|
-
}
|
|
96
|
-
// 1024px and above: 3 columns (mergeMap only for >=1080px)
|
|
97
|
-
if (isThreeCol) {
|
|
98
|
-
if (screenWidth < 1080) {
|
|
99
|
-
const col = ((id - 1) % 3) + 1;
|
|
100
|
-
const row = Math.floor((id - 1) / 3) + 1;
|
|
101
|
-
return {
|
|
102
|
-
display: 'block',
|
|
103
|
-
height: CARD_HEIGHT,
|
|
104
|
-
gridColumnStart: col,
|
|
105
|
-
gridRowStart: row,
|
|
106
|
-
};
|
|
107
|
-
}
|
|
108
|
-
// >=1080px: 3 columns, merging enabled
|
|
109
|
-
const isMerged = mergeMap[id];
|
|
110
|
-
const isTarget = Object.values(mergeMap).includes(id);
|
|
111
|
-
if (isMerged) return { display: "none" };
|
|
112
|
-
const col = ((id - 1) % 3) + 1;
|
|
113
|
-
const row = Math.floor((id - 1) / 3) + 1;
|
|
114
|
-
return {
|
|
115
|
-
gridColumnStart: col,
|
|
116
|
-
gridRowStart: row,
|
|
117
|
-
height: isTarget ? CARD_EXPANDED_HEIGHT : CARD_HEIGHT,
|
|
118
|
-
};
|
|
119
|
-
}
|
|
120
|
-
};
|
|
121
|
-
|
|
122
|
-
return (
|
|
123
|
-
<div
|
|
124
|
-
className={`grid gap-2 justify-center p-4 w-full ${screenWidth >= 1024 ? 'grid-cols-3' : screenWidth >= 768 ? 'grid-cols-2' : 'grid-cols-1'}`}
|
|
125
|
-
style={screenWidth >= 1024 ? {
|
|
126
|
-
// minmax(0, …): columns share the container's width instead of overflowing it.
|
|
127
|
-
gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
|
|
128
|
-
gridTemplateRows: `repeat(2, ${CARD_HEIGHT}px)`
|
|
129
|
-
} : screenWidth >= 768 ? {
|
|
130
|
-
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
|
|
131
|
-
gridTemplateRows: `repeat(3, ${CARD_HEIGHT}px)`
|
|
132
|
-
} : {}}
|
|
133
|
-
>
|
|
134
|
-
{cards.map((card) => {
|
|
135
|
-
const style = getCardStyle(card.id);
|
|
136
|
-
if (style.display === "none") return null;
|
|
137
|
-
|
|
138
|
-
const pos = mousePos[card.id] || { x: 0, y: 0 };
|
|
139
|
-
|
|
140
|
-
return (
|
|
141
|
-
<div
|
|
142
|
-
key={card.id}
|
|
143
|
-
ref={(el) => (cardRefs.current[card.id] = el)}
|
|
144
|
-
style={style}
|
|
145
|
-
onMouseMove={(e) => handleMouseMove(e, card.id)}
|
|
146
|
-
className="group relative overflow-hidden p-[1px] rounded-2xl bg-zinc-900 border border-zinc-700 shadow-2xl transition-all duration-500"
|
|
147
|
-
>
|
|
148
|
-
{/* Mouse Glow Border */}
|
|
149
|
-
<div
|
|
150
|
-
className="pointer-events-none absolute inset-0 rounded-2xl opacity-0 group-hover:opacity-100 transition duration-300"
|
|
151
|
-
style={{
|
|
152
|
-
maskImage: `radial-gradient(circle at ${pos.x}px ${pos.y}px, white 40%, transparent 80%)`,
|
|
153
|
-
WebkitMaskImage: `radial-gradient(circle at ${pos.x}px ${pos.y}px, white 40%, transparent 80%)`,
|
|
154
|
-
background: `radial-gradient(circle at ${pos.x}px ${pos.y}px, ${borderColor}, transparent 80%)`,
|
|
155
|
-
borderRadius: "1rem",
|
|
156
|
-
}}
|
|
157
|
-
/>
|
|
158
|
-
|
|
159
|
-
{/* Card Content */}
|
|
160
|
-
{/* Compact enough that the button always fits inside the fixed card height */}
|
|
161
|
-
<div className="relative z-10 flex flex-col items-start h-full w-full p-5 rounded-2xl bg-zinc-900 text-white gap-3">
|
|
162
|
-
<div className="flex h-11 w-11 shrink-0 items-center justify-center border border-dashed rounded-lg border-purple-600">{card.icon}</div>
|
|
163
|
-
<div className="min-h-0">
|
|
164
|
-
<h3 className="text-lg font-bold mb-1.5 break-words leading-tight">{card.title}</h3>
|
|
165
|
-
<p className="text-sm leading-relaxed w-full text-white/70 break-words line-clamp-3">{card.description}</p>
|
|
166
|
-
</div>
|
|
167
|
-
<button
|
|
168
|
-
type="button"
|
|
169
|
-
className="mt-auto shrink-0 relative overflow-hidden whitespace-nowrap px-4 py-2 rounded-lg border border-purple-400/60 text-white transition hover:text-white bg-black/20 group flex items-center gap-2 text-sm"
|
|
170
|
-
>
|
|
171
|
-
<span className="relative z-10 flex items-center gap-2">
|
|
172
|
-
{card.buttonText}
|
|
173
|
-
<span className="arrow-icon transition-transform duration-200 group-hover:translate-x-2 flex items-center">
|
|
174
|
-
<svg width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
|
175
|
-
<path d="M5 12h14M13 6l6 6-6 6" />
|
|
176
|
-
</svg>
|
|
177
|
-
</span>
|
|
178
|
-
</span>
|
|
179
|
-
<span
|
|
180
|
-
className="absolute bottom-0 left-0 w-full h-full opacity-0 group-hover:opacity-100 origin-bottom transition-all ease-in-out duration-500 rounded-lg"
|
|
181
|
-
style={{
|
|
182
|
-
background: "radial-gradient(circle at bottom, purple, transparent 69%)",
|
|
183
|
-
mixBlendMode: "screen",
|
|
184
|
-
}}
|
|
185
|
-
/>
|
|
186
|
-
</button>
|
|
187
|
-
</div>
|
|
188
|
-
</div>
|
|
189
|
-
);
|
|
190
|
-
})}
|
|
191
|
-
</div>
|
|
192
|
-
);
|
|
193
|
-
};
|
|
194
|
-
|
|
195
|
-
// Default Cards
|
|
196
|
-
function defaultCards() {
|
|
197
|
-
return [
|
|
198
|
-
{
|
|
199
|
-
id: 1,
|
|
200
|
-
icon: <LaptopMinimal className="w-6 h-6 text-white" />,
|
|
201
|
-
title: "Responsive Layout",
|
|
202
|
-
description: "Works across all screen sizes with smooth animation.",
|
|
203
|
-
buttonText: "Learn More",
|
|
204
|
-
},
|
|
205
|
-
{
|
|
206
|
-
id: 2,
|
|
207
|
-
icon: <Box className="w-6 h-6 text-white" />,
|
|
208
|
-
title: "Composable Components",
|
|
209
|
-
description: "Each card is modular and easy to customize.",
|
|
210
|
-
buttonText: "Explore",
|
|
211
|
-
},
|
|
212
|
-
{
|
|
213
|
-
id: 3,
|
|
214
|
-
icon: <Settings className="w-6 h-6 text-white" />,
|
|
215
|
-
title: "Easy Config",
|
|
216
|
-
description: "Use props like mergeMap to control logic.",
|
|
217
|
-
buttonText: "Settings",
|
|
218
|
-
},
|
|
219
|
-
{
|
|
220
|
-
id: 4,
|
|
221
|
-
icon: <Lock className="w-6 h-6 text-white" />,
|
|
222
|
-
title: "Secure UI",
|
|
223
|
-
description: "Perfect for dashboards and secure apps.",
|
|
224
|
-
buttonText: "Secure Now",
|
|
225
|
-
},
|
|
226
|
-
{
|
|
227
|
-
id: 5,
|
|
228
|
-
icon: <Sparkles className="w-6 h-6 text-white" />,
|
|
229
|
-
title: "Edge Effects",
|
|
230
|
-
description: "Glowing borders that follow your mouse.",
|
|
231
|
-
buttonText: "Try It",
|
|
232
|
-
},
|
|
233
|
-
{
|
|
234
|
-
id: 6,
|
|
235
|
-
icon: <Search className="w-6 h-6 text-white" />,
|
|
236
|
-
title: "Fast Search",
|
|
237
|
-
description: "Lightning-fast component navigation.",
|
|
238
|
-
buttonText: "Search",
|
|
239
|
-
},
|
|
240
|
-
];
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
export default SmartGridCard;
|