apex-ui-kit 2.1.2 → 2.2.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/AnimatedButton/AnimatedButton.jsx +251 -0
- package/dist/ApexUI-Kit/templates/AnimatedTabs/AnimatedTabs.jsx +99 -0
- package/dist/ApexUI-Kit/templates/BlurRevealText/BlurRevealText.jsx +60 -0
- package/dist/ApexUI-Kit/templates/Carousel3D/Carousel3D.jsx +222 -0
- package/dist/ApexUI-Kit/templates/DotGlobe/DotGlobe.jsx +289 -0
- package/dist/ApexUI-Kit/templates/DotWaveBackground/DotWaveBackground.jsx +189 -0
- package/dist/ApexUI-Kit/templates/DynamicIsland/DynamicIsland.jsx +221 -0
- package/dist/ApexUI-Kit/templates/FaqAccordion/FaqAccordion.jsx +88 -0
- package/dist/ApexUI-Kit/templates/FlipClock/FlipClock.jsx +121 -0
- package/dist/ApexUI-Kit/templates/LiquidGradient/LiquidGradient.jsx +238 -0
- package/dist/ApexUI-Kit/templates/LoadingScreen/LoadingScreen.jsx +361 -0
- package/dist/ApexUI-Kit/templates/MagneticCursor/MagneticCursor.jsx +306 -0
- package/dist/ApexUI-Kit/templates/MagnifyDock/MagnifyDock.jsx +154 -0
- package/dist/ApexUI-Kit/templates/ProfileCard/ProfileCard.jsx +213 -0
- package/dist/ApexUI-Kit/templates/RollingCounter/RollingCounter.jsx +143 -0
- package/dist/ApexUI-Kit/templates/ScrambleText/ScrambleText.jsx +132 -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/ToastStack/ToastStack.jsx +108 -0
- package/dist/ApexUI-Kit/templates/ToolTip/ToolTip.jsx +5 -3
- 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 +25 -0
- package/package.json +1 -1
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
|
|
3
|
+
import { MessageCircle, Pause, Phone, PhoneOff, Play, SkipBack, SkipForward, Timer } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* Dynamic Island: a black capsule that morphs between live activities —
|
|
7
|
+
* idle, music, call, timer and notification. Click the music view to expand
|
|
8
|
+
* it. Runs through the views on its own, or follows a controlled `view`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const IK = "https://ik.imagekit.io/ny6yinyut/apexui/assets";
|
|
12
|
+
const ALL_VIEWS = ["idle", "music", "call", "timer", "notification"];
|
|
13
|
+
|
|
14
|
+
// Capsule size per view, in rem so it scales with the page's font size.
|
|
15
|
+
const SIZES = {
|
|
16
|
+
idle: { width: "7.5rem", height: "2.1rem", borderRadius: "1.05rem" },
|
|
17
|
+
music: { width: "17rem", height: "2.6rem", borderRadius: "1.3rem" },
|
|
18
|
+
musicExpanded: { width: "22rem", height: "11.25rem", borderRadius: "2.4rem" },
|
|
19
|
+
call: { width: "22rem", height: "4.6rem", borderRadius: "2.3rem" },
|
|
20
|
+
timer: { width: "14rem", height: "2.6rem", borderRadius: "1.3rem" },
|
|
21
|
+
notification: { width: "22rem", height: "4.6rem", borderRadius: "2.3rem" },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const spring = { type: "spring", stiffness: 380, damping: 32, mass: 0.9 };
|
|
25
|
+
|
|
26
|
+
const formatTime = (s) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
|
27
|
+
|
|
28
|
+
function Waveform({ color, playing, moving }) {
|
|
29
|
+
const bars = [0.55, 1, 0.7, 0.9, 0.45];
|
|
30
|
+
return (
|
|
31
|
+
<span className="flex h-4 items-center gap-[3px]" aria-hidden="true">
|
|
32
|
+
{bars.map((peak, i) => (
|
|
33
|
+
<motion.span
|
|
34
|
+
key={i}
|
|
35
|
+
className="w-[3px] origin-center rounded-full"
|
|
36
|
+
style={{ backgroundColor: color, height: "100%" }}
|
|
37
|
+
animate={playing && moving ? { scaleY: [0.3, peak, 0.4, peak * 0.8, 0.3] } : { scaleY: 0.3 }}
|
|
38
|
+
transition={playing && moving ? { duration: 0.9 + i * 0.12, repeat: Infinity, ease: "easeInOut" } : { duration: 0.3 }}
|
|
39
|
+
/>
|
|
40
|
+
))}
|
|
41
|
+
</span>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const DynamicIsland = ({
|
|
46
|
+
view,
|
|
47
|
+
defaultView = "music",
|
|
48
|
+
views = ALL_VIEWS,
|
|
49
|
+
autoPlay = true,
|
|
50
|
+
interval = 3600,
|
|
51
|
+
accentColor = "#a3e635",
|
|
52
|
+
music = { title: "Midnight City", artist: "Apex Sessions", cover: `${IK}/spiderman.png?tr=f-auto,q-80,w-200` },
|
|
53
|
+
call = { name: "Tony Stark", subtitle: "Mobile", avatar: `${IK}/ironman.png?tr=f-auto,q-80,w-160` },
|
|
54
|
+
notification = { app: "Messages", title: "Peter Parker", message: "On my way — saving the city first 🕷️" },
|
|
55
|
+
timerSeconds = 300,
|
|
56
|
+
onViewChange,
|
|
57
|
+
className = "",
|
|
58
|
+
}) => {
|
|
59
|
+
const controlled = view !== undefined;
|
|
60
|
+
const [ownView, setOwnView] = useState(views.includes(defaultView) ? defaultView : views[0]);
|
|
61
|
+
const current = controlled ? view : ownView;
|
|
62
|
+
const [expanded, setExpanded] = useState(false);
|
|
63
|
+
const [playing, setPlaying] = useState(true);
|
|
64
|
+
const [hovered, setHovered] = useState(false);
|
|
65
|
+
const [remaining, setRemaining] = useState(timerSeconds);
|
|
66
|
+
const moving = !useReducedMotion();
|
|
67
|
+
const onViewChangeRef = useRef(onViewChange);
|
|
68
|
+
onViewChangeRef.current = onViewChange;
|
|
69
|
+
|
|
70
|
+
// Cycle through the views on its own (uncontrolled), pausing while hovered or expanded.
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (controlled || !autoPlay || hovered || expanded || views.length < 2) return undefined;
|
|
73
|
+
const id = setTimeout(() => {
|
|
74
|
+
setOwnView((v) => {
|
|
75
|
+
const next = views[(views.indexOf(v) + 1) % views.length];
|
|
76
|
+
onViewChangeRef.current?.(next);
|
|
77
|
+
return next;
|
|
78
|
+
});
|
|
79
|
+
}, interval);
|
|
80
|
+
return () => clearTimeout(id);
|
|
81
|
+
}, [controlled, autoPlay, hovered, expanded, views, interval, ownView]);
|
|
82
|
+
|
|
83
|
+
// A new view always starts compact.
|
|
84
|
+
useEffect(() => setExpanded(false), [current]);
|
|
85
|
+
|
|
86
|
+
// Live countdown while the timer is showing.
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
if (current !== "timer") return undefined;
|
|
89
|
+
const id = setInterval(() => setRemaining((s) => (s > 0 ? s - 1 : timerSeconds)), 1000);
|
|
90
|
+
return () => clearInterval(id);
|
|
91
|
+
}, [current, timerSeconds]);
|
|
92
|
+
|
|
93
|
+
const sizeKey = current === "music" && expanded ? "musicExpanded" : current;
|
|
94
|
+
const size = SIZES[sizeKey] ?? SIZES.idle;
|
|
95
|
+
const canExpand = current === "music";
|
|
96
|
+
|
|
97
|
+
const content = {
|
|
98
|
+
idle: <span className="sr-only">Idle</span>,
|
|
99
|
+
music: (
|
|
100
|
+
<div className="flex h-full w-full items-center justify-between pl-1.5 pr-4">
|
|
101
|
+
<img src={music.cover} alt="" className="h-[1.85rem] w-[1.85rem] rounded-full object-cover" draggable="false" />
|
|
102
|
+
<span className="sr-only">Now playing {music.title} by {music.artist}</span>
|
|
103
|
+
<Waveform color={accentColor} playing={playing} moving={moving} />
|
|
104
|
+
</div>
|
|
105
|
+
),
|
|
106
|
+
musicExpanded: (
|
|
107
|
+
<div className="flex h-full w-full flex-col justify-between p-5">
|
|
108
|
+
<div className="flex items-center gap-3.5">
|
|
109
|
+
<img src={music.cover} alt="" className="h-14 w-14 rounded-2xl object-cover" draggable="false" />
|
|
110
|
+
<div className="min-w-0 flex-1">
|
|
111
|
+
<p className="truncate text-[0.95rem] font-semibold">{music.title}</p>
|
|
112
|
+
<p className="truncate text-[0.8rem] text-white/50">{music.artist}</p>
|
|
113
|
+
</div>
|
|
114
|
+
<Waveform color={accentColor} playing={playing} moving={moving} />
|
|
115
|
+
</div>
|
|
116
|
+
<div>
|
|
117
|
+
<div className="h-1 overflow-hidden rounded-full bg-white/15">
|
|
118
|
+
<motion.div
|
|
119
|
+
className="h-full rounded-full"
|
|
120
|
+
style={{ backgroundColor: accentColor }}
|
|
121
|
+
initial={{ width: "32%" }}
|
|
122
|
+
animate={{ width: playing && moving ? "78%" : "32%" }}
|
|
123
|
+
transition={{ duration: playing && moving ? 20 : 0.3, ease: "linear" }}
|
|
124
|
+
/>
|
|
125
|
+
</div>
|
|
126
|
+
<div className="mt-3 flex items-center justify-center gap-7">
|
|
127
|
+
<button type="button" aria-label="Previous" onClick={(e) => e.stopPropagation()} className="text-white/70 transition hover:text-white">
|
|
128
|
+
<SkipBack className="h-5 w-5" fill="currentColor" />
|
|
129
|
+
</button>
|
|
130
|
+
<button
|
|
131
|
+
type="button"
|
|
132
|
+
aria-label={playing ? "Pause" : "Play"}
|
|
133
|
+
onClick={(e) => {
|
|
134
|
+
e.stopPropagation();
|
|
135
|
+
setPlaying((p) => !p);
|
|
136
|
+
}}
|
|
137
|
+
className="text-white transition hover:scale-105"
|
|
138
|
+
>
|
|
139
|
+
{playing ? <Pause className="h-7 w-7" fill="currentColor" /> : <Play className="h-7 w-7" fill="currentColor" />}
|
|
140
|
+
</button>
|
|
141
|
+
<button type="button" aria-label="Next" onClick={(e) => e.stopPropagation()} className="text-white/70 transition hover:text-white">
|
|
142
|
+
<SkipForward className="h-5 w-5" fill="currentColor" />
|
|
143
|
+
</button>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
),
|
|
148
|
+
call: (
|
|
149
|
+
<div className="flex h-full w-full items-center gap-3 pl-2.5 pr-2.5">
|
|
150
|
+
<img src={call.avatar} alt="" className="h-[3.4rem] w-[3.4rem] rounded-full object-cover" draggable="false" />
|
|
151
|
+
<div className="min-w-0 flex-1">
|
|
152
|
+
<p className="truncate text-[0.72rem] text-white/50">{call.subtitle}</p>
|
|
153
|
+
<p className="truncate text-[0.95rem] font-semibold">{call.name}</p>
|
|
154
|
+
</div>
|
|
155
|
+
<button type="button" aria-label="Decline" onClick={(e) => e.stopPropagation()} className="grid h-11 w-11 place-items-center rounded-full bg-[#ff453a] transition hover:brightness-110">
|
|
156
|
+
<PhoneOff className="h-[1.1rem] w-[1.1rem]" />
|
|
157
|
+
</button>
|
|
158
|
+
<button type="button" aria-label="Accept" onClick={(e) => e.stopPropagation()} className="grid h-11 w-11 place-items-center rounded-full bg-[#30d158] transition hover:brightness-110">
|
|
159
|
+
<Phone className="h-[1.1rem] w-[1.1rem]" fill="currentColor" />
|
|
160
|
+
</button>
|
|
161
|
+
</div>
|
|
162
|
+
),
|
|
163
|
+
timer: (
|
|
164
|
+
<div className="flex h-full w-full items-center justify-between pl-3 pr-4">
|
|
165
|
+
<span className="grid h-[1.85rem] w-[1.85rem] place-items-center rounded-full bg-[#ff9f0a]/20 text-[#ff9f0a]">
|
|
166
|
+
<Timer className="h-4 w-4" />
|
|
167
|
+
</span>
|
|
168
|
+
<span className="font-mono text-[0.95rem] font-semibold tabular-nums text-[#ff9f0a]" aria-label={`Timer ${formatTime(remaining)} left`}>
|
|
169
|
+
{formatTime(remaining)}
|
|
170
|
+
</span>
|
|
171
|
+
</div>
|
|
172
|
+
),
|
|
173
|
+
notification: (
|
|
174
|
+
<div className="flex h-full w-full items-center gap-3 pl-2.5 pr-5">
|
|
175
|
+
<span className="grid h-[3.4rem] w-[3.4rem] shrink-0 place-items-center rounded-full" style={{ backgroundColor: accentColor }}>
|
|
176
|
+
<MessageCircle className="h-6 w-6 text-black" fill="currentColor" />
|
|
177
|
+
</span>
|
|
178
|
+
<div className="min-w-0 flex-1">
|
|
179
|
+
<p className="flex items-center justify-between gap-2 text-[0.72rem] text-white/50">
|
|
180
|
+
<span className="truncate">{notification.app}</span>
|
|
181
|
+
<span className="shrink-0">now</span>
|
|
182
|
+
</p>
|
|
183
|
+
<p className="truncate text-[0.9rem] font-semibold">{notification.title}</p>
|
|
184
|
+
<p className="truncate text-[0.78rem] text-white/70">{notification.message}</p>
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
),
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
return (
|
|
191
|
+
<div className={`flex w-full justify-center ${className}`}>
|
|
192
|
+
<motion.div
|
|
193
|
+
role="status"
|
|
194
|
+
aria-live="polite"
|
|
195
|
+
aria-label="Dynamic island"
|
|
196
|
+
onClick={canExpand ? () => setExpanded((e) => !e) : undefined}
|
|
197
|
+
onPointerEnter={(e) => e.pointerType === "mouse" && setHovered(true)}
|
|
198
|
+
onPointerLeave={() => setHovered(false)}
|
|
199
|
+
className={`relative max-w-[calc(100vw-2rem)] overflow-hidden bg-black text-white shadow-[0_18px_40px_-16px_rgba(0,0,0,0.65),inset_0_0_0_1px_rgba(255,255,255,0.06)] ${canExpand ? "cursor-pointer" : ""}`}
|
|
200
|
+
initial={false}
|
|
201
|
+
animate={size}
|
|
202
|
+
transition={moving ? spring : { duration: 0 }}
|
|
203
|
+
>
|
|
204
|
+
<AnimatePresence initial={false} mode="popLayout">
|
|
205
|
+
<motion.div
|
|
206
|
+
key={sizeKey}
|
|
207
|
+
className="absolute inset-0 flex"
|
|
208
|
+
initial={moving ? { opacity: 0, scale: 0.92, filter: "blur(6px)" } : { opacity: 0 }}
|
|
209
|
+
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
|
|
210
|
+
exit={moving ? { opacity: 0, scale: 0.92, filter: "blur(6px)" } : { opacity: 0 }}
|
|
211
|
+
transition={{ duration: 0.28, ease: [0.22, 1, 0.36, 1], delay: moving ? 0.06 : 0 }}
|
|
212
|
+
>
|
|
213
|
+
{content[sizeKey]}
|
|
214
|
+
</motion.div>
|
|
215
|
+
</AnimatePresence>
|
|
216
|
+
</motion.div>
|
|
217
|
+
</div>
|
|
218
|
+
);
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
export default DynamicIsland;
|
|
@@ -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,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;
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import React, { useEffect, useRef } from "react";
|
|
2
|
+
import { Mesh, Program, Renderer, Triangle } from "ogl";
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* Liquid Gradient: a slow, flowing four-color mesh gradient drawn by a single
|
|
6
|
+
* OGL fragment shader on one full-screen triangle. The colors lean toward the
|
|
7
|
+
* pointer, a light film grain keeps it from banding, and it pauses off screen.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const hexToVec3 = (hex) => {
|
|
11
|
+
let h = String(hex).replace("#", "");
|
|
12
|
+
if (h.length === 3) h = h.split("").map((c) => c + c).join("");
|
|
13
|
+
const n = parseInt(h, 16) || 0;
|
|
14
|
+
return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const vertex = /* glsl */ `
|
|
18
|
+
attribute vec2 uv;
|
|
19
|
+
attribute vec2 position;
|
|
20
|
+
varying vec2 vUv;
|
|
21
|
+
void main() {
|
|
22
|
+
vUv = uv;
|
|
23
|
+
gl_Position = vec4(position, 0.0, 1.0);
|
|
24
|
+
}
|
|
25
|
+
`;
|
|
26
|
+
|
|
27
|
+
const fragment = /* glsl */ `
|
|
28
|
+
precision highp float;
|
|
29
|
+
uniform float uTime;
|
|
30
|
+
uniform vec2 uResolution;
|
|
31
|
+
uniform vec2 uMouse;
|
|
32
|
+
uniform float uMouseStrength;
|
|
33
|
+
uniform float uScale;
|
|
34
|
+
uniform float uGrain;
|
|
35
|
+
uniform vec3 uColor1;
|
|
36
|
+
uniform vec3 uColor2;
|
|
37
|
+
uniform vec3 uColor3;
|
|
38
|
+
uniform vec3 uColor4;
|
|
39
|
+
varying vec2 vUv;
|
|
40
|
+
|
|
41
|
+
vec2 hash(vec2 p) {
|
|
42
|
+
p = vec2(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3)));
|
|
43
|
+
return -1.0 + 2.0 * fract(sin(p) * 43758.5453);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
float noise(vec2 p) {
|
|
47
|
+
vec2 i = floor(p);
|
|
48
|
+
vec2 f = fract(p);
|
|
49
|
+
vec2 u = f * f * (3.0 - 2.0 * f);
|
|
50
|
+
return mix(
|
|
51
|
+
mix(dot(hash(i), f), dot(hash(i + vec2(1.0, 0.0)), f - vec2(1.0, 0.0)), u.x),
|
|
52
|
+
mix(dot(hash(i + vec2(0.0, 1.0)), f - vec2(0.0, 1.0)), dot(hash(i + vec2(1.0, 1.0)), f - vec2(1.0, 1.0)), u.x),
|
|
53
|
+
u.y
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
float fbm(vec2 p) {
|
|
58
|
+
float v = 0.0;
|
|
59
|
+
float a = 0.5;
|
|
60
|
+
for (int i = 0; i < 4; i++) {
|
|
61
|
+
v += a * noise(p);
|
|
62
|
+
p *= 2.0;
|
|
63
|
+
a *= 0.5;
|
|
64
|
+
}
|
|
65
|
+
return v;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
void main() {
|
|
69
|
+
float aspect = uResolution.x / uResolution.y;
|
|
70
|
+
vec2 p = (vUv - 0.5) * vec2(aspect, 1.0) * uScale;
|
|
71
|
+
float t = uTime * 0.12;
|
|
72
|
+
|
|
73
|
+
// Pull the field toward the pointer.
|
|
74
|
+
vec2 m = (uMouse - 0.5) * vec2(aspect, 1.0) * uScale;
|
|
75
|
+
float md = length(p - m);
|
|
76
|
+
p += (m - p) * uMouseStrength * 0.35 * exp(-md * md * 1.5);
|
|
77
|
+
|
|
78
|
+
// Domain-warped noise: noise feeding noise gives the liquid folds.
|
|
79
|
+
vec2 q = vec2(fbm(p + t), fbm(p + vec2(5.2, 1.3) - t));
|
|
80
|
+
vec2 r = vec2(fbm(p + 3.0 * q + vec2(1.7, 9.2) + t * 0.6), fbm(p + 3.0 * q + vec2(8.3, 2.8) - t * 0.4));
|
|
81
|
+
float n = fbm(p + 2.5 * r);
|
|
82
|
+
|
|
83
|
+
vec3 col = mix(uColor1, uColor2, smoothstep(-0.4, 0.4, q.x));
|
|
84
|
+
col = mix(col, uColor3, smoothstep(-0.3, 0.5, r.y));
|
|
85
|
+
col = mix(col, uColor4, smoothstep(0.1, 0.7, n));
|
|
86
|
+
|
|
87
|
+
col *= 1.0 - 0.25 * length(vUv - 0.5);
|
|
88
|
+
float g = fract(sin(dot(gl_FragCoord.xy + fract(uTime) * 100.0, vec2(12.9898, 78.233))) * 43758.5453);
|
|
89
|
+
col += (g - 0.5) * uGrain;
|
|
90
|
+
|
|
91
|
+
gl_FragColor = vec4(col, 1.0);
|
|
92
|
+
}
|
|
93
|
+
`;
|
|
94
|
+
|
|
95
|
+
const LiquidGradient = ({
|
|
96
|
+
children,
|
|
97
|
+
className = "",
|
|
98
|
+
containerClassName = "",
|
|
99
|
+
colors = ["#1e1b4b", "#4f46e5", "#db2777", "#f97316"],
|
|
100
|
+
speed = 1,
|
|
101
|
+
scale = 1.6,
|
|
102
|
+
grain = 0.06,
|
|
103
|
+
mouseInteractive = true,
|
|
104
|
+
quality = 1,
|
|
105
|
+
}) => {
|
|
106
|
+
const containerRef = useRef(null);
|
|
107
|
+
const programRef = useRef(null);
|
|
108
|
+
const renderRef = useRef(() => {});
|
|
109
|
+
const speedRef = useRef(speed);
|
|
110
|
+
speedRef.current = speed;
|
|
111
|
+
const pointer = useRef({ x: 0.5, y: 0.5, tx: 0.5, ty: 0.5, strength: 0, target: 0 });
|
|
112
|
+
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
const container = containerRef.current;
|
|
115
|
+
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
116
|
+
const renderer = new Renderer({ dpr: Math.min(window.devicePixelRatio || 1, 1.5) * quality, antialias: false, alpha: false });
|
|
117
|
+
const gl = renderer.gl;
|
|
118
|
+
gl.canvas.style.cssText = "position:absolute;inset:0;width:100%;height:100%;display:block;";
|
|
119
|
+
container.prepend(gl.canvas);
|
|
120
|
+
|
|
121
|
+
const program = new Program(gl, {
|
|
122
|
+
vertex,
|
|
123
|
+
fragment,
|
|
124
|
+
uniforms: {
|
|
125
|
+
uTime: { value: 0 },
|
|
126
|
+
uResolution: { value: [1, 1] },
|
|
127
|
+
uMouse: { value: [0.5, 0.5] },
|
|
128
|
+
uMouseStrength: { value: 0 },
|
|
129
|
+
uScale: { value: scale },
|
|
130
|
+
uGrain: { value: grain },
|
|
131
|
+
uColor1: { value: hexToVec3(colors[0]) },
|
|
132
|
+
uColor2: { value: hexToVec3(colors[1] ?? colors[0]) },
|
|
133
|
+
uColor3: { value: hexToVec3(colors[2] ?? colors[0]) },
|
|
134
|
+
uColor4: { value: hexToVec3(colors[3] ?? colors[0]) },
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
programRef.current = program;
|
|
138
|
+
const mesh = new Mesh(gl, { geometry: new Triangle(gl), program });
|
|
139
|
+
|
|
140
|
+
let raf = 0;
|
|
141
|
+
let last = performance.now();
|
|
142
|
+
let time = 7; // start mid-flow so the first frame already looks liquid
|
|
143
|
+
let visible = true;
|
|
144
|
+
|
|
145
|
+
const render = () => renderer.render({ scene: mesh });
|
|
146
|
+
renderRef.current = render;
|
|
147
|
+
const resize = () => {
|
|
148
|
+
const { width, height } = container.getBoundingClientRect();
|
|
149
|
+
renderer.setSize(Math.max(1, width), Math.max(1, height));
|
|
150
|
+
program.uniforms.uResolution.value = [Math.max(1, width), Math.max(1, height)];
|
|
151
|
+
render();
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const loop = (now) => {
|
|
155
|
+
const dt = Math.min(0.05, (now - last) / 1000);
|
|
156
|
+
last = now;
|
|
157
|
+
time += dt * speedRef.current;
|
|
158
|
+
const p = pointer.current;
|
|
159
|
+
p.x += (p.tx - p.x) * 0.06;
|
|
160
|
+
p.y += (p.ty - p.y) * 0.06;
|
|
161
|
+
p.strength += (p.target - p.strength) * 0.05;
|
|
162
|
+
program.uniforms.uTime.value = time;
|
|
163
|
+
program.uniforms.uMouse.value = [p.x, p.y];
|
|
164
|
+
program.uniforms.uMouseStrength.value = p.strength;
|
|
165
|
+
render();
|
|
166
|
+
raf = requestAnimationFrame(loop);
|
|
167
|
+
};
|
|
168
|
+
const start = () => {
|
|
169
|
+
if (reduced || raf || !visible) return;
|
|
170
|
+
last = performance.now();
|
|
171
|
+
raf = requestAnimationFrame(loop);
|
|
172
|
+
};
|
|
173
|
+
const stop = () => {
|
|
174
|
+
cancelAnimationFrame(raf);
|
|
175
|
+
raf = 0;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const ro = new ResizeObserver(resize);
|
|
179
|
+
ro.observe(container);
|
|
180
|
+
const io = new IntersectionObserver(([entry]) => {
|
|
181
|
+
visible = entry.isIntersecting;
|
|
182
|
+
if (visible) start();
|
|
183
|
+
else stop();
|
|
184
|
+
});
|
|
185
|
+
io.observe(container);
|
|
186
|
+
program.uniforms.uTime.value = time;
|
|
187
|
+
resize();
|
|
188
|
+
start();
|
|
189
|
+
|
|
190
|
+
return () => {
|
|
191
|
+
stop();
|
|
192
|
+
ro.disconnect();
|
|
193
|
+
io.disconnect();
|
|
194
|
+
programRef.current = null;
|
|
195
|
+
renderRef.current = () => {};
|
|
196
|
+
gl.canvas.remove();
|
|
197
|
+
gl.getExtension("WEBGL_lose_context")?.loseContext();
|
|
198
|
+
};
|
|
199
|
+
// The renderer is rebuilt only when quality changes; other props update uniforms below.
|
|
200
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
201
|
+
}, [quality]);
|
|
202
|
+
|
|
203
|
+
useEffect(() => {
|
|
204
|
+
const program = programRef.current;
|
|
205
|
+
if (!program) return;
|
|
206
|
+
program.uniforms.uScale.value = scale;
|
|
207
|
+
program.uniforms.uGrain.value = grain;
|
|
208
|
+
program.uniforms.uColor1.value = hexToVec3(colors[0]);
|
|
209
|
+
program.uniforms.uColor2.value = hexToVec3(colors[1] ?? colors[0]);
|
|
210
|
+
program.uniforms.uColor3.value = hexToVec3(colors[2] ?? colors[0]);
|
|
211
|
+
program.uniforms.uColor4.value = hexToVec3(colors[3] ?? colors[0]);
|
|
212
|
+
// Redraw now so a paused (reduced-motion) gradient shows the change too.
|
|
213
|
+
renderRef.current();
|
|
214
|
+
}, [colors, scale, grain]);
|
|
215
|
+
|
|
216
|
+
const onPointerMove = (e) => {
|
|
217
|
+
const rect = containerRef.current.getBoundingClientRect();
|
|
218
|
+
pointer.current.tx = (e.clientX - rect.left) / rect.width;
|
|
219
|
+
pointer.current.ty = 1 - (e.clientY - rect.top) / rect.height;
|
|
220
|
+
pointer.current.target = 1;
|
|
221
|
+
};
|
|
222
|
+
const onPointerLeave = () => {
|
|
223
|
+
pointer.current.target = 0;
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
return (
|
|
227
|
+
<div
|
|
228
|
+
ref={containerRef}
|
|
229
|
+
onPointerMove={mouseInteractive ? onPointerMove : undefined}
|
|
230
|
+
onPointerLeave={onPointerLeave}
|
|
231
|
+
className={`relative h-screen w-full overflow-hidden bg-[#0b0b14] ${containerClassName}`}
|
|
232
|
+
>
|
|
233
|
+
{children && <div className={`relative z-10 h-full w-full ${className}`}>{children}</div>}
|
|
234
|
+
</div>
|
|
235
|
+
);
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
export default LiquidGradient;
|