apex-ui-kit 2.1.3 → 2.3.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/dist/ApexUI-Kit/templates/AiChat/AiChat.jsx +171 -0
- package/dist/ApexUI-Kit/templates/AiModelPrompt/AiModelPrompt.jsx +221 -0
- package/dist/ApexUI-Kit/templates/AiModelPrompt/index.js +2 -0
- package/dist/ApexUI-Kit/templates/AiModelPrompt/models.js +15 -0
- package/dist/ApexUI-Kit/templates/AiPromptBox/AiPromptBox.jsx +173 -0
- package/dist/ApexUI-Kit/templates/AiResponse/AiResponse.jsx +172 -0
- package/dist/ApexUI-Kit/templates/AiThinking/AiThinking.jsx +106 -0
- package/dist/ApexUI-Kit/templates/AiVoiceBars/AiVoiceBars.jsx +78 -0
- package/dist/ApexUI-Kit/templates/AiVoiceBlob/AiVoiceBlob.jsx +105 -0
- package/dist/ApexUI-Kit/templates/AiVoiceOrb/AiVoiceOrb.jsx +74 -0
- package/dist/ApexUI-Kit/templates/AiVoiceRing/AiVoiceRing.jsx +120 -0
- package/dist/ApexUI-Kit/templates/AiVoiceWave/AiVoiceWave.jsx +107 -0
- package/dist/ApexUI-Kit/templates/AnimatedButton/AnimatedButton.jsx +251 -0
- package/dist/ApexUI-Kit/templates/AnimatedSwitch/AnimatedSwitch.jsx +80 -0
- package/dist/ApexUI-Kit/templates/AnimatedTabs/AnimatedTabs.jsx +107 -0
- package/dist/ApexUI-Kit/templates/AuroraBackground/AuroraBackground.jsx +48 -0
- package/dist/ApexUI-Kit/templates/BlurRevealText/BlurRevealText.jsx +60 -0
- package/dist/ApexUI-Kit/templates/BounceCards/BounceCards.jsx +53 -0
- package/dist/ApexUI-Kit/templates/CardSwap3D/CardSwap3D.jsx +80 -0
- package/dist/ApexUI-Kit/templates/Carousel3D/Carousel3D.jsx +222 -0
- package/dist/ApexUI-Kit/templates/CircularText/CircularText.jsx +49 -0
- package/dist/ApexUI-Kit/templates/ClickSpark/ClickSpark.jsx +72 -0
- package/dist/ApexUI-Kit/templates/Coverflow3D/Coverflow3D.jsx +108 -0
- package/dist/ApexUI-Kit/templates/CubeGallery/CubeGallery.jsx +166 -0
- package/dist/ApexUI-Kit/templates/CursorTrail/CursorTrail.jsx +200 -0
- package/dist/ApexUI-Kit/templates/DotGlobe/DotGlobe.jsx +289 -0
- package/dist/ApexUI-Kit/templates/ElasticSlider/ElasticSlider.jsx +152 -0
- package/dist/ApexUI-Kit/templates/ExtrudedText/ExtrudedText.jsx +78 -0
- package/dist/ApexUI-Kit/templates/FaqAccordion/FaqAccordion.jsx +88 -0
- package/dist/ApexUI-Kit/templates/FlipBook/FlipBook.jsx +191 -0
- package/dist/ApexUI-Kit/templates/FlipClock/FlipClock.jsx +121 -0
- package/dist/ApexUI-Kit/templates/FloatingNavbar/FloatingNavbar.jsx +162 -0
- package/dist/ApexUI-Kit/templates/Folder/Folder.jsx +101 -0
- package/dist/ApexUI-Kit/templates/GlitchText/GlitchText.jsx +56 -0
- package/dist/ApexUI-Kit/templates/GooeyNav/GooeyNav.jsx +310 -0
- package/dist/ApexUI-Kit/templates/HoldToConfirm/HoldToConfirm.jsx +169 -0
- package/dist/ApexUI-Kit/templates/HoldToConfirm/UndoNotice.jsx +58 -0
- package/dist/ApexUI-Kit/templates/HoloCard/HoloCard.jsx +92 -0
- package/dist/ApexUI-Kit/templates/ImageCompare/ImageCompare.jsx +94 -0
- package/dist/ApexUI-Kit/templates/IsoStack/IsoStack.jsx +86 -0
- package/dist/ApexUI-Kit/templates/LiquidGradient/LiquidGradient.jsx +238 -0
- package/dist/ApexUI-Kit/templates/LoadingScreen/LoadingScreen.jsx +805 -0
- package/dist/ApexUI-Kit/templates/MagnetLines/MagnetLines.jsx +67 -0
- package/dist/ApexUI-Kit/templates/MagneticCursor/MagneticCursor.jsx +148 -0
- package/dist/ApexUI-Kit/templates/PixelTransition/PixelTransition.jsx +75 -0
- package/dist/ApexUI-Kit/templates/PricingCards/PricingCards.jsx +115 -0
- package/dist/ApexUI-Kit/templates/ProfileCard/ProfileCard.jsx +213 -0
- package/dist/ApexUI-Kit/templates/ProximityText/ProximityText.jsx +66 -0
- package/dist/ApexUI-Kit/templates/RotatingText/RotatingText.jsx +50 -0
- package/dist/ApexUI-Kit/templates/ShinyText/ShinyText.jsx +30 -0
- package/dist/ApexUI-Kit/templates/SidebarNav/SidebarNav.jsx +220 -0
- package/dist/ApexUI-Kit/templates/SpotlightGrid/SpotlightGrid.jsx +110 -0
- package/dist/ApexUI-Kit/templates/StarBorder/StarBorder.jsx +33 -0
- package/dist/ApexUI-Kit/templates/Stepper/Stepper.jsx +129 -0
- package/dist/ApexUI-Kit/templates/SwipeCards/SwipeCards.jsx +123 -0
- package/dist/ApexUI-Kit/templates/TextRing3D/TextRing3D.jsx +73 -0
- package/dist/ApexUI-Kit/templates/ToastStack/ToastStack.jsx +108 -0
- package/dist/ApexUI-Kit/templates/ToolTip/ToolTip.jsx +5 -3
- package/dist/ApexUI-Kit/templates/TrueFocus/TrueFocus.jsx +64 -0
- package/dist/ApexUI-Kit/templates/TypewriterText/TypewriterText.jsx +70 -0
- package/dist/ApexUI-Kit/templates/WebGLCarousel/WebGLCarousel.jsx +326 -0
- package/dist/ApexUI-Kit/templates/WorldMap/WorldMap.jsx +359 -0
- package/dist/cli/index.js +63 -0
- package/package.json +1 -1
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { useReducedMotion } from "framer-motion";
|
|
3
|
+
import { Check } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* Hold to Confirm: for actions you don't want to trigger by accident. Press
|
|
7
|
+
* and hold (pointer, Enter or Space) and a solid copy of the button wipes
|
|
8
|
+
* in from the left; let go, slide off the button or lose focus and it snaps
|
|
9
|
+
* back. Once the wipe finishes it confirms and resets after `resetAfter`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const HoldToConfirm = ({ children = "Hold to confirm", className = "", confirmedContent, disabled, duration = 1600, onConfirm, resetAfter = 1800, ...buttonProps }) => {
|
|
13
|
+
const buttonRef = useRef(null);
|
|
14
|
+
const confirmTimerRef = useRef(null);
|
|
15
|
+
const resetTimerRef = useRef(null);
|
|
16
|
+
const activePointerIdRef = useRef(null);
|
|
17
|
+
const inputModeRef = useRef("pointer");
|
|
18
|
+
const isHoldingRef = useRef(false);
|
|
19
|
+
const [inputMode, setInputMode] = useState(null);
|
|
20
|
+
const [status, setStatus] = useState("idle"); // idle | holding | confirmed
|
|
21
|
+
const shouldReduceMotion = useReducedMotion();
|
|
22
|
+
|
|
23
|
+
const clearConfirmTimer = useCallback(() => {
|
|
24
|
+
if (confirmTimerRef.current === null) return;
|
|
25
|
+
window.clearTimeout(confirmTimerRef.current);
|
|
26
|
+
confirmTimerRef.current = null;
|
|
27
|
+
}, []);
|
|
28
|
+
|
|
29
|
+
const completeHold = useCallback(() => {
|
|
30
|
+
if (!isHoldingRef.current) return;
|
|
31
|
+
isHoldingRef.current = false;
|
|
32
|
+
activePointerIdRef.current = null;
|
|
33
|
+
clearConfirmTimer();
|
|
34
|
+
setStatus("confirmed");
|
|
35
|
+
onConfirm?.(inputModeRef.current);
|
|
36
|
+
if (resetAfter > 0) {
|
|
37
|
+
resetTimerRef.current = window.setTimeout(() => {
|
|
38
|
+
setStatus("idle");
|
|
39
|
+
resetTimerRef.current = null;
|
|
40
|
+
}, resetAfter);
|
|
41
|
+
}
|
|
42
|
+
}, [clearConfirmTimer, onConfirm, resetAfter]);
|
|
43
|
+
|
|
44
|
+
const cancelHold = useCallback(() => {
|
|
45
|
+
if (!isHoldingRef.current) return;
|
|
46
|
+
isHoldingRef.current = false;
|
|
47
|
+
activePointerIdRef.current = null;
|
|
48
|
+
clearConfirmTimer();
|
|
49
|
+
setStatus("idle");
|
|
50
|
+
}, [clearConfirmTimer]);
|
|
51
|
+
|
|
52
|
+
const startHold = useCallback(
|
|
53
|
+
(input) => {
|
|
54
|
+
if (disabled || status === "confirmed" || isHoldingRef.current) return;
|
|
55
|
+
if (resetTimerRef.current !== null) {
|
|
56
|
+
window.clearTimeout(resetTimerRef.current);
|
|
57
|
+
resetTimerRef.current = null;
|
|
58
|
+
}
|
|
59
|
+
inputModeRef.current = input;
|
|
60
|
+
setInputMode(input);
|
|
61
|
+
isHoldingRef.current = true;
|
|
62
|
+
setStatus("holding");
|
|
63
|
+
confirmTimerRef.current = window.setTimeout(completeHold, duration);
|
|
64
|
+
},
|
|
65
|
+
[completeHold, disabled, duration, status],
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
useEffect(
|
|
69
|
+
() => () => {
|
|
70
|
+
clearConfirmTimer();
|
|
71
|
+
if (resetTimerRef.current !== null) window.clearTimeout(resetTimerRef.current);
|
|
72
|
+
},
|
|
73
|
+
[clearConfirmTimer],
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (disabled) cancelHold();
|
|
78
|
+
}, [cancelHold, disabled]);
|
|
79
|
+
|
|
80
|
+
const releasePointerCapture = (pointerId) => {
|
|
81
|
+
const button = buttonRef.current;
|
|
82
|
+
if (button?.hasPointerCapture(pointerId)) button.releasePointerCapture(pointerId);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const handlePointerDown = (event) => {
|
|
86
|
+
if (!event.isPrimary || event.button !== 0 || disabled) return;
|
|
87
|
+
activePointerIdRef.current = event.pointerId;
|
|
88
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
89
|
+
startHold("pointer");
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const handlePointerMove = (event) => {
|
|
93
|
+
if (!isHoldingRef.current || activePointerIdRef.current !== event.pointerId) return;
|
|
94
|
+
const rect = event.currentTarget.getBoundingClientRect();
|
|
95
|
+
const boundaryPadding = 8;
|
|
96
|
+
const isOutside = event.clientX < rect.left - boundaryPadding || event.clientX > rect.right + boundaryPadding || event.clientY < rect.top - boundaryPadding || event.clientY > rect.bottom + boundaryPadding;
|
|
97
|
+
if (isOutside) {
|
|
98
|
+
cancelHold();
|
|
99
|
+
releasePointerCapture(event.pointerId);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const handlePointerEnd = (event) => {
|
|
104
|
+
if (activePointerIdRef.current !== event.pointerId) return;
|
|
105
|
+
cancelHold();
|
|
106
|
+
releasePointerCapture(event.pointerId);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const handleKeyDown = (event) => {
|
|
110
|
+
if (event.repeat || (event.key !== "Enter" && event.key !== " ")) return;
|
|
111
|
+
event.preventDefault();
|
|
112
|
+
startHold("keyboard");
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const handleKeyUp = (event) => {
|
|
116
|
+
if (event.key !== "Enter" && event.key !== " ") return;
|
|
117
|
+
event.preventDefault();
|
|
118
|
+
cancelHold();
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const isConfirmed = status === "confirmed";
|
|
122
|
+
const isHolding = status === "holding";
|
|
123
|
+
const overlayStyle = {
|
|
124
|
+
clipPath: isHolding ? "inset(0 0 0 0)" : "inset(0 100% 0 0)",
|
|
125
|
+
transitionDuration: shouldReduceMotion || inputMode === "keyboard" ? "0ms" : isHolding ? `${duration}ms` : "180ms",
|
|
126
|
+
transitionProperty: "clip-path",
|
|
127
|
+
transitionTimingFunction: isHolding ? "linear" : "cubic-bezier(0.23, 1, 0.32, 1)",
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<button
|
|
132
|
+
{...buttonProps}
|
|
133
|
+
aria-busy={isHolding}
|
|
134
|
+
className={`relative isolate inline-flex h-9 min-w-40 cursor-pointer touch-none select-none items-center justify-center overflow-hidden rounded-lg border border-red-500 px-3 text-sm font-medium outline-none transition-[transform,border-color,background-color,color] duration-150 [transition-timing-function:cubic-bezier(0.23,1,0.32,1)] focus-visible:ring-2 focus-visible:ring-red-400 focus-visible:ring-offset-2 focus-visible:ring-offset-transparent data-[input=pointer]:active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-50 ${isConfirmed ? "bg-red-500 text-white" : "bg-red-500/10 text-red-500"} ${className}`}
|
|
135
|
+
data-input={inputMode ?? undefined}
|
|
136
|
+
disabled={disabled}
|
|
137
|
+
onBlur={cancelHold}
|
|
138
|
+
onKeyDown={handleKeyDown}
|
|
139
|
+
onKeyUp={handleKeyUp}
|
|
140
|
+
onLostPointerCapture={cancelHold}
|
|
141
|
+
onPointerCancel={handlePointerEnd}
|
|
142
|
+
onPointerDown={handlePointerDown}
|
|
143
|
+
onPointerMove={handlePointerMove}
|
|
144
|
+
onPointerUp={handlePointerEnd}
|
|
145
|
+
ref={buttonRef}
|
|
146
|
+
type="button"
|
|
147
|
+
>
|
|
148
|
+
{isConfirmed ? (
|
|
149
|
+
<span className="relative flex items-center justify-center gap-1.5">
|
|
150
|
+
{confirmedContent ?? (
|
|
151
|
+
<>
|
|
152
|
+
<Check aria-hidden="true" className="h-[15px] w-[15px]" strokeWidth={3} />
|
|
153
|
+
Confirmed
|
|
154
|
+
</>
|
|
155
|
+
)}
|
|
156
|
+
</span>
|
|
157
|
+
) : (
|
|
158
|
+
<>
|
|
159
|
+
<span className="relative flex items-center justify-center gap-1.5">{children}</span>
|
|
160
|
+
<span aria-hidden="true" className="absolute inset-0 flex items-center justify-center gap-1.5 bg-red-500 px-3 text-white" style={overlayStyle}>
|
|
161
|
+
{children}
|
|
162
|
+
</span>
|
|
163
|
+
</>
|
|
164
|
+
)}
|
|
165
|
+
</button>
|
|
166
|
+
);
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
export default HoldToConfirm;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import React, { useEffect, useState } from "react";
|
|
2
|
+
import { useReducedMotion } from "framer-motion";
|
|
3
|
+
import { CheckCircle2, RotateCcw } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* Undo Notice: pairs with HoldToConfirm. Shows what just happened with an
|
|
7
|
+
* Undo button whose solid fill drains away over `duration`; when it runs
|
|
8
|
+
* out, onExpire fires. onUndo tells you if it came from a key or a pointer.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const UndoNotice = ({ className = "", duration = 5000, message = "Project deleted", onExpire, onUndo }) => {
|
|
12
|
+
const [isCounting, setIsCounting] = useState(false);
|
|
13
|
+
const shouldReduceMotion = useReducedMotion();
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
const frame = window.requestAnimationFrame(() => setIsCounting(true));
|
|
17
|
+
const timer = window.setTimeout(() => onExpire?.(), duration);
|
|
18
|
+
return () => {
|
|
19
|
+
window.cancelAnimationFrame(frame);
|
|
20
|
+
window.clearTimeout(timer);
|
|
21
|
+
};
|
|
22
|
+
}, [duration, onExpire]);
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div aria-live="polite" role="status" className={`flex min-h-9 w-full max-w-xs items-center justify-between gap-3 rounded-xl bg-neutral-100 p-1 pl-3 dark:bg-neutral-800 ${className}`}>
|
|
26
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
27
|
+
<CheckCircle2 aria-hidden="true" className="h-4 w-4 shrink-0 fill-green-500 text-white dark:text-neutral-800" />
|
|
28
|
+
<p className="truncate text-xs font-medium text-neutral-900 dark:text-neutral-100">{message}</p>
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<button
|
|
32
|
+
type="button"
|
|
33
|
+
onClick={(event) => onUndo?.(event.detail === 0 ? "keyboard" : "pointer")}
|
|
34
|
+
className="relative isolate flex h-7 min-w-20 cursor-pointer items-center justify-center overflow-hidden rounded-lg border border-neutral-200 bg-white px-2.5 text-xs font-medium text-neutral-600 outline-none transition-transform duration-150 [transition-timing-function:cubic-bezier(0.23,1,0.32,1)] focus-visible:ring-2 focus-visible:ring-neutral-400 active:scale-[0.97] dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300"
|
|
35
|
+
>
|
|
36
|
+
<span className="flex items-center gap-1.5">
|
|
37
|
+
<RotateCcw aria-hidden="true" className="h-[13px] w-[13px]" strokeWidth={2.5} />
|
|
38
|
+
Undo
|
|
39
|
+
</span>
|
|
40
|
+
<span
|
|
41
|
+
aria-hidden="true"
|
|
42
|
+
className="absolute inset-0 flex items-center justify-center gap-1.5 bg-neutral-900 px-2.5 text-white dark:bg-neutral-100 dark:text-neutral-900"
|
|
43
|
+
style={{
|
|
44
|
+
clipPath: isCounting ? "inset(0 100% 0 0)" : "inset(0 0 0 0)",
|
|
45
|
+
transitionDuration: shouldReduceMotion ? "0ms" : `${duration}ms`,
|
|
46
|
+
transitionProperty: "clip-path",
|
|
47
|
+
transitionTimingFunction: "linear",
|
|
48
|
+
}}
|
|
49
|
+
>
|
|
50
|
+
<RotateCcw aria-hidden="true" className="h-[13px] w-[13px]" strokeWidth={2.5} />
|
|
51
|
+
Undo
|
|
52
|
+
</span>
|
|
53
|
+
</button>
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export default UndoNotice;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import React, { useRef } from "react";
|
|
2
|
+
import { motion, useMotionTemplate, useMotionValue, useReducedMotion, useSpring, useTransform } from "framer-motion";
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* Holo Card: a collectible-style card that tilts in 3D under the pointer.
|
|
6
|
+
* A rainbow foil and a glare follow the light, and the artwork, title and
|
|
7
|
+
* badge float at different depths so the card feels thick.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const IK = "https://ik.imagekit.io/ny6yinyut/apexui/assets";
|
|
11
|
+
|
|
12
|
+
const HoloCard = ({
|
|
13
|
+
image = `${IK}/doctorStrange.png?tr=f-auto,q-80,w-600`,
|
|
14
|
+
title = "Doctor Strange",
|
|
15
|
+
subtitle = "Sorcerer Supreme",
|
|
16
|
+
badge = "Legendary",
|
|
17
|
+
stat = "98",
|
|
18
|
+
accentColor = "#a3e635",
|
|
19
|
+
maxTilt = 16,
|
|
20
|
+
width = 17,
|
|
21
|
+
className = "",
|
|
22
|
+
}) => {
|
|
23
|
+
const ref = useRef(null);
|
|
24
|
+
const reduced = useReducedMotion();
|
|
25
|
+
const px = useMotionValue(0.5);
|
|
26
|
+
const py = useMotionValue(0.5);
|
|
27
|
+
const spring = { stiffness: 180, damping: 18, mass: 0.6 };
|
|
28
|
+
const sx = useSpring(px, spring);
|
|
29
|
+
const sy = useSpring(py, spring);
|
|
30
|
+
const rotateY = useTransform(sx, [0, 1], [-maxTilt, maxTilt]);
|
|
31
|
+
const rotateX = useTransform(sy, [0, 1], [maxTilt, -maxTilt]);
|
|
32
|
+
const glareX = useTransform(sx, (v) => `${v * 100}%`);
|
|
33
|
+
const glareY = useTransform(sy, (v) => `${v * 100}%`);
|
|
34
|
+
const foilPos = useTransform([sx, sy], ([x, y]) => `${x * 100}% ${y * 100}%`);
|
|
35
|
+
const glare = useMotionTemplate`radial-gradient(circle at ${glareX} ${glareY}, rgba(255,255,255,0.55), rgba(255,255,255,0) 45%)`;
|
|
36
|
+
const shadow = useTransform([sx, sy], ([x, y]) => `${(0.5 - x) * 40}px ${(0.5 - y) * 40 + 30}px 60px -20px rgba(0,0,0,0.6)`);
|
|
37
|
+
|
|
38
|
+
const onMove = (e) => {
|
|
39
|
+
if (reduced) return;
|
|
40
|
+
const r = ref.current.getBoundingClientRect();
|
|
41
|
+
px.set((e.clientX - r.left) / r.width);
|
|
42
|
+
py.set((e.clientY - r.top) / r.height);
|
|
43
|
+
};
|
|
44
|
+
const onLeave = () => {
|
|
45
|
+
px.set(0.5);
|
|
46
|
+
py.set(0.5);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div className={`[perspective:1100px] ${className}`}>
|
|
51
|
+
<motion.article
|
|
52
|
+
ref={ref}
|
|
53
|
+
onPointerMove={onMove}
|
|
54
|
+
onPointerLeave={onLeave}
|
|
55
|
+
className="relative aspect-[5/7] cursor-pointer select-none rounded-[1.4rem] bg-neutral-900 [transform-style:preserve-3d]"
|
|
56
|
+
style={{ width: `min(${width}rem, 80vw)`, rotateX, rotateY, boxShadow: shadow }}
|
|
57
|
+
whileHover={{ scale: 1.03 }}
|
|
58
|
+
transition={{ type: "spring", stiffness: 260, damping: 20 }}
|
|
59
|
+
>
|
|
60
|
+
<div className="absolute inset-0 overflow-hidden rounded-[1.4rem] ring-1 ring-white/15">
|
|
61
|
+
<img src={image} alt="" draggable="false" className="h-full w-full object-cover" />
|
|
62
|
+
{/* Holographic foil: a rainbow that slides with the light. */}
|
|
63
|
+
<motion.div
|
|
64
|
+
aria-hidden="true"
|
|
65
|
+
className="absolute inset-0 opacity-[0.22] mix-blend-color-dodge"
|
|
66
|
+
style={{
|
|
67
|
+
backgroundImage: "linear-gradient(115deg, transparent 20%, #ff7ad9 30%, #7afcff 40%, #fdff7a 50%, #7aff9b 60%, #b57aff 70%, transparent 80%)",
|
|
68
|
+
backgroundSize: "260% 260%",
|
|
69
|
+
backgroundPosition: foilPos,
|
|
70
|
+
}}
|
|
71
|
+
/>
|
|
72
|
+
<motion.div aria-hidden="true" className="absolute inset-0 mix-blend-overlay" style={{ background: glare }} />
|
|
73
|
+
<div className="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/85 to-transparent" />
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
{/* Layers that float above the card. */}
|
|
77
|
+
<div className="pointer-events-none absolute inset-x-4 top-4 flex items-center justify-between [transform:translateZ(40px)]">
|
|
78
|
+
<span className="rounded-full px-2.5 py-1 text-[0.65rem] font-bold uppercase tracking-[0.18em] text-black shadow-lg" style={{ background: accentColor }}>
|
|
79
|
+
{badge}
|
|
80
|
+
</span>
|
|
81
|
+
<span className="grid h-10 w-10 place-items-center rounded-full border border-white/30 bg-black/50 font-mono text-sm font-bold text-white backdrop-blur">{stat}</span>
|
|
82
|
+
</div>
|
|
83
|
+
<div className="pointer-events-none absolute inset-x-5 bottom-5 text-white [transform:translateZ(60px)]">
|
|
84
|
+
<p className="text-[1.35rem] font-bold leading-tight drop-shadow-lg">{title}</p>
|
|
85
|
+
<p className="text-[0.82rem] text-white/70">{subtitle}</p>
|
|
86
|
+
</div>
|
|
87
|
+
</motion.article>
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export default HoloCard;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import React, { useRef, useState } from "react";
|
|
2
|
+
import { ChevronsLeftRight } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* Image Compare: a before / after slider. Drag the handle (or click anywhere,
|
|
6
|
+
* or use the arrow keys) to wipe between the two images. Give one `image` and
|
|
7
|
+
* the "before" side is drawn with a CSS filter, or pass both `before` and `after`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const ImageCompare = ({
|
|
11
|
+
before,
|
|
12
|
+
after,
|
|
13
|
+
image = "https://ik.imagekit.io/ny6yinyut/apexui/assets/thanos.png?tr=f-auto,q-80,w-1200",
|
|
14
|
+
beforeFilter = "grayscale(1) contrast(1.05) brightness(0.9)",
|
|
15
|
+
beforeLabel = "Before",
|
|
16
|
+
afterLabel = "After",
|
|
17
|
+
defaultPosition = 50,
|
|
18
|
+
vertical = false,
|
|
19
|
+
accentColor = "#a3e635",
|
|
20
|
+
aspectRatio = "16 / 10",
|
|
21
|
+
className = "",
|
|
22
|
+
}) => {
|
|
23
|
+
const ref = useRef(null);
|
|
24
|
+
const [pos, setPos] = useState(defaultPosition);
|
|
25
|
+
const [held, setHeld] = useState(false);
|
|
26
|
+
const beforeSrc = before ?? image;
|
|
27
|
+
const afterSrc = after ?? image;
|
|
28
|
+
|
|
29
|
+
const fromPointer = (e) => {
|
|
30
|
+
const r = ref.current.getBoundingClientRect();
|
|
31
|
+
const p = vertical ? (e.clientY - r.top) / r.height : (e.clientX - r.left) / r.width;
|
|
32
|
+
setPos(Math.min(100, Math.max(0, p * 100)));
|
|
33
|
+
};
|
|
34
|
+
const onKeyDown = (e) => {
|
|
35
|
+
const step = e.shiftKey ? 10 : 2;
|
|
36
|
+
const delta = { ArrowLeft: -step, ArrowDown: vertical ? step : -step, ArrowRight: step, ArrowUp: vertical ? -step : step }[e.key];
|
|
37
|
+
if (delta === undefined) return;
|
|
38
|
+
e.preventDefault();
|
|
39
|
+
setPos((p) => Math.min(100, Math.max(0, p + delta)));
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const clip = vertical ? `inset(0 0 ${100 - pos}% 0)` : `inset(0 ${100 - pos}% 0 0)`;
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div
|
|
46
|
+
ref={ref}
|
|
47
|
+
onPointerDown={(e) => {
|
|
48
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
49
|
+
setHeld(true);
|
|
50
|
+
fromPointer(e);
|
|
51
|
+
}}
|
|
52
|
+
onPointerMove={(e) => held && fromPointer(e)}
|
|
53
|
+
onPointerUp={() => setHeld(false)}
|
|
54
|
+
onPointerCancel={() => setHeld(false)}
|
|
55
|
+
className={`group relative w-full select-none overflow-hidden rounded-2xl bg-neutral-900 ${vertical ? "cursor-row-resize touch-none" : "cursor-col-resize touch-pan-y"} ${className}`}
|
|
56
|
+
style={{ aspectRatio }}
|
|
57
|
+
>
|
|
58
|
+
<img src={afterSrc} alt={afterLabel} draggable="false" className="absolute inset-0 h-full w-full object-cover" />
|
|
59
|
+
<img
|
|
60
|
+
src={beforeSrc}
|
|
61
|
+
alt={beforeLabel}
|
|
62
|
+
draggable="false"
|
|
63
|
+
className="absolute inset-0 h-full w-full object-cover"
|
|
64
|
+
style={{ clipPath: clip, filter: before ? undefined : beforeFilter, transition: held ? "none" : "clip-path 200ms ease-out" }}
|
|
65
|
+
/>
|
|
66
|
+
|
|
67
|
+
<span className="pointer-events-none absolute left-3 top-3 rounded-full bg-black/55 px-2.5 py-1 text-[0.7rem] font-semibold uppercase tracking-wider text-white backdrop-blur">{beforeLabel}</span>
|
|
68
|
+
<span className={`pointer-events-none absolute rounded-full bg-black/55 px-2.5 py-1 text-[0.7rem] font-semibold uppercase tracking-wider text-white backdrop-blur ${vertical ? "bottom-3 left-3" : "right-3 top-3"}`}>{afterLabel}</span>
|
|
69
|
+
|
|
70
|
+
{/* Divider and handle */}
|
|
71
|
+
<div
|
|
72
|
+
className="pointer-events-none absolute"
|
|
73
|
+
style={vertical ? { left: 0, right: 0, top: `${pos}%`, height: 2, marginTop: -1, background: accentColor, transition: held ? "none" : "top 200ms ease-out" } : { top: 0, bottom: 0, left: `${pos}%`, width: 2, marginLeft: -1, background: accentColor, transition: held ? "none" : "left 200ms ease-out" }}
|
|
74
|
+
>
|
|
75
|
+
<button
|
|
76
|
+
type="button"
|
|
77
|
+
role="slider"
|
|
78
|
+
aria-label="Comparison position"
|
|
79
|
+
aria-valuemin={0}
|
|
80
|
+
aria-valuemax={100}
|
|
81
|
+
aria-valuenow={Math.round(pos)}
|
|
82
|
+
aria-orientation={vertical ? "vertical" : "horizontal"}
|
|
83
|
+
onKeyDown={onKeyDown}
|
|
84
|
+
className={`pointer-events-auto absolute left-1/2 top-1/2 grid h-10 w-10 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full text-black shadow-[0_6px_20px_-4px_rgba(0,0,0,0.6)] outline-none transition-transform duration-200 focus-visible:ring-4 focus-visible:ring-white/50 ${held ? "scale-110" : "group-hover:scale-105"}`}
|
|
85
|
+
style={{ background: accentColor }}
|
|
86
|
+
>
|
|
87
|
+
<ChevronsLeftRight className={`h-5 w-5 ${vertical ? "rotate-90" : ""}`} />
|
|
88
|
+
</button>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export default ImageCompare;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { motion } from "framer-motion";
|
|
3
|
+
import { Cloud, Database, Layout, Server, Shield } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* Iso Stack: layers drawn in isometric 3D, stacked like a sandwich. Hover
|
|
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.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const DEFAULT_LAYERS = [
|
|
12
|
+
{ label: "Interface", note: "React + Tailwind", icon: Layout, color: "#a3e635" },
|
|
13
|
+
{ label: "Edge", note: "CDN & caching", icon: Cloud, color: "#38bdf8" },
|
|
14
|
+
{ label: "Security", note: "Auth & rate limits", icon: Shield, color: "#a78bfa" },
|
|
15
|
+
{ label: "API", note: "Node services", icon: Server, color: "#f59e0b" },
|
|
16
|
+
{ label: "Data", note: "Postgres + Redis", icon: Database, color: "#f43f5e" },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const IsoStack = ({ layers = DEFAULT_LAYERS, size = 13, gap = 1.6, theme = "dark", className = "" }) => {
|
|
20
|
+
const [open, setOpen] = useState(false);
|
|
21
|
+
const [hover, setHover] = useState(-1);
|
|
22
|
+
const dark = theme !== "light";
|
|
23
|
+
const n = layers.length;
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div
|
|
27
|
+
className={`relative flex items-center justify-center ${className}`}
|
|
28
|
+
style={{ height: `${size * 0.6 + n * gap * 1.6 + 6}rem`, width: "100%" }}
|
|
29
|
+
onPointerEnter={(e) => e.pointerType === "mouse" && setOpen(true)}
|
|
30
|
+
onPointerLeave={() => {
|
|
31
|
+
setOpen(false);
|
|
32
|
+
setHover(-1);
|
|
33
|
+
}}
|
|
34
|
+
onClick={() => setOpen((o) => !o)}
|
|
35
|
+
>
|
|
36
|
+
{/* 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" }}>
|
|
38
|
+
{layers.map((layer, i) => {
|
|
39
|
+
const Icon = layer.icon;
|
|
40
|
+
const fromTop = i;
|
|
41
|
+
const lift = (n - 1 - fromTop) * (open ? gap * 2.1 : gap * 0.55) + (hover === i ? 1.2 : 0);
|
|
42
|
+
return (
|
|
43
|
+
<motion.div
|
|
44
|
+
key={layer.label}
|
|
45
|
+
className="absolute inset-0 rounded-2xl border"
|
|
46
|
+
style={{
|
|
47
|
+
zIndex: n - fromTop,
|
|
48
|
+
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
|
+
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})`,
|
|
51
|
+
}}
|
|
52
|
+
animate={{ z: `${lift}rem` }}
|
|
53
|
+
transition={{ type: "spring", stiffness: 200, damping: 22, delay: open ? (n - fromTop) * 0.03 : 0 }}
|
|
54
|
+
onPointerEnter={() => setHover(i)}
|
|
55
|
+
onPointerLeave={() => setHover(-1)}
|
|
56
|
+
>
|
|
57
|
+
<div className="absolute inset-3 rounded-xl border border-dashed" style={{ borderColor: `color-mix(in srgb, ${layer.color} 35%, transparent)` }} />
|
|
58
|
+
<span className="absolute left-4 top-4 grid h-9 w-9 place-items-center rounded-lg" style={{ background: layer.color, color: "#0a0a0a" }}>
|
|
59
|
+
{Icon && <Icon className="h-5 w-5" />}
|
|
60
|
+
</span>
|
|
61
|
+
</motion.div>
|
|
62
|
+
);
|
|
63
|
+
})}
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
{/* 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">
|
|
68
|
+
{layers.map((layer, i) => (
|
|
69
|
+
<motion.div
|
|
70
|
+
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 }}
|
|
73
|
+
transition={{ delay: open ? i * 0.05 : 0 }}
|
|
74
|
+
>
|
|
75
|
+
<p className="text-[0.78rem] font-semibold" style={{ color: hover === i ? layer.color : undefined }}>
|
|
76
|
+
{layer.label}
|
|
77
|
+
</p>
|
|
78
|
+
<p className="text-[0.65rem] opacity-60">{layer.note}</p>
|
|
79
|
+
</motion.div>
|
|
80
|
+
))}
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export default IsoStack;
|