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,154 @@
|
|
|
1
|
+
import React, { useRef, useState } from "react";
|
|
2
|
+
import { AnimatePresence, motion, useAnimationControls, useMotionValue, useReducedMotion, useSpring, useTransform } from "framer-motion";
|
|
3
|
+
import { Compass, House, Image, Mail, MessageCircle, Music, Settings } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* Magnify Dock: a glass dock whose icons swell toward the pointer, like the
|
|
7
|
+
* macOS dock. Hover shows a label, a click bounces the icon and marks it open.
|
|
8
|
+
* Sizes are in rem so the dock scales with the page's font size.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const DEFAULT_ITEMS = [
|
|
12
|
+
{ label: "Home", icon: House, color: "linear-gradient(160deg,#60a5fa,#2563eb)" },
|
|
13
|
+
{ label: "Explore", icon: Compass, color: "linear-gradient(160deg,#a78bfa,#7c3aed)" },
|
|
14
|
+
{ label: "Music", icon: Music, color: "linear-gradient(160deg,#fb7185,#e11d48)" },
|
|
15
|
+
{ label: "Photos", icon: Image, color: "linear-gradient(160deg,#fcd34d,#f97316)" },
|
|
16
|
+
{ label: "Messages", icon: MessageCircle, color: "linear-gradient(160deg,#4ade80,#16a34a)" },
|
|
17
|
+
{ label: "Mail", icon: Mail, color: "linear-gradient(160deg,#38bdf8,#0369a1)" },
|
|
18
|
+
{ type: "separator" },
|
|
19
|
+
{ label: "Settings", icon: Settings, color: "linear-gradient(160deg,#a1a1aa,#52525b)" },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const THEMES = {
|
|
23
|
+
dark: {
|
|
24
|
+
panel: "border-white/10 bg-neutral-900/75 shadow-[0_20px_50px_-20px_rgba(0,0,0,0.7),inset_0_1px_0_rgba(255,255,255,0.08)]",
|
|
25
|
+
glassTile: "bg-white/10 text-white",
|
|
26
|
+
label: "bg-neutral-900/90 text-white border-white/10",
|
|
27
|
+
separator: "bg-white/15",
|
|
28
|
+
dot: "bg-white/80",
|
|
29
|
+
},
|
|
30
|
+
light: {
|
|
31
|
+
panel: "border-black/10 bg-white/70 shadow-[0_20px_50px_-24px_rgba(0,0,0,0.35),inset_0_1px_0_rgba(255,255,255,0.9)]",
|
|
32
|
+
glassTile: "bg-black/[0.06] text-neutral-800",
|
|
33
|
+
label: "bg-white/95 text-neutral-900 border-black/10",
|
|
34
|
+
separator: "bg-black/15",
|
|
35
|
+
dot: "bg-neutral-800/80",
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function DockItem({ item, index, mouseX, baseSize, magnification, distance, theme, tileStyle, showLabels, open, onSelect }) {
|
|
40
|
+
const ref = useRef(null);
|
|
41
|
+
const [hovered, setHovered] = useState(false);
|
|
42
|
+
const controls = useAnimationControls();
|
|
43
|
+
const t = THEMES[theme] ?? THEMES.dark;
|
|
44
|
+
|
|
45
|
+
// Distance from the pointer to this icon's centre, mapped to a size factor.
|
|
46
|
+
const offset = useTransform(mouseX, (x) => {
|
|
47
|
+
const rect = ref.current?.getBoundingClientRect();
|
|
48
|
+
return rect ? x - (rect.left + rect.width / 2) : Infinity;
|
|
49
|
+
});
|
|
50
|
+
const target = useTransform(offset, [-distance, 0, distance], [1, magnification, 1]);
|
|
51
|
+
const factor = useSpring(target, { mass: 0.1, stiffness: 170, damping: 14 });
|
|
52
|
+
const size = useTransform(factor, (f) => `${f * baseSize}rem`);
|
|
53
|
+
|
|
54
|
+
const Icon = item.icon;
|
|
55
|
+
const Tag = item.href ? motion.a : motion.button;
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<motion.div className="relative flex flex-col items-center" style={{ width: size }}>
|
|
59
|
+
<AnimatePresence>
|
|
60
|
+
{showLabels && hovered && (
|
|
61
|
+
<motion.span
|
|
62
|
+
className={`pointer-events-none absolute bottom-full mb-3 whitespace-nowrap rounded-lg border px-2.5 py-1 text-[0.75rem] font-medium backdrop-blur-md ${t.label}`}
|
|
63
|
+
initial={{ opacity: 0, y: 6, scale: 0.9 }}
|
|
64
|
+
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
65
|
+
exit={{ opacity: 0, y: 4, scale: 0.95 }}
|
|
66
|
+
transition={{ duration: 0.16 }}
|
|
67
|
+
>
|
|
68
|
+
{item.label}
|
|
69
|
+
</motion.span>
|
|
70
|
+
)}
|
|
71
|
+
</AnimatePresence>
|
|
72
|
+
<Tag
|
|
73
|
+
ref={ref}
|
|
74
|
+
type={item.href ? undefined : "button"}
|
|
75
|
+
href={item.href}
|
|
76
|
+
aria-label={item.label}
|
|
77
|
+
animate={controls}
|
|
78
|
+
onClick={(e) => {
|
|
79
|
+
controls.start({ y: [0, -14, 0, -5, 0], transition: { duration: 0.7, ease: "easeOut" } });
|
|
80
|
+
onSelect(index, item, e);
|
|
81
|
+
}}
|
|
82
|
+
onPointerEnter={(e) => e.pointerType === "mouse" && setHovered(true)}
|
|
83
|
+
onPointerLeave={() => setHovered(false)}
|
|
84
|
+
onFocus={() => setHovered(true)}
|
|
85
|
+
onBlur={() => setHovered(false)}
|
|
86
|
+
className={`grid aspect-square w-full place-items-center rounded-[24%] outline-none transition-shadow focus-visible:ring-2 focus-visible:ring-white/70 ${tileStyle === "glass" ? t.glassTile : "text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.35),0_6px_14px_-6px_rgba(0,0,0,0.5)]"}`}
|
|
87
|
+
style={tileStyle === "glass" ? undefined : { background: item.color }}
|
|
88
|
+
>
|
|
89
|
+
{Icon && <Icon className="h-[48%] w-[48%]" strokeWidth={2} />}
|
|
90
|
+
</Tag>
|
|
91
|
+
<span className={`absolute -bottom-[0.45rem] h-1 w-1 rounded-full transition-opacity ${t.dot} ${open ? "opacity-100" : "opacity-0"}`} />
|
|
92
|
+
</motion.div>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const MagnifyDock = ({
|
|
97
|
+
items = DEFAULT_ITEMS,
|
|
98
|
+
baseSize = 2.75,
|
|
99
|
+
magnification = 1.7,
|
|
100
|
+
distance = 140,
|
|
101
|
+
theme = "dark",
|
|
102
|
+
tileStyle = "color",
|
|
103
|
+
showLabels = true,
|
|
104
|
+
defaultOpen = [0],
|
|
105
|
+
onSelect,
|
|
106
|
+
className = "",
|
|
107
|
+
}) => {
|
|
108
|
+
const mouseX = useMotionValue(Infinity);
|
|
109
|
+
const [open, setOpen] = useState(() => new Set(defaultOpen));
|
|
110
|
+
const reduced = useReducedMotion();
|
|
111
|
+
const t = THEMES[theme] ?? THEMES.dark;
|
|
112
|
+
const reset = () => mouseX.set(Infinity);
|
|
113
|
+
|
|
114
|
+
const select = (index, item, e) => {
|
|
115
|
+
setOpen((prev) => new Set(prev).add(index));
|
|
116
|
+
onSelect?.(item, index, e);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<nav aria-label="Dock" className={`flex w-full justify-center ${className}`}>
|
|
121
|
+
<div
|
|
122
|
+
onPointerMove={(e) => mouseX.set(e.clientX)}
|
|
123
|
+
onPointerLeave={reset}
|
|
124
|
+
onPointerUp={(e) => e.pointerType !== "mouse" && reset()}
|
|
125
|
+
onPointerCancel={reset}
|
|
126
|
+
className={`flex max-w-full items-end gap-[0.45rem] rounded-[1.35rem] border px-[0.55rem] pb-[0.6rem] pt-[0.55rem] backdrop-blur-xl ${t.panel}`}
|
|
127
|
+
style={{ height: `${baseSize + 1.15}rem`, touchAction: "pan-y" }}
|
|
128
|
+
>
|
|
129
|
+
{items.map((item, i) =>
|
|
130
|
+
item.type === "separator" ? (
|
|
131
|
+
<span key={`sep-${i}`} aria-hidden="true" className={`mx-[0.2rem] w-px self-stretch ${t.separator}`} />
|
|
132
|
+
) : (
|
|
133
|
+
<DockItem
|
|
134
|
+
key={item.label ?? i}
|
|
135
|
+
item={item}
|
|
136
|
+
index={i}
|
|
137
|
+
mouseX={mouseX}
|
|
138
|
+
baseSize={baseSize}
|
|
139
|
+
magnification={reduced ? 1 : magnification}
|
|
140
|
+
distance={distance}
|
|
141
|
+
theme={theme}
|
|
142
|
+
tileStyle={tileStyle}
|
|
143
|
+
showLabels={showLabels}
|
|
144
|
+
open={open.has(i)}
|
|
145
|
+
onSelect={select}
|
|
146
|
+
/>
|
|
147
|
+
)
|
|
148
|
+
)}
|
|
149
|
+
</div>
|
|
150
|
+
</nav>
|
|
151
|
+
);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
export default MagnifyDock;
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import React, { useEffect, useId, useMemo, useState } from "react";
|
|
2
|
+
import { animate, motion, useMotionValue, useReducedMotion, useSpring, useTransform } from "framer-motion";
|
|
3
|
+
import { Check, Github, Linkedin, MapPin, Plus, Twitter } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* Profile Card: one profile, two looks.
|
|
7
|
+
* card – cover, avatar, bio, stats, socials and a Follow button, with a soft 3D tilt
|
|
8
|
+
* lanyard – the same person as an ID badge hanging from a ribbon; drag it and
|
|
9
|
+
* let go to watch it swing back like a pendulum
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const DEFAULT_PROFILE = {
|
|
13
|
+
name: "Mohan Dalei",
|
|
14
|
+
role: "Frontend Engineer",
|
|
15
|
+
company: "ApexUI",
|
|
16
|
+
location: "Bhubaneswar, India",
|
|
17
|
+
bio: "Building animated React components that feel alive. Coffee, motion and clean code.",
|
|
18
|
+
avatar: "https://ik.imagekit.io/ny6yinyut/apexui/assets/black-panther.png?tr=f-auto,q-80,w-320",
|
|
19
|
+
id: "APX-0042",
|
|
20
|
+
stats: [
|
|
21
|
+
{ label: "Projects", value: "48" },
|
|
22
|
+
{ label: "Followers", value: "12.8k" },
|
|
23
|
+
{ label: "Stars", value: "3.2k" },
|
|
24
|
+
],
|
|
25
|
+
socials: [
|
|
26
|
+
{ icon: Github, href: "#", label: "GitHub" },
|
|
27
|
+
{ icon: Twitter, href: "#", label: "Twitter" },
|
|
28
|
+
{ icon: Linkedin, href: "#", label: "LinkedIn" },
|
|
29
|
+
],
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// Stable pseudo-random barcode bars from a string.
|
|
33
|
+
const barsFrom = (seed) => {
|
|
34
|
+
let h = 2166136261;
|
|
35
|
+
for (const ch of seed) h = Math.imul(h ^ ch.charCodeAt(0), 16777619);
|
|
36
|
+
return Array.from({ length: 34 }, (_, i) => 1 + (((h >>> (i % 24)) ^ (i * 7)) & 3));
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function CardVariant({ p, color, theme }) {
|
|
40
|
+
const [following, setFollowing] = useState(false);
|
|
41
|
+
const reduced = useReducedMotion();
|
|
42
|
+
const rx = useSpring(0, { stiffness: 200, damping: 20 });
|
|
43
|
+
const ry = useSpring(0, { stiffness: 200, damping: 20 });
|
|
44
|
+
const dark = theme !== "light";
|
|
45
|
+
|
|
46
|
+
const onMove = (e) => {
|
|
47
|
+
if (reduced || e.pointerType !== "mouse") return;
|
|
48
|
+
const r = e.currentTarget.getBoundingClientRect();
|
|
49
|
+
ry.set(((e.clientX - r.left) / r.width - 0.5) * 10);
|
|
50
|
+
rx.set(-((e.clientY - r.top) / r.height - 0.5) * 10);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div style={{ perspective: 1000 }}>
|
|
55
|
+
<motion.article
|
|
56
|
+
onPointerMove={onMove}
|
|
57
|
+
onPointerLeave={() => {
|
|
58
|
+
rx.set(0);
|
|
59
|
+
ry.set(0);
|
|
60
|
+
}}
|
|
61
|
+
style={{ rotateX: rx, rotateY: ry }}
|
|
62
|
+
className={`w-[min(21rem,88vw)] overflow-hidden rounded-3xl border shadow-[0_30px_60px_-30px_rgba(0,0,0,0.5)] ${dark ? "border-white/10 bg-neutral-950 text-white" : "border-black/10 bg-white text-neutral-900"}`}
|
|
63
|
+
>
|
|
64
|
+
<div className="relative h-24" style={{ background: `radial-gradient(120% 140% at 0% 0%, ${color}, transparent 60%), radial-gradient(120% 140% at 100% 0%, #6366f1, transparent 60%), ${dark ? "#111" : "#eee"}` }}>
|
|
65
|
+
<div className="absolute inset-0 opacity-30 [background-image:radial-gradient(rgba(255,255,255,.6)_1px,transparent_1px)] [background-size:12px_12px]" />
|
|
66
|
+
</div>
|
|
67
|
+
<div className="px-6 pb-6">
|
|
68
|
+
<div className="-mt-11 flex items-end justify-between">
|
|
69
|
+
<div className="relative">
|
|
70
|
+
<img src={p.avatar} alt="" className={`h-[5.5rem] w-[5.5rem] rounded-full object-cover ring-4 ${dark ? "bg-neutral-800 ring-neutral-950" : "bg-neutral-200 ring-white"}`} />
|
|
71
|
+
<span className={`absolute bottom-1.5 right-1.5 h-3.5 w-3.5 rounded-full bg-emerald-400 ring-[3px] ${dark ? "ring-neutral-950" : "ring-white"}`} aria-label="Online" />
|
|
72
|
+
</div>
|
|
73
|
+
<motion.button
|
|
74
|
+
type="button"
|
|
75
|
+
onClick={() => setFollowing((f) => !f)}
|
|
76
|
+
whileTap={{ scale: 0.94 }}
|
|
77
|
+
layout
|
|
78
|
+
className="mb-1 inline-flex h-9 items-center gap-1.5 rounded-full px-4 text-[0.8rem] font-semibold"
|
|
79
|
+
style={following ? { background: "transparent", color: dark ? "#fff" : "#111", boxShadow: `inset 0 0 0 1.5px ${dark ? "rgba(255,255,255,.25)" : "rgba(0,0,0,.2)"}` } : { background: color, color: "#0a0a0a" }}
|
|
80
|
+
>
|
|
81
|
+
{following ? <Check className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
|
|
82
|
+
{following ? "Following" : "Follow"}
|
|
83
|
+
</motion.button>
|
|
84
|
+
</div>
|
|
85
|
+
<h3 className="mt-3 text-lg font-semibold leading-tight">{p.name}</h3>
|
|
86
|
+
<p className={`text-[0.85rem] ${dark ? "text-white/55" : "text-neutral-500"}`}>
|
|
87
|
+
{p.role} · {p.company}
|
|
88
|
+
</p>
|
|
89
|
+
{p.location && (
|
|
90
|
+
<p className={`mt-1 flex items-center gap-1 text-[0.78rem] ${dark ? "text-white/45" : "text-neutral-400"}`}>
|
|
91
|
+
<MapPin className="h-3.5 w-3.5" /> {p.location}
|
|
92
|
+
</p>
|
|
93
|
+
)}
|
|
94
|
+
{p.bio && <p className={`mt-3 text-[0.85rem] leading-relaxed ${dark ? "text-white/75" : "text-neutral-600"}`}>{p.bio}</p>}
|
|
95
|
+
<dl className={`mt-4 grid grid-cols-3 divide-x rounded-2xl py-3 text-center ${dark ? "divide-white/10 bg-white/[0.04]" : "divide-black/10 bg-black/[0.03]"}`}>
|
|
96
|
+
{p.stats?.map((s) => (
|
|
97
|
+
<div key={s.label}>
|
|
98
|
+
<dd className="text-[1rem] font-semibold">{s.value}</dd>
|
|
99
|
+
<dt className={`text-[0.7rem] ${dark ? "text-white/45" : "text-neutral-500"}`}>{s.label}</dt>
|
|
100
|
+
</div>
|
|
101
|
+
))}
|
|
102
|
+
</dl>
|
|
103
|
+
<div className="mt-4 flex gap-2">
|
|
104
|
+
{p.socials?.map(({ icon: Icon, href, label }) => (
|
|
105
|
+
<a key={label} href={href} aria-label={label} className={`grid h-9 w-9 place-items-center rounded-full transition hover:-translate-y-0.5 ${dark ? "bg-white/[0.06] hover:bg-white/[0.12]" : "bg-black/[0.04] hover:bg-black/[0.08]"}`}>
|
|
106
|
+
<Icon className="h-4 w-4" />
|
|
107
|
+
</a>
|
|
108
|
+
))}
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
</motion.article>
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function LanyardVariant({ p, color, theme, strapLength }) {
|
|
117
|
+
const pathId = `strap-${useId().replace(/[^a-zA-Z0-9-]/g, "")}`;
|
|
118
|
+
const reduced = useReducedMotion();
|
|
119
|
+
const x = useMotionValue(0);
|
|
120
|
+
const y = useMotionValue(reduced ? 0 : -140);
|
|
121
|
+
const length = strapLength * 16;
|
|
122
|
+
const bars = useMemo(() => barsFrom(p.id || p.name), [p.id, p.name]);
|
|
123
|
+
const dark = theme !== "light";
|
|
124
|
+
const swing = { type: "spring", stiffness: 90, damping: 5, mass: 1 };
|
|
125
|
+
|
|
126
|
+
// Drop in on mount.
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
if (reduced) return undefined;
|
|
129
|
+
const a = animate(y, 0, { type: "spring", stiffness: 120, damping: 9 });
|
|
130
|
+
return () => a.stop();
|
|
131
|
+
}, [reduced, y]);
|
|
132
|
+
|
|
133
|
+
// The badge hangs along the strap, so its angle follows its offset.
|
|
134
|
+
const rotate = useTransform([x, y], ([vx, vy]) => (-Math.atan2(vx, Math.max(40, length + vy)) * 180) / Math.PI);
|
|
135
|
+
const d = useTransform([x, y], ([vx, vy]) => {
|
|
136
|
+
const ex = 160 + vx;
|
|
137
|
+
const ey = length + vy;
|
|
138
|
+
return `M160 0 Q ${160 + vx * 0.35} ${ey * 0.55} ${ex} ${ey}`;
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<div className="relative flex w-[20rem] max-w-full justify-center" style={{ height: `${strapLength + 22}rem` }}>
|
|
143
|
+
<svg className="pointer-events-none absolute left-1/2 top-0 -translate-x-1/2 overflow-visible" width="320" height={length + 40} aria-hidden="true">
|
|
144
|
+
<defs>
|
|
145
|
+
<motion.path id={pathId} d={d} />
|
|
146
|
+
</defs>
|
|
147
|
+
<motion.path d={d} fill="none" stroke={color} strokeWidth="26" strokeLinecap="butt" />
|
|
148
|
+
<motion.path d={d} fill="none" stroke="rgba(0,0,0,0.18)" strokeWidth="26" strokeDasharray="2 10" />
|
|
149
|
+
<text fontSize="11" fontWeight="800" letterSpacing="3" fill="#0a0a0a" dy="4">
|
|
150
|
+
<textPath href={`#${pathId}`} startOffset="8">
|
|
151
|
+
{`${(p.company || "APEXUI").toUpperCase()} • `.repeat(6)}
|
|
152
|
+
</textPath>
|
|
153
|
+
</text>
|
|
154
|
+
<rect x="148" y="-6" width="24" height="14" rx="3" fill={dark ? "#2a2a2a" : "#bbb"} />
|
|
155
|
+
</svg>
|
|
156
|
+
|
|
157
|
+
<motion.div
|
|
158
|
+
drag
|
|
159
|
+
dragMomentum={false}
|
|
160
|
+
onDragEnd={() => {
|
|
161
|
+
animate(x, 0, swing);
|
|
162
|
+
animate(y, 0, swing);
|
|
163
|
+
}}
|
|
164
|
+
whileDrag={{ cursor: "grabbing" }}
|
|
165
|
+
className="absolute left-1/2 cursor-grab touch-none"
|
|
166
|
+
style={{ x, y, rotate, top: length, marginLeft: "-7.25rem", transformOrigin: "50% 0%" }}
|
|
167
|
+
>
|
|
168
|
+
{/* Metal clip */}
|
|
169
|
+
<div className="mx-auto h-5 w-9 rounded-b-md rounded-t-sm bg-gradient-to-b from-neutral-300 to-neutral-500 shadow-inner" />
|
|
170
|
+
<article className={`-mt-1 w-[14.5rem] overflow-hidden rounded-2xl border shadow-[0_30px_60px_-24px_rgba(0,0,0,0.6)] ${dark ? "border-white/10 bg-neutral-950 text-white" : "border-black/10 bg-white text-neutral-900"}`}>
|
|
171
|
+
<div className="relative h-16 px-4 pt-3" style={{ background: color }}>
|
|
172
|
+
<span className="mx-auto block h-2.5 w-12 rounded-full bg-black/25" aria-hidden="true" />
|
|
173
|
+
<p className="mt-2 flex items-center justify-between text-[0.7rem] font-black uppercase tracking-[0.2em] text-black/80">
|
|
174
|
+
<span>{p.company}</span>
|
|
175
|
+
<span>Staff</span>
|
|
176
|
+
</p>
|
|
177
|
+
</div>
|
|
178
|
+
<div className="px-5 pb-5">
|
|
179
|
+
<img src={p.avatar} alt="" draggable="false" className={`relative -mt-8 h-24 w-24 rounded-2xl object-cover ring-4 ${dark ? "bg-neutral-800 ring-neutral-950" : "bg-neutral-200 ring-white"}`} />
|
|
180
|
+
<h3 className="mt-3 text-[1.05rem] font-bold leading-tight">{p.name}</h3>
|
|
181
|
+
<p className={`text-[0.78rem] ${dark ? "text-white/55" : "text-neutral-500"}`}>{p.role}</p>
|
|
182
|
+
<div className={`mt-3 grid grid-cols-2 gap-2 text-[0.68rem] ${dark ? "text-white/45" : "text-neutral-500"}`}>
|
|
183
|
+
<div>
|
|
184
|
+
<p className="uppercase tracking-wider">ID</p>
|
|
185
|
+
<p className={`font-mono text-[0.78rem] font-semibold ${dark ? "text-white" : "text-neutral-900"}`}>{p.id}</p>
|
|
186
|
+
</div>
|
|
187
|
+
<div>
|
|
188
|
+
<p className="uppercase tracking-wider">Location</p>
|
|
189
|
+
<p className={`truncate text-[0.78rem] font-semibold ${dark ? "text-white" : "text-neutral-900"}`}>{p.location?.split(",")[0]}</p>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
<div className="mt-4 flex h-9 items-stretch gap-[2px]" aria-hidden="true">
|
|
193
|
+
{bars.map((w, i) => (
|
|
194
|
+
<span key={i} className={dark ? "bg-white/80" : "bg-neutral-900"} style={{ width: w, opacity: i % 5 === 0 ? 0.5 : 1 }} />
|
|
195
|
+
))}
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
</article>
|
|
199
|
+
</motion.div>
|
|
200
|
+
</div>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const ProfileCard = ({ variant = "card", profile = DEFAULT_PROFILE, color = "#a3e635", theme = "dark", strapLength = 10, className = "" }) => {
|
|
205
|
+
const p = { ...DEFAULT_PROFILE, ...profile };
|
|
206
|
+
return (
|
|
207
|
+
<div className={`flex justify-center ${className}`}>
|
|
208
|
+
{variant === "lanyard" ? <LanyardVariant p={p} color={color} theme={theme} strapLength={strapLength} /> : <CardVariant p={p} color={color} theme={theme} />}
|
|
209
|
+
</div>
|
|
210
|
+
);
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
export default ProfileCard;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useRef } from "react";
|
|
2
|
+
import { AnimatePresence, motion, useInView, useReducedMotion, useSpring, useTransform } from "framer-motion";
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* Rolling Counter: an odometer-style number. Every digit is a wheel that
|
|
6
|
+
* rolls forward or back to its new value, carrying into the next wheel like a
|
|
7
|
+
* real odometer. Formats with Intl (separators, decimals, currency).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
|
11
|
+
const STEP = 1.15; // wheel height per digit, in em
|
|
12
|
+
const DEFAULT_SPRING = { stiffness: 70, damping: 18, mass: 1 };
|
|
13
|
+
|
|
14
|
+
function WheelDigit({ digit, position }) {
|
|
15
|
+
// Place each digit on a loop around the wheel so the wheel can spin either way.
|
|
16
|
+
const y = useTransform(position, (v) => {
|
|
17
|
+
const off = (((digit - v) % 10) + 10) % 10;
|
|
18
|
+
return `${(off > 5 ? off - 10 : off) * STEP}em`;
|
|
19
|
+
});
|
|
20
|
+
return (
|
|
21
|
+
<motion.span className="absolute inset-0 flex items-center justify-center" style={{ y }}>
|
|
22
|
+
{digit}
|
|
23
|
+
</motion.span>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function Wheel({ target, spring, moving }) {
|
|
28
|
+
// New wheels start at 0 and roll in.
|
|
29
|
+
const position = useSpring(moving ? 0 : target, spring);
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (!moving) {
|
|
32
|
+
position.jump(target);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
// Big jumps skip ahead (keeping the same digit) so the wheel spins at most ~2 turns.
|
|
36
|
+
const current = position.get();
|
|
37
|
+
const gap = target - current;
|
|
38
|
+
if (Math.abs(gap) > 20) position.jump(current + 10 * Math.round((gap - Math.sign(gap) * 20) / 10));
|
|
39
|
+
position.set(target);
|
|
40
|
+
}, [target, moving, position]);
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<span
|
|
44
|
+
className="relative inline-block overflow-hidden"
|
|
45
|
+
style={{
|
|
46
|
+
width: "1ch",
|
|
47
|
+
height: `${STEP}em`,
|
|
48
|
+
maskImage: "linear-gradient(to bottom, transparent, #000 22%, #000 78%, transparent)",
|
|
49
|
+
WebkitMaskImage: "linear-gradient(to bottom, transparent, #000 22%, #000 78%, transparent)",
|
|
50
|
+
}}
|
|
51
|
+
>
|
|
52
|
+
{DIGITS.map((d) => (
|
|
53
|
+
<WheelDigit key={d} digit={d} position={position} />
|
|
54
|
+
))}
|
|
55
|
+
</span>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const RollingCounter = ({
|
|
60
|
+
value = 0,
|
|
61
|
+
locale = "en-US",
|
|
62
|
+
decimals = 0,
|
|
63
|
+
currency,
|
|
64
|
+
prefix = "",
|
|
65
|
+
suffix = "",
|
|
66
|
+
fontSize = 4,
|
|
67
|
+
fontWeight = 700,
|
|
68
|
+
color = "currentColor",
|
|
69
|
+
startOnView = true,
|
|
70
|
+
spring = DEFAULT_SPRING,
|
|
71
|
+
className = "",
|
|
72
|
+
}) => {
|
|
73
|
+
const ref = useRef(null);
|
|
74
|
+
const inView = useInView(ref, { once: true, margin: "-10% 0px" });
|
|
75
|
+
const moving = !useReducedMotion();
|
|
76
|
+
const shown = startOnView && !inView ? 0 : value;
|
|
77
|
+
|
|
78
|
+
const formatter = useMemo(
|
|
79
|
+
() =>
|
|
80
|
+
new Intl.NumberFormat(locale, {
|
|
81
|
+
numberingSystem: "latn",
|
|
82
|
+
minimumFractionDigits: decimals,
|
|
83
|
+
maximumFractionDigits: decimals,
|
|
84
|
+
...(currency ? { style: "currency", currency } : {}),
|
|
85
|
+
}),
|
|
86
|
+
[locale, decimals, currency]
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
// Whole number of the smallest unit shown, e.g. 12.34 with 2 decimals -> 1234.
|
|
90
|
+
const scaled = Math.round(Math.abs(shown) * 10 ** decimals);
|
|
91
|
+
const text = formatter.format(shown);
|
|
92
|
+
|
|
93
|
+
// Walk the formatted string from the right so each wheel keeps its place as
|
|
94
|
+
// the number grows. Symbols before the first digit ($, -) keep a fixed key.
|
|
95
|
+
const isDigit = (ch) => ch >= "0" && ch <= "9";
|
|
96
|
+
const firstDigit = [...text].findIndex(isDigit);
|
|
97
|
+
const parts = [];
|
|
98
|
+
let place = 0;
|
|
99
|
+
for (let i = text.length - 1; i >= 0; i--) {
|
|
100
|
+
const ch = text[i];
|
|
101
|
+
if (isDigit(ch)) {
|
|
102
|
+
parts.unshift({ key: `d${place}`, place });
|
|
103
|
+
place += 1;
|
|
104
|
+
} else {
|
|
105
|
+
parts.unshift({ key: i < firstDigit ? `lead${i}-${ch}` : `s${place}-${ch}`, char: ch });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<span
|
|
111
|
+
ref={ref}
|
|
112
|
+
role="img"
|
|
113
|
+
aria-label={`${prefix}${formatter.format(value)}${suffix}`}
|
|
114
|
+
className={`inline-flex items-center whitespace-nowrap leading-none tabular-nums ${className}`}
|
|
115
|
+
style={{ fontSize: `${fontSize}rem`, fontWeight, color, fontVariantNumeric: "tabular-nums" }}
|
|
116
|
+
>
|
|
117
|
+
{prefix && <span aria-hidden="true">{prefix}</span>}
|
|
118
|
+
<AnimatePresence initial={false} mode="popLayout">
|
|
119
|
+
{parts.map((part) => (
|
|
120
|
+
<motion.span
|
|
121
|
+
key={part.key}
|
|
122
|
+
layout="position"
|
|
123
|
+
aria-hidden="true"
|
|
124
|
+
className="inline-flex"
|
|
125
|
+
initial={{ opacity: 0, y: "-0.4em" }}
|
|
126
|
+
animate={{ opacity: 1, y: 0 }}
|
|
127
|
+
exit={{ opacity: 0, y: "0.4em" }}
|
|
128
|
+
transition={{ duration: moving ? 0.35 : 0 }}
|
|
129
|
+
>
|
|
130
|
+
{part.char !== undefined ? (
|
|
131
|
+
part.char === " " || part.char === " " || part.char === " " ? " " : part.char
|
|
132
|
+
) : (
|
|
133
|
+
<Wheel target={Math.floor(scaled / 10 ** part.place)} spring={spring} moving={moving} />
|
|
134
|
+
)}
|
|
135
|
+
</motion.span>
|
|
136
|
+
))}
|
|
137
|
+
</AnimatePresence>
|
|
138
|
+
{suffix && <span aria-hidden="true">{suffix}</span>}
|
|
139
|
+
</span>
|
|
140
|
+
);
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
export default RollingCounter;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { useInView, useReducedMotion } from "framer-motion";
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* Scramble Text: letters cycle through random characters and lock into place
|
|
6
|
+
* one by one, like text being decrypted. Runs when it scrolls into view, on
|
|
7
|
+
* hover, or on a loop. Every letter keeps its real width, so nothing jumps.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const DEFAULT_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#%&*@$/<>";
|
|
11
|
+
|
|
12
|
+
// Random character, lowercased for lowercase letters so widths stay close.
|
|
13
|
+
const pick = (chars, ch = "") => {
|
|
14
|
+
const c = chars[Math.floor(Math.random() * chars.length)];
|
|
15
|
+
return ch !== ch.toUpperCase() ? c.toLowerCase() : c;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// Order in which the letters lock in.
|
|
19
|
+
function revealOrder(text, direction) {
|
|
20
|
+
const chars = [...text];
|
|
21
|
+
const idx = chars.map((_, i) => i).filter((i) => chars[i] !== " ");
|
|
22
|
+
if (direction === "end") return idx.reverse();
|
|
23
|
+
if (direction === "center") {
|
|
24
|
+
const mid = (chars.length - 1) / 2;
|
|
25
|
+
return idx.sort((a, b) => Math.abs(a - mid) - Math.abs(b - mid));
|
|
26
|
+
}
|
|
27
|
+
if (direction === "random") {
|
|
28
|
+
for (let i = idx.length - 1; i > 0; i--) {
|
|
29
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
30
|
+
[idx[i], idx[j]] = [idx[j], idx[i]];
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return idx;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const scrambleAll = (text, chars) => [...text].map((ch) => (ch === " " ? " " : pick(chars, ch)));
|
|
37
|
+
|
|
38
|
+
const ScrambleText = ({
|
|
39
|
+
text = "Decrypting the future",
|
|
40
|
+
as: Tag = "span",
|
|
41
|
+
trigger = "view",
|
|
42
|
+
speed = 40,
|
|
43
|
+
revealRate = 2,
|
|
44
|
+
direction = "start",
|
|
45
|
+
characters = DEFAULT_CHARS,
|
|
46
|
+
accentColor = "#a3e635",
|
|
47
|
+
loopDelay = 2400,
|
|
48
|
+
className = "",
|
|
49
|
+
...rest
|
|
50
|
+
}) => {
|
|
51
|
+
const ref = useRef(null);
|
|
52
|
+
const timers = useRef({ tick: 0, wait: 0 });
|
|
53
|
+
const reduced = useReducedMotion();
|
|
54
|
+
const inView = useInView(ref, { once: trigger === "view", margin: "-10% 0px" });
|
|
55
|
+
// null means "show the real text"; an array means scrambled letters, with locked ones in `locked`.
|
|
56
|
+
const [output, setOutput] = useState(() => (trigger === "view" && !reduced ? scrambleAll(text, characters) : null));
|
|
57
|
+
const [locked, setLocked] = useState(() => new Set());
|
|
58
|
+
const running = useRef(false);
|
|
59
|
+
|
|
60
|
+
const stop = useCallback(() => {
|
|
61
|
+
clearInterval(timers.current.tick);
|
|
62
|
+
clearTimeout(timers.current.wait);
|
|
63
|
+
running.current = false;
|
|
64
|
+
}, []);
|
|
65
|
+
|
|
66
|
+
const run = useCallback(() => {
|
|
67
|
+
if (reduced || running.current) return;
|
|
68
|
+
running.current = true;
|
|
69
|
+
const order = revealOrder(text, direction);
|
|
70
|
+
let tick = 0;
|
|
71
|
+
timers.current.tick = setInterval(() => {
|
|
72
|
+
tick += 1;
|
|
73
|
+
const count = Math.floor(tick / Math.max(1, revealRate));
|
|
74
|
+
const done = new Set(order.slice(0, count));
|
|
75
|
+
if (count >= order.length) {
|
|
76
|
+
clearInterval(timers.current.tick);
|
|
77
|
+
running.current = false;
|
|
78
|
+
setOutput(null);
|
|
79
|
+
setLocked(new Set());
|
|
80
|
+
if (trigger === "loop") timers.current.wait = setTimeout(run, loopDelay);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
setLocked(done);
|
|
84
|
+
setOutput([...text].map((ch, i) => (ch === " " || done.has(i) ? ch : pick(characters, ch))));
|
|
85
|
+
}, speed);
|
|
86
|
+
}, [reduced, text, direction, revealRate, speed, characters, trigger, loopDelay]);
|
|
87
|
+
|
|
88
|
+
// Start on view / loop; restart cleanly when the settings change.
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (reduced) {
|
|
91
|
+
setOutput(null);
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
if ((trigger === "view" || trigger === "loop") && inView) run();
|
|
95
|
+
return stop;
|
|
96
|
+
}, [trigger, inView, run, stop, reduced]);
|
|
97
|
+
|
|
98
|
+
const hoverProps = trigger === "hover" ? { onPointerEnter: run, onFocus: run } : {};
|
|
99
|
+
|
|
100
|
+
// Group letters by word so lines only wrap between words.
|
|
101
|
+
let index = 0;
|
|
102
|
+
const words = text.split(" ").map((word, w) => {
|
|
103
|
+
const letters = [...word].map((ch) => {
|
|
104
|
+
const i = index++;
|
|
105
|
+
const shown = output ? output[i] : ch;
|
|
106
|
+
const scrambling = output && !locked.has(i);
|
|
107
|
+
return (
|
|
108
|
+
<span key={i} className="relative inline-block">
|
|
109
|
+
<span className="invisible">{ch}</span>
|
|
110
|
+
<span className="absolute inset-0 text-center" style={scrambling ? { color: accentColor } : undefined}>
|
|
111
|
+
{shown}
|
|
112
|
+
</span>
|
|
113
|
+
</span>
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
index += 1; // the space
|
|
117
|
+
return (
|
|
118
|
+
<React.Fragment key={w}>
|
|
119
|
+
{w > 0 && " "}
|
|
120
|
+
<span className="inline-block whitespace-nowrap">{letters}</span>
|
|
121
|
+
</React.Fragment>
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<Tag ref={ref} aria-label={text} className={className} {...rest} {...hoverProps}>
|
|
127
|
+
<span aria-hidden="true">{words}</span>
|
|
128
|
+
</Tag>
|
|
129
|
+
);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
export default ScrambleText;
|