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,152 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { animate, motion, useMotionValue, useTransform } from "framer-motion";
|
|
3
|
+
import { Volume1, Volume2 } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* Elastic Slider: a volume-style slider with a knob. Grab anywhere on the
|
|
7
|
+
* track and drag; pull past either end and the track stretches like rubber
|
|
8
|
+
* (the end icon gets dragged along), then springs back when you let go.
|
|
9
|
+
* The fill follows the pointer every frame — no React render in the loop.
|
|
10
|
+
* Arrow keys, Page Up/Down, Home and End work too.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const MAX_OVERFLOW = 56;
|
|
14
|
+
// Diminishing stretch: the further you pull, the less it gives.
|
|
15
|
+
const decay = (v, max) => (2 / (1 + Math.exp(-v / max)) - 1) * max;
|
|
16
|
+
|
|
17
|
+
const ElasticSlider = ({
|
|
18
|
+
defaultValue = 50,
|
|
19
|
+
min = 0,
|
|
20
|
+
max = 100,
|
|
21
|
+
step = 1,
|
|
22
|
+
leftIcon = <Volume1 className="h-5 w-5" />,
|
|
23
|
+
rightIcon = <Volume2 className="h-5 w-5" />,
|
|
24
|
+
accentColor = "#a3e635",
|
|
25
|
+
onChange,
|
|
26
|
+
showValue = true,
|
|
27
|
+
className = "",
|
|
28
|
+
}) => {
|
|
29
|
+
const clamp = (v) => Math.min(max, Math.max(min, v));
|
|
30
|
+
const [value, setValue] = useState(clamp(defaultValue));
|
|
31
|
+
const [dragging, setDragging] = useState(false);
|
|
32
|
+
const [hover, setHover] = useState(false);
|
|
33
|
+
const track = useRef(null);
|
|
34
|
+
const drag = useRef(false);
|
|
35
|
+
|
|
36
|
+
const pct = useMotionValue(((clamp(defaultValue) - min) / (max - min)) * 100);
|
|
37
|
+
const overflow = useMotionValue(0); // px past an end, negative = left
|
|
38
|
+
const fillWidth = useTransform(pct, (p) => `${p}%`);
|
|
39
|
+
const knobLeft = useTransform(pct, (p) => `${p}%`);
|
|
40
|
+
const scaleX = useTransform(overflow, (o) => 1 + Math.abs(o) / (track.current?.offsetWidth || 240));
|
|
41
|
+
const originX = useTransform(overflow, (o) => (o < 0 ? 1 : 0));
|
|
42
|
+
const leftX = useTransform(overflow, (o) => (o < 0 ? o : 0));
|
|
43
|
+
const rightX = useTransform(overflow, (o) => (o > 0 ? o : 0));
|
|
44
|
+
|
|
45
|
+
const commit = (raw) => {
|
|
46
|
+
const v = clamp(Math.round(raw / step) * step);
|
|
47
|
+
setValue((prev) => {
|
|
48
|
+
if (prev !== v) onChange?.(v);
|
|
49
|
+
return v;
|
|
50
|
+
});
|
|
51
|
+
return v;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const fromPointer = (clientX) => {
|
|
55
|
+
const r = track.current.getBoundingClientRect();
|
|
56
|
+
const ratio = (clientX - r.left) / r.width;
|
|
57
|
+
pct.set(Math.min(1, Math.max(0, ratio)) * 100);
|
|
58
|
+
commit(min + Math.min(1, Math.max(0, ratio)) * (max - min));
|
|
59
|
+
const past = clientX < r.left ? clientX - r.left : clientX > r.right ? clientX - r.right : 0;
|
|
60
|
+
overflow.set(Math.sign(past) * decay(Math.abs(past), MAX_OVERFLOW));
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const end = () => {
|
|
64
|
+
if (!drag.current) return;
|
|
65
|
+
drag.current = false;
|
|
66
|
+
setDragging(false);
|
|
67
|
+
animate(overflow, 0, { type: "spring", stiffness: 380, damping: 12 });
|
|
68
|
+
// Snap the fill to the stepped value.
|
|
69
|
+
animate(pct, ((value - min) / (max - min)) * 100, { duration: 0.15 });
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Keep the fill in sync for keyboard changes.
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (!drag.current) animate(pct, ((value - min) / (max - min)) * 100, { type: "spring", stiffness: 500, damping: 40 });
|
|
75
|
+
}, [value, min, max, pct]);
|
|
76
|
+
|
|
77
|
+
const nudge = (delta) => commit(value + delta);
|
|
78
|
+
const big = Math.max(step, (max - min) / 10);
|
|
79
|
+
const thick = dragging || hover;
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<div className={`flex w-full max-w-sm flex-col items-center gap-3 ${className}`}>
|
|
83
|
+
<div className="flex w-full touch-none select-none items-center gap-4">
|
|
84
|
+
<motion.button type="button" aria-label="Decrease" onClick={() => nudge(-big)} style={{ x: leftX }} className="shrink-0 opacity-70 transition-opacity hover:opacity-100">
|
|
85
|
+
{leftIcon}
|
|
86
|
+
</motion.button>
|
|
87
|
+
|
|
88
|
+
<div
|
|
89
|
+
ref={track}
|
|
90
|
+
role="slider"
|
|
91
|
+
tabIndex={0}
|
|
92
|
+
aria-valuemin={min}
|
|
93
|
+
aria-valuemax={max}
|
|
94
|
+
aria-valuenow={value}
|
|
95
|
+
aria-label="Value"
|
|
96
|
+
className="relative flex h-10 flex-1 cursor-grab items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-current/30 active:cursor-grabbing"
|
|
97
|
+
onPointerEnter={() => setHover(true)}
|
|
98
|
+
onPointerLeave={() => setHover(false)}
|
|
99
|
+
onPointerDown={(e) => {
|
|
100
|
+
if (e.button !== 0) return;
|
|
101
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
102
|
+
drag.current = true;
|
|
103
|
+
setDragging(true);
|
|
104
|
+
fromPointer(e.clientX);
|
|
105
|
+
}}
|
|
106
|
+
onPointerMove={(e) => drag.current && fromPointer(e.clientX)}
|
|
107
|
+
onPointerUp={end}
|
|
108
|
+
onPointerCancel={end}
|
|
109
|
+
onLostPointerCapture={end}
|
|
110
|
+
onKeyDown={(e) => {
|
|
111
|
+
const map = { ArrowRight: step, ArrowUp: step, ArrowLeft: -step, ArrowDown: -step, PageUp: big, PageDown: -big };
|
|
112
|
+
if (e.key in map) nudge(map[e.key]);
|
|
113
|
+
else if (e.key === "Home") commit(min);
|
|
114
|
+
else if (e.key === "End") commit(max);
|
|
115
|
+
else return;
|
|
116
|
+
e.preventDefault();
|
|
117
|
+
}}
|
|
118
|
+
>
|
|
119
|
+
<motion.div className="relative w-full rounded-full" style={{ scaleX, originX }} animate={{ height: thick ? 12 : 7 }} transition={{ type: "spring", stiffness: 400, damping: 30 }}>
|
|
120
|
+
<div className="absolute inset-0 overflow-hidden rounded-full" style={{ background: "color-mix(in srgb, currentColor 16%, transparent)" }}>
|
|
121
|
+
<motion.div className="absolute inset-y-0 left-0 rounded-full" style={{ width: fillWidth, background: accentColor }} />
|
|
122
|
+
</div>
|
|
123
|
+
{/* Knob */}
|
|
124
|
+
<motion.span
|
|
125
|
+
className="absolute top-1/2 block rounded-full bg-white shadow-[0_1px_4px_rgba(0,0,0,0.35)] ring-1 ring-black/10"
|
|
126
|
+
style={{ left: knobLeft, x: "-50%", y: "-50%" }}
|
|
127
|
+
animate={{ width: dragging ? 22 : thick ? 18 : 14, height: dragging ? 22 : thick ? 18 : 14 }}
|
|
128
|
+
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
|
129
|
+
/>
|
|
130
|
+
{/* Value bubble while dragging */}
|
|
131
|
+
<motion.span
|
|
132
|
+
className="pointer-events-none absolute bottom-full mb-3 rounded-md px-1.5 py-0.5 font-mono text-[0.7rem] font-semibold text-black"
|
|
133
|
+
style={{ left: knobLeft, x: "-50%", background: accentColor }}
|
|
134
|
+
initial={false}
|
|
135
|
+
animate={{ opacity: dragging ? 1 : 0, y: dragging ? 0 : 4 }}
|
|
136
|
+
transition={{ duration: 0.15 }}
|
|
137
|
+
>
|
|
138
|
+
{value}
|
|
139
|
+
</motion.span>
|
|
140
|
+
</motion.div>
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
<motion.button type="button" aria-label="Increase" onClick={() => nudge(big)} style={{ x: rightX }} className="shrink-0 opacity-70 transition-opacity hover:opacity-100">
|
|
144
|
+
{rightIcon}
|
|
145
|
+
</motion.button>
|
|
146
|
+
</div>
|
|
147
|
+
{showValue && <p className="font-mono text-sm tabular-nums opacity-60">{value}</p>}
|
|
148
|
+
</div>
|
|
149
|
+
);
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
export default ElasticSlider;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import React, { useRef } from "react";
|
|
2
|
+
import { motion, useMotionValue, useReducedMotion, useSpring, useTransform } from "framer-motion";
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* Extruded Text: chunky 3D letters built from stacked copies of the text,
|
|
6
|
+
* each pushed a little deeper. The whole block tilts toward the pointer so
|
|
7
|
+
* you can see the sides, and the face can carry a gradient.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const ExtrudedText = ({
|
|
11
|
+
text = "APEX",
|
|
12
|
+
depth = 14,
|
|
13
|
+
faceColor = "#a3e635",
|
|
14
|
+
sideColor = "#3f6212",
|
|
15
|
+
gradient = "",
|
|
16
|
+
maxTilt = 22,
|
|
17
|
+
fontSize = 7,
|
|
18
|
+
className = "",
|
|
19
|
+
}) => {
|
|
20
|
+
const ref = useRef(null);
|
|
21
|
+
const reduced = useReducedMotion();
|
|
22
|
+
// Rest a little turned so the depth shows before the pointer arrives.
|
|
23
|
+
const REST = { x: -0.55, y: 0.5 };
|
|
24
|
+
const px = useMotionValue(REST.x);
|
|
25
|
+
const py = useMotionValue(REST.y);
|
|
26
|
+
const rotateY = useSpring(useTransform(px, [-1, 1], [-maxTilt, maxTilt]), { stiffness: 140, damping: 16 });
|
|
27
|
+
const rotateX = useSpring(useTransform(py, [-1, 1], [maxTilt * 0.7, -maxTilt * 0.7]), { stiffness: 140, damping: 16 });
|
|
28
|
+
|
|
29
|
+
const onMove = (e) => {
|
|
30
|
+
if (reduced) return;
|
|
31
|
+
const r = ref.current.getBoundingClientRect();
|
|
32
|
+
px.set(((e.clientX - r.left) / r.width) * 2 - 1);
|
|
33
|
+
py.set(((e.clientY - r.top) / r.height) * 2 - 1);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const layers = Array.from({ length: depth }, (_, i) => depth - i);
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div
|
|
40
|
+
ref={ref}
|
|
41
|
+
onPointerMove={onMove}
|
|
42
|
+
onPointerLeave={() => {
|
|
43
|
+
px.set(REST.x);
|
|
44
|
+
py.set(REST.y);
|
|
45
|
+
}}
|
|
46
|
+
className={`flex w-full items-center justify-center py-10 [perspective:900px] ${className}`}
|
|
47
|
+
aria-label={text}
|
|
48
|
+
role="img"
|
|
49
|
+
>
|
|
50
|
+
<motion.div className="relative font-black leading-none tracking-tight [transform-style:preserve-3d]" style={{ rotateX, rotateY, fontSize: `clamp(2.5rem, 14vw, ${fontSize}rem)` }}>
|
|
51
|
+
{/* Back layers form the sides, darker the deeper they go. */}
|
|
52
|
+
{layers.map((z) => (
|
|
53
|
+
<span
|
|
54
|
+
key={z}
|
|
55
|
+
aria-hidden="true"
|
|
56
|
+
className="absolute inset-0 select-none"
|
|
57
|
+
style={{ transform: `translateZ(${-z * 3}px)`, color: sideColor, filter: `brightness(${1 - (z / depth) * 0.55})` }}
|
|
58
|
+
>
|
|
59
|
+
{text}
|
|
60
|
+
</span>
|
|
61
|
+
))}
|
|
62
|
+
<span
|
|
63
|
+
aria-hidden="true"
|
|
64
|
+
className="relative block select-none"
|
|
65
|
+
style={
|
|
66
|
+
gradient
|
|
67
|
+
? { backgroundImage: gradient, WebkitBackgroundClip: "text", backgroundClip: "text", color: "transparent" }
|
|
68
|
+
: { color: faceColor, textShadow: "0 1px 0 rgba(255,255,255,0.35)" }
|
|
69
|
+
}
|
|
70
|
+
>
|
|
71
|
+
{text}
|
|
72
|
+
</span>
|
|
73
|
+
</motion.div>
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export default ExtrudedText;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import React, { useId, useState } from "react";
|
|
2
|
+
import { AnimatePresence, motion } from "framer-motion";
|
|
3
|
+
import { Plus } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* FAQ Accordion: questions that open with a smooth height animation, a plus
|
|
7
|
+
* that turns into a cross, and a soft highlight on the open item. One or many
|
|
8
|
+
* items can be open at once.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const DEFAULT_ITEMS = [
|
|
12
|
+
{ q: "Is ApexUI free to use?", a: "Yes. Every component is free and open source — copy it into your project or add it with one CLI command." },
|
|
13
|
+
{ q: "Does it work with Next.js?", a: "Yes. The components are plain React with Tailwind CSS, so they work in Vite, Next.js, Remix and anything else that runs React." },
|
|
14
|
+
{ q: "Can I change the colors?", a: "Every component takes props for its accent colors, and the Tailwind classes are right there in the code if you want to go further." },
|
|
15
|
+
{ q: "Do the animations respect reduced motion?", a: "Yes. Components check the visitor's reduced-motion setting and tone their movement down or switch it off." },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const FaqAccordion = ({ items = DEFAULT_ITEMS, multiple = false, defaultOpen = [0], accentColor = "#a3e635", theme = "dark", className = "" }) => {
|
|
19
|
+
const id = useId();
|
|
20
|
+
const [open, setOpen] = useState(() => new Set(defaultOpen));
|
|
21
|
+
const dark = theme !== "light";
|
|
22
|
+
|
|
23
|
+
const toggle = (i) =>
|
|
24
|
+
setOpen((prev) => {
|
|
25
|
+
const next = new Set(multiple ? prev : prev.has(i) ? [i] : []);
|
|
26
|
+
if (next.has(i)) next.delete(i);
|
|
27
|
+
else next.add(i);
|
|
28
|
+
return next;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div className={`w-full max-w-2xl space-y-2 ${className}`}>
|
|
33
|
+
{items.map((item, i) => {
|
|
34
|
+
const on = open.has(i);
|
|
35
|
+
return (
|
|
36
|
+
<div
|
|
37
|
+
key={i}
|
|
38
|
+
className={`overflow-hidden rounded-2xl border transition-colors duration-300 ${dark ? "text-white" : "text-neutral-900"} ${
|
|
39
|
+
on ? (dark ? "border-white/15 bg-white/[0.05]" : "border-black/10 bg-black/[0.03]") : dark ? "border-white/10 bg-transparent hover:bg-white/[0.03]" : "border-black/10 bg-transparent hover:bg-black/[0.02]"
|
|
40
|
+
}`}
|
|
41
|
+
>
|
|
42
|
+
<button
|
|
43
|
+
type="button"
|
|
44
|
+
id={`${id}-q-${i}`}
|
|
45
|
+
aria-expanded={on}
|
|
46
|
+
aria-controls={`${id}-a-${i}`}
|
|
47
|
+
onClick={() => toggle(i)}
|
|
48
|
+
className="flex w-full items-center justify-between gap-4 px-5 py-4 text-left text-[0.95rem] font-semibold outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-current/30"
|
|
49
|
+
>
|
|
50
|
+
{item.q}
|
|
51
|
+
<motion.span
|
|
52
|
+
animate={{ rotate: on ? 45 : 0, backgroundColor: on ? accentColor : dark ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.05)", color: on ? "#0a0a0a" : dark ? "#fff" : "#111" }}
|
|
53
|
+
transition={{ type: "spring", stiffness: 380, damping: 26 }}
|
|
54
|
+
className="grid h-7 w-7 shrink-0 place-items-center rounded-full"
|
|
55
|
+
>
|
|
56
|
+
<Plus className="h-4 w-4" />
|
|
57
|
+
</motion.span>
|
|
58
|
+
</button>
|
|
59
|
+
<AnimatePresence initial={false}>
|
|
60
|
+
{on && (
|
|
61
|
+
<motion.div
|
|
62
|
+
id={`${id}-a-${i}`}
|
|
63
|
+
role="region"
|
|
64
|
+
aria-labelledby={`${id}-q-${i}`}
|
|
65
|
+
initial={{ height: 0, opacity: 0 }}
|
|
66
|
+
animate={{ height: "auto", opacity: 1 }}
|
|
67
|
+
exit={{ height: 0, opacity: 0 }}
|
|
68
|
+
transition={{ height: { duration: 0.32, ease: [0.22, 1, 0.36, 1] }, opacity: { duration: 0.2 } }}
|
|
69
|
+
>
|
|
70
|
+
<motion.p
|
|
71
|
+
initial={{ y: -6 }}
|
|
72
|
+
animate={{ y: 0 }}
|
|
73
|
+
exit={{ y: -6 }}
|
|
74
|
+
className={`px-5 pb-5 text-[0.9rem] leading-relaxed ${dark ? "text-white/65" : "text-neutral-600"}`}
|
|
75
|
+
>
|
|
76
|
+
{item.a}
|
|
77
|
+
</motion.p>
|
|
78
|
+
</motion.div>
|
|
79
|
+
)}
|
|
80
|
+
</AnimatePresence>
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
})}
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export default FaqAccordion;
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { animate, motion, useMotionValue, useTransform } from "framer-motion";
|
|
3
|
+
import { ChevronLeft, ChevronRight } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* Flip Book: a small 3D hardcover book. Pages turn around the spine and lift
|
|
7
|
+
* off the stack while a shadow sweeps across them; drag a page to turn it
|
|
8
|
+
* by hand, click it, use the buttons or the arrow keys. The page edges
|
|
9
|
+
* thicken on whichever side has more pages, and every page is numbered.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const IK = "https://ik.imagekit.io/ny6yinyut/apexui/assets";
|
|
13
|
+
const DEFAULT_PAGES = [
|
|
14
|
+
{ title: "ApexUI", body: "A field guide to motion", cover: true },
|
|
15
|
+
{ title: "Chapter 1", body: "Start small. One spring, one easing, one idea per interaction." },
|
|
16
|
+
{ title: "Chapter 2", body: "Motion should explain — where things come from and where they go.", image: `${IK}/spiderman.png?tr=f-auto,q-80,w-500` },
|
|
17
|
+
{ title: "Chapter 3", body: "Keep it fast. Animate transform and opacity, never layout." },
|
|
18
|
+
{ title: "Chapter 4", body: "Respect reduced motion. Calm is a feature too.", image: `${IK}/doctorStrange.png?tr=f-auto,q-80,w-500` },
|
|
19
|
+
{ title: "Chapter 5", body: "Details compound. Tiny shadows and easing make it feel real." },
|
|
20
|
+
{ title: "Chapter 6", body: "Test on a slow phone. If it stutters there, it is not done." },
|
|
21
|
+
{ title: "The end", body: "Now go make something that feels alive.", cover: true },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const PAPER = "#f7f2e8";
|
|
25
|
+
|
|
26
|
+
function Face({ page, number, accentColor, back }) {
|
|
27
|
+
const round = back ? "rounded-l-[0.7rem] rounded-r-sm" : "rounded-r-[0.7rem] rounded-l-sm";
|
|
28
|
+
if (!page) return <div className={`h-full w-full ${round}`} style={{ background: PAPER }} />;
|
|
29
|
+
if (page.cover) {
|
|
30
|
+
return (
|
|
31
|
+
<div className={`relative flex h-full w-full flex-col justify-between overflow-hidden p-6 text-neutral-950 ${round}`} style={{ background: `linear-gradient(135deg, ${accentColor}, color-mix(in srgb, ${accentColor} 50%, #000))` }}>
|
|
32
|
+
<div className="pointer-events-none absolute inset-3 rounded-md border border-black/15" />
|
|
33
|
+
<span className="text-[0.62rem] font-bold uppercase tracking-[0.3em] opacity-70">Volume 1</span>
|
|
34
|
+
<div>
|
|
35
|
+
<p className="text-[1.6rem] font-black leading-none">{page.title}</p>
|
|
36
|
+
<p className="mt-2 text-[0.82rem] font-medium opacity-80">{page.body}</p>
|
|
37
|
+
</div>
|
|
38
|
+
{/* Cloth texture */}
|
|
39
|
+
<div className="pointer-events-none absolute inset-0 opacity-[0.12] mix-blend-multiply" style={{ backgroundImage: "repeating-linear-gradient(45deg, #000 0 1px, transparent 1px 4px)" }} />
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
return (
|
|
44
|
+
<div className={`flex h-full w-full flex-col p-5 text-neutral-800 ${round}`} style={{ background: PAPER }}>
|
|
45
|
+
<p className="text-[0.62rem] font-bold uppercase tracking-[0.25em]" style={{ color: `color-mix(in srgb, ${accentColor} 50%, #000)` }}>
|
|
46
|
+
{page.title}
|
|
47
|
+
</p>
|
|
48
|
+
{page.image && <img src={page.image} alt="" draggable="false" className="mt-3 h-[38%] w-full rounded-md object-cover object-top" />}
|
|
49
|
+
<p className="mt-3 font-serif text-[0.98rem] leading-relaxed">{page.body}</p>
|
|
50
|
+
<div className="mt-auto flex items-end justify-between gap-3">
|
|
51
|
+
{!page.image && (
|
|
52
|
+
<div className="flex-1 space-y-1.5" aria-hidden="true">
|
|
53
|
+
{[90, 75, 82].map((w, i) => (
|
|
54
|
+
<span key={i} className="block h-1.5 rounded-full bg-neutral-900/10" style={{ width: `${w}%` }} />
|
|
55
|
+
))}
|
|
56
|
+
</div>
|
|
57
|
+
)}
|
|
58
|
+
<span className={`font-mono text-[0.65rem] text-neutral-500 ${back ? "order-first" : "ml-auto"}`}>{number}</span>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Stacked page edges peeking out under a page.
|
|
65
|
+
const edges = (count, side) => {
|
|
66
|
+
const n = Math.min(6, Math.ceil(count * 1.5));
|
|
67
|
+
const dir = side === "right" ? 1 : -1;
|
|
68
|
+
return Array.from({ length: n }, (_, k) => `${dir * (k + 1)}px ${k + 1}px 0 ${k % 2 ? "#e4dccb" : "#efe8da"}`).join(", ") || "none";
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
function Sheet({ index, sheet, flipped, active, dragAngle, accentColor, total, onClick }) {
|
|
72
|
+
const rot = useMotionValue(flipped ? -180 : 0);
|
|
73
|
+
// Lift while turning, and darken the page as it faces away from the light.
|
|
74
|
+
const z = useTransform(rot, [0, -90, -180], [0, 40, 0]);
|
|
75
|
+
const frontShade = useTransform(rot, [0, -90], [0, 0.45]);
|
|
76
|
+
const backShade = useTransform(rot, [-90, -180], [0.45, 0]);
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (dragAngle != null) rot.set(dragAngle);
|
|
80
|
+
else animate(rot, flipped ? -180 : 0, { type: "spring", stiffness: 75, damping: 16 });
|
|
81
|
+
}, [flipped, dragAngle, rot]);
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<motion.div className="group absolute inset-0 origin-left cursor-pointer [transform-style:preserve-3d]" style={{ rotateY: rot, z, zIndex: active ? 50 : flipped ? index : total - index }} onClick={onClick}>
|
|
85
|
+
<div className="absolute inset-0 shadow-[0_18px_36px_-18px_rgba(0,0,0,0.55)] [backface-visibility:hidden]">
|
|
86
|
+
<Face page={sheet.front} number={index * 2 + 1} accentColor={accentColor} />
|
|
87
|
+
<div className="pointer-events-none absolute inset-y-0 left-0 w-10 bg-gradient-to-r from-black/20 via-black/5 to-transparent" />
|
|
88
|
+
<motion.div className="pointer-events-none absolute inset-0 rounded-r-[0.7rem] bg-gradient-to-l from-black to-black/40" style={{ opacity: frontShade }} />
|
|
89
|
+
{/* Dog-ear hint */}
|
|
90
|
+
{!flipped && <span className="pointer-events-none absolute bottom-0 right-0 h-7 w-7 origin-bottom-right scale-0 rounded-tl-md bg-gradient-to-tl from-transparent from-50% to-[#e2d9c6] to-50% shadow-[-2px_-2px_6px_rgba(0,0,0,0.15)] transition-transform duration-300 group-hover:scale-100" />}
|
|
91
|
+
</div>
|
|
92
|
+
<div className="absolute inset-0 shadow-[0_18px_36px_-18px_rgba(0,0,0,0.55)] [backface-visibility:hidden] [transform:rotateY(180deg)]">
|
|
93
|
+
<Face page={sheet.back} number={index * 2 + 2} accentColor={accentColor} back />
|
|
94
|
+
<div className="pointer-events-none absolute inset-y-0 right-0 w-10 bg-gradient-to-l from-black/20 via-black/5 to-transparent" />
|
|
95
|
+
<motion.div className="pointer-events-none absolute inset-0 rounded-l-[0.7rem] bg-gradient-to-r from-black to-black/40" style={{ opacity: backShade }} />
|
|
96
|
+
</div>
|
|
97
|
+
</motion.div>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const FlipBook = ({ pages = DEFAULT_PAGES, width = 12, accentColor = "#a3e635", className = "" }) => {
|
|
102
|
+
// Sheets: page 0+1 on sheet 0, 2+3 on sheet 1, …
|
|
103
|
+
const sheets = [];
|
|
104
|
+
for (let i = 0; i < pages.length; i += 2) sheets.push({ front: pages[i], back: pages[i + 1] });
|
|
105
|
+
const [turned, setTurned] = useState(0);
|
|
106
|
+
const [drag, setDrag] = useState(null); // { sheet, angle }
|
|
107
|
+
const start = useRef(null);
|
|
108
|
+
const dragged = useRef(false);
|
|
109
|
+
const pageRef = useRef(null);
|
|
110
|
+
const go = (dir) => setTurned((t) => Math.min(sheets.length, Math.max(0, t + dir)));
|
|
111
|
+
const open = turned > 0 && turned < sheets.length;
|
|
112
|
+
|
|
113
|
+
const onDown = (e) => {
|
|
114
|
+
dragged.current = false;
|
|
115
|
+
start.current = { x: e.clientX, moved: false, w: pageRef.current.offsetWidth };
|
|
116
|
+
};
|
|
117
|
+
const onMove = (e) => {
|
|
118
|
+
const s = start.current;
|
|
119
|
+
if (!s) return;
|
|
120
|
+
const dx = e.clientX - s.x;
|
|
121
|
+
if (!s.moved && Math.abs(dx) < 6) return;
|
|
122
|
+
if (!s.moved) {
|
|
123
|
+
s.moved = true;
|
|
124
|
+
s.sheet = dx < 0 ? turned : turned - 1;
|
|
125
|
+
s.from = dx < 0 ? 0 : -180;
|
|
126
|
+
if (s.sheet < 0 || s.sheet >= sheets.length) return (start.current = null);
|
|
127
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
128
|
+
}
|
|
129
|
+
const angle = Math.max(-180, Math.min(0, s.from + (dx / s.w) * 180));
|
|
130
|
+
setDrag({ sheet: s.sheet, angle });
|
|
131
|
+
};
|
|
132
|
+
const onUp = () => {
|
|
133
|
+
const s = start.current;
|
|
134
|
+
start.current = null;
|
|
135
|
+
if (!s?.moved || !drag) return;
|
|
136
|
+
dragged.current = true;
|
|
137
|
+
const flippedNow = drag.angle < -90;
|
|
138
|
+
setTurned(flippedNow ? s.sheet + 1 : s.sheet);
|
|
139
|
+
setDrag(null);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<div
|
|
144
|
+
className={`flex flex-col items-center gap-7 outline-none ${className}`}
|
|
145
|
+
tabIndex={0}
|
|
146
|
+
onKeyDown={(e) => {
|
|
147
|
+
if (e.key === "ArrowRight") go(1);
|
|
148
|
+
if (e.key === "ArrowLeft") go(-1);
|
|
149
|
+
}}
|
|
150
|
+
aria-label="Flip book"
|
|
151
|
+
>
|
|
152
|
+
<div className="relative flex touch-pan-y select-none [perspective:2000px]" style={{ width: `min(${width * 2}rem, 92vw)` }} onPointerDown={onDown} onPointerMove={onMove} onPointerUp={onUp} onPointerCancel={onUp}>
|
|
153
|
+
{/* Hardcover boards behind the open spread */}
|
|
154
|
+
<div className="pointer-events-none absolute -inset-x-2 -inset-y-2 rounded-xl transition-opacity duration-500" style={{ opacity: open ? 1 : 0, background: `color-mix(in srgb, ${accentColor} 35%, #111)`, boxShadow: "0 30px 60px -25px rgba(0,0,0,0.6)" }} />
|
|
155
|
+
<div className="flex-1" />
|
|
156
|
+
<div ref={pageRef} className="relative aspect-[3/4] flex-1 [transform-style:preserve-3d]" style={{ transform: `translateX(${turned === 0 && !drag ? "-50%" : turned === sheets.length && !drag ? "50%" : "0%"})`, transition: "transform 600ms cubic-bezier(0.22,1,0.36,1)" }}>
|
|
157
|
+
<div className="absolute inset-y-0 right-full w-full rounded-l-[0.7rem] transition-[opacity,box-shadow] duration-500" style={{ opacity: turned > 0 ? 1 : 0, boxShadow: edges(turned, "left") }} />
|
|
158
|
+
<div className="absolute inset-0 rounded-r-[0.7rem] transition-[opacity,box-shadow] duration-500" style={{ opacity: turned < sheets.length ? 1 : 0, boxShadow: edges(sheets.length - turned, "right") }} />
|
|
159
|
+
{sheets.map((sheet, i) => (
|
|
160
|
+
<Sheet
|
|
161
|
+
key={i}
|
|
162
|
+
index={i}
|
|
163
|
+
sheet={sheet}
|
|
164
|
+
total={sheets.length}
|
|
165
|
+
flipped={i < turned}
|
|
166
|
+
active={drag?.sheet === i}
|
|
167
|
+
dragAngle={drag?.sheet === i ? drag.angle : null}
|
|
168
|
+
accentColor={accentColor}
|
|
169
|
+
onClick={() => !dragged.current && go(i < turned ? -1 : 1)}
|
|
170
|
+
/>
|
|
171
|
+
))}
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
<div className="flex items-center gap-4">
|
|
175
|
+
<button type="button" aria-label="Previous page" onClick={() => go(-1)} disabled={turned === 0} className="grid h-10 w-10 place-items-center rounded-full border border-current/20 transition hover:scale-105 active:scale-95 disabled:opacity-30">
|
|
176
|
+
<ChevronLeft className="h-5 w-5" />
|
|
177
|
+
</button>
|
|
178
|
+
<div className="flex gap-1" aria-hidden="true">
|
|
179
|
+
{Array.from({ length: sheets.length + 1 }, (_, i) => (
|
|
180
|
+
<span key={i} className="h-1.5 rounded-full transition-all duration-300" style={{ width: i === turned ? 18 : 6, background: i === turned ? accentColor : "color-mix(in srgb, currentColor 25%, transparent)" }} />
|
|
181
|
+
))}
|
|
182
|
+
</div>
|
|
183
|
+
<button type="button" aria-label="Next page" onClick={() => go(1)} disabled={turned === sheets.length} className="grid h-10 w-10 place-items-center rounded-full border border-current/20 transition hover:scale-105 active:scale-95 disabled:opacity-30">
|
|
184
|
+
<ChevronRight className="h-5 w-5" />
|
|
185
|
+
</button>
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
);
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
export default FlipBook;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { motion, useReducedMotion } from "framer-motion";
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* Flip Clock: split-flap digits that fold over when they change. Count down
|
|
6
|
+
* to a date, count down a number of seconds, or show the time of day.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
10
|
+
|
|
11
|
+
function partsFor(mode, target, seconds, start) {
|
|
12
|
+
const now = Date.now();
|
|
13
|
+
if (mode === "clock") {
|
|
14
|
+
const d = new Date(now);
|
|
15
|
+
return [
|
|
16
|
+
["Hours", pad(d.getHours())],
|
|
17
|
+
["Minutes", pad(d.getMinutes())],
|
|
18
|
+
["Seconds", pad(d.getSeconds())],
|
|
19
|
+
];
|
|
20
|
+
}
|
|
21
|
+
const end = mode === "date" ? new Date(target).getTime() : start + seconds * 1000;
|
|
22
|
+
const left = Math.max(0, Math.floor((end - now) / 1000));
|
|
23
|
+
return [
|
|
24
|
+
["Days", pad(Math.floor(left / 86400))],
|
|
25
|
+
["Hours", pad(Math.floor((left % 86400) / 3600))],
|
|
26
|
+
["Minutes", pad(Math.floor((left % 3600) / 60))],
|
|
27
|
+
["Seconds", pad(left % 60)],
|
|
28
|
+
];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// One half of a digit card: the full glyph, clipped to the top or bottom half.
|
|
32
|
+
function Half({ digit, top, card, style, ...motionProps }) {
|
|
33
|
+
return (
|
|
34
|
+
<motion.span
|
|
35
|
+
className={`absolute inset-x-0 h-1/2 overflow-hidden ${top ? "top-0 origin-bottom rounded-t-[0.14em]" : "bottom-0 origin-top rounded-b-[0.14em]"}`}
|
|
36
|
+
style={{ background: card, backfaceVisibility: "hidden", ...style }}
|
|
37
|
+
{...motionProps}
|
|
38
|
+
>
|
|
39
|
+
<span className={`absolute inset-x-0 grid h-[200%] place-items-center leading-none ${top ? "top-0" : "bottom-0"}`}>{digit}</span>
|
|
40
|
+
</motion.span>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function Flap({ digit, card, ink, moving }) {
|
|
45
|
+
const [prev, setPrev] = useState(digit);
|
|
46
|
+
const [flip, setFlip] = useState(0);
|
|
47
|
+
|
|
48
|
+
// A new digit: fold the old top half down, then swing the new bottom half in.
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (digit === prev) return undefined;
|
|
51
|
+
if (!moving) {
|
|
52
|
+
setPrev(digit);
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
setFlip((f) => f + 1);
|
|
56
|
+
const t = setTimeout(() => setPrev(digit), 620);
|
|
57
|
+
return () => clearTimeout(t);
|
|
58
|
+
}, [digit, prev, moving]);
|
|
59
|
+
|
|
60
|
+
const flipping = digit !== prev;
|
|
61
|
+
return (
|
|
62
|
+
<span className="relative inline-block h-[1.35em] w-[0.9em] rounded-[0.14em] [perspective:400px]" style={{ color: ink }}>
|
|
63
|
+
<Half digit={digit} top card={card} />
|
|
64
|
+
<Half digit={flipping ? prev : digit} card={card} />
|
|
65
|
+
{flipping && (
|
|
66
|
+
<React.Fragment key={flip}>
|
|
67
|
+
<Half digit={prev} top card={card} style={{ zIndex: 2 }} initial={{ rotateX: 0 }} animate={{ rotateX: -90 }} transition={{ duration: 0.3, ease: "easeIn" }} />
|
|
68
|
+
<Half digit={digit} card={card} style={{ zIndex: 2 }} initial={{ rotateX: 90 }} animate={{ rotateX: 0 }} transition={{ duration: 0.3, delay: 0.3, ease: "easeOut" }} />
|
|
69
|
+
</React.Fragment>
|
|
70
|
+
)}
|
|
71
|
+
<span aria-hidden="true" className="absolute inset-x-0 top-1/2 z-[3] h-px bg-black/40" />
|
|
72
|
+
</span>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const FlipClock = ({ mode = "seconds", target, seconds = 3 * 86400 + 4 * 3600 + 25 * 60, accentColor = "#a3e635", theme = "dark", showLabels = true, size = 3, className = "" }) => {
|
|
77
|
+
const reduced = useReducedMotion();
|
|
78
|
+
const start = useRef(Date.now());
|
|
79
|
+
const [parts, setParts] = useState(() => partsFor(mode, target, seconds, start.current));
|
|
80
|
+
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
start.current = Date.now();
|
|
83
|
+
const tick = () => setParts(partsFor(mode, target, seconds, start.current));
|
|
84
|
+
tick();
|
|
85
|
+
const id = setInterval(tick, 250);
|
|
86
|
+
return () => clearInterval(id);
|
|
87
|
+
}, [mode, target, seconds]);
|
|
88
|
+
|
|
89
|
+
const dark = theme !== "light";
|
|
90
|
+
const card = dark ? "#18181b" : "#f4f4f5";
|
|
91
|
+
const ink = dark ? "#fafafa" : "#18181b";
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<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: `${size}rem` }}>
|
|
95
|
+
{parts.map(([label, value], i) => (
|
|
96
|
+
<React.Fragment key={label}>
|
|
97
|
+
{i > 0 && (
|
|
98
|
+
<span aria-hidden="true" className="flex h-[1.35em] flex-col justify-center gap-[0.18em]">
|
|
99
|
+
<span className="h-[0.12em] w-[0.12em] rounded-full" style={{ background: accentColor }} />
|
|
100
|
+
<span className="h-[0.12em] w-[0.12em] rounded-full" style={{ background: accentColor }} />
|
|
101
|
+
</span>
|
|
102
|
+
)}
|
|
103
|
+
<span className="flex flex-col items-center">
|
|
104
|
+
<span aria-hidden="true" className="flex gap-[0.08em]">
|
|
105
|
+
{[...value].map((d, k) => (
|
|
106
|
+
<Flap key={k} digit={d} card={card} ink={ink} moving={!reduced} />
|
|
107
|
+
))}
|
|
108
|
+
</span>
|
|
109
|
+
{showLabels && (
|
|
110
|
+
<span className="mt-[0.4em] text-[0.22em] font-semibold uppercase tracking-[0.2em]" style={{ color: dark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.5)" }}>
|
|
111
|
+
{label}
|
|
112
|
+
</span>
|
|
113
|
+
)}
|
|
114
|
+
</span>
|
|
115
|
+
</React.Fragment>
|
|
116
|
+
))}
|
|
117
|
+
</div>
|
|
118
|
+
);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export default FlipClock;
|