apex-ui-kit 2.7.0 → 2.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ReadMe.md +21 -9
- package/dist/ApexUI-Kit/templates/AnimatedTabs/AnimatedTabs.jsx +1 -1
- package/dist/ApexUI-Kit/templates/ArcGallery/ArcGallery.jsx +29 -11
- package/dist/ApexUI-Kit/templates/CircularCarousel/CircularCarousel.jsx +0 -1
- package/dist/ApexUI-Kit/templates/CommandPalette/CommandPalette.jsx +250 -0
- package/dist/ApexUI-Kit/templates/CrossedMarquee/CrossedMarquee.jsx +158 -0
- package/dist/ApexUI-Kit/templates/DitherWaves/DitherWaves.jsx +207 -0
- package/dist/ApexUI-Kit/templates/FlipClock/FlipClock.jsx +3 -2
- package/dist/ApexUI-Kit/templates/GlareCard/GlareCard.jsx +25 -18
- package/dist/ApexUI-Kit/templates/GlassLens/GlassLens.jsx +273 -0
- package/dist/ApexUI-Kit/templates/GooeyNav/GooeyNav.jsx +1 -1
- package/dist/ApexUI-Kit/templates/ImageAccordion/ImageAccordion.jsx +116 -0
- package/dist/ApexUI-Kit/templates/InfiniteCanvas/InfiniteCanvas.jsx +230 -0
- package/dist/ApexUI-Kit/templates/LiquidGlass/LiquidGlass.jsx +444 -0
- package/dist/ApexUI-Kit/templates/OrbitIcons/OrbitIcons.jsx +169 -0
- package/dist/ApexUI-Kit/templates/RadialMenu/RadialMenu.jsx +20 -4
- package/dist/ApexUI-Kit/templates/ScrollStack/ScrollStack.jsx +3 -3
- package/dist/ApexUI-Kit/templates/SunriseBackground/SunriseBackground.jsx +68 -2
- package/dist/ApexUI-Kit/templates/TextRing3D/TextRing3D.jsx +18 -3
- package/dist/ApexUI-Kit/templates/ThemeSwitcher/ThemeSwitcher.jsx +215 -0
- package/dist/ApexUI-Kit/templates/ThemeSwitcher/themes.js +38 -0
- package/dist/ApexUI-Kit/templates/ToastStack/ToastStack.jsx +21 -13
- package/dist/cli/index.js +10 -0
- package/package.json +2 -2
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
import React, { useEffect, useRef } from "react";
|
|
2
|
+
import { Color, Mesh, OrthographicCamera, PlaneGeometry, Scene, ShaderMaterial, TextureLoader, Vector2, Vector4, WebGLRenderer } from "three";
|
|
3
|
+
import { CloudSun, Compass, Home, Music2, Plus, Search, User } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* Liquid Glass: iOS style glass panels over a wallpaper, drawn in one WebGL
|
|
7
|
+
* shader. Each panel bends the picture at its rounded edges, frosts it a
|
|
8
|
+
* little inside, splits colours along the rim and catches a specular edge
|
|
9
|
+
* light. Panels are draggable, wobble like jelly while they move, and melt
|
|
10
|
+
* into each other when they get close. Content inside each panel is plain
|
|
11
|
+
* HTML that rides along with it.
|
|
12
|
+
*
|
|
13
|
+
* Props: items ({ id, x, y, offset?, width, maxWidth?, height, radius, content }[],
|
|
14
|
+
* up to 4; x / y place the centre as 0..1 of the box, offset nudges x in px,
|
|
15
|
+
* a width of 1 or less is a share of the box width), children (shown under
|
|
16
|
+
* the glass, e.g. hero text), image (wallpaper; a soft animated gradient when empty),
|
|
17
|
+
* refraction, frost, chroma, vibrancy, tint, tintAmount, merge (px of liquid
|
|
18
|
+
* blending),
|
|
19
|
+
* draggable, height, className.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const VERT = `void main(){ gl_Position = vec4(position.xy, 0.0, 1.0); }`;
|
|
23
|
+
|
|
24
|
+
const FRAG = `
|
|
25
|
+
precision highp float;
|
|
26
|
+
uniform vec2 uRes; uniform float uTime; uniform float uDpr;
|
|
27
|
+
uniform sampler2D uTex; uniform float uHasTex; uniform vec2 uCover;
|
|
28
|
+
uniform vec4 uBox[4]; uniform float uRad[4]; uniform float uCount;
|
|
29
|
+
uniform float uRefract; uniform float uFrost; uniform float uChroma; uniform vec3 uTint; uniform float uTintAmt; uniform float uMerge;
|
|
30
|
+
uniform vec2 uLight; uniform float uVibrancy;
|
|
31
|
+
|
|
32
|
+
float sdBox(vec2 p, vec2 b, float r){ vec2 q = abs(p) - b + r; return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r; }
|
|
33
|
+
float smin(float a, float b, float k){ float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0); return mix(b, a, h) - k * h * (1.0 - h); }
|
|
34
|
+
|
|
35
|
+
float scene(vec2 p){
|
|
36
|
+
float d = 1e5;
|
|
37
|
+
for (int i = 0; i < 4; i++) {
|
|
38
|
+
if (float(i) >= uCount) break;
|
|
39
|
+
float s = sdBox(p - uBox[i].xy, uBox[i].zw, uRad[i]);
|
|
40
|
+
d = smin(d, s, max(uMerge, 0.001));
|
|
41
|
+
}
|
|
42
|
+
return d;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Half-size (shorter side) of the panel closest to p, for a bevel that suits it.
|
|
46
|
+
float nearestSize(vec2 p){
|
|
47
|
+
float best = 1e5; float size = 30.0;
|
|
48
|
+
for (int i = 0; i < 4; i++) {
|
|
49
|
+
if (float(i) >= uCount) break;
|
|
50
|
+
float s = sdBox(p - uBox[i].xy, uBox[i].zw, uRad[i]);
|
|
51
|
+
if (s < best) { best = s; size = min(uBox[i].z, uBox[i].w); }
|
|
52
|
+
}
|
|
53
|
+
return size;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Default wallpaper: drifting colour blobs with fine stripes so the bending shows.
|
|
57
|
+
vec3 wallpaper(vec2 uv){
|
|
58
|
+
float t = uTime * 0.06;
|
|
59
|
+
vec2 a = vec2(0.25 + 0.15 * sin(t * 2.1), 0.3 + 0.2 * cos(t * 1.7));
|
|
60
|
+
vec2 b = vec2(0.75 + 0.12 * cos(t * 1.3), 0.65 + 0.15 * sin(t * 2.3));
|
|
61
|
+
vec2 c = vec2(0.5 + 0.25 * sin(t * 0.9 + 2.0), 0.15 + 0.1 * cos(t * 1.1));
|
|
62
|
+
vec2 asp = vec2(uRes.x / uRes.y, 1.0);
|
|
63
|
+
vec3 col = vec3(0.06, 0.07, 0.12);
|
|
64
|
+
col += vec3(1.0, 0.36, 0.42) * exp(-length((uv - a) * asp) * 3.2) * 0.95;
|
|
65
|
+
col += vec3(0.25, 0.45, 1.0) * exp(-length((uv - b) * asp) * 2.8) * 0.95;
|
|
66
|
+
col += vec3(1.0, 0.75, 0.25) * exp(-length((uv - c) * asp) * 3.6) * 0.8;
|
|
67
|
+
float stripes = smoothstep(0.35, 0.5, abs(fract((uv.x * asp.x + uv.y) * 14.0) - 0.5));
|
|
68
|
+
col *= 0.88 + 0.12 * stripes;
|
|
69
|
+
return col;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
vec3 bg(vec2 px){
|
|
73
|
+
vec2 uv = px / uRes;
|
|
74
|
+
if (uHasTex > 0.5) return texture2D(uTex, (uv - 0.5) * uCover + 0.5).rgb;
|
|
75
|
+
return wallpaper(uv);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
void main(){
|
|
79
|
+
vec2 p = gl_FragCoord.xy;
|
|
80
|
+
float d = scene(p);
|
|
81
|
+
vec3 base = bg(p);
|
|
82
|
+
|
|
83
|
+
// Two soft shadows under the glass: a tight contact one and a wide ambient one.
|
|
84
|
+
float sd = scene(p + vec2(0.0, 6.0 * uDpr));
|
|
85
|
+
float sd2 = scene(p + vec2(0.0, 18.0 * uDpr));
|
|
86
|
+
float outside = step(0.0, d);
|
|
87
|
+
vec3 col = base * (1.0 - outside * (0.22 * exp(-max(sd, 0.0) / (8.0 * uDpr)) + 0.18 * exp(-max(sd2, 0.0) / (34.0 * uDpr))));
|
|
88
|
+
|
|
89
|
+
if (d < 1.5 * uDpr) {
|
|
90
|
+
vec2 e = vec2(1.0, 0.0);
|
|
91
|
+
vec2 n = normalize(vec2(scene(p + e.xy) - scene(p - e.xy), scene(p + e.yx) - scene(p - e.yx)) + 1e-6);
|
|
92
|
+
// The rim curves over about half of the short side, like a real slab of glass.
|
|
93
|
+
float bevel = clamp(nearestSize(p) * 0.55, 10.0 * uDpr, 34.0 * uDpr);
|
|
94
|
+
float depth = clamp(-d / bevel, 0.0, 1.0); // 0 at the rim, 1 on the flat top
|
|
95
|
+
// A rounded edge profile: the steeper the surface, the more it bends light.
|
|
96
|
+
float x = 1.0 - depth;
|
|
97
|
+
float h = sqrt(max(1.0 - x * x, 0.0));
|
|
98
|
+
float slope = x / max(h, 0.08);
|
|
99
|
+
vec2 off = n * min(slope, 6.0) * uRefract * bevel * 0.32;
|
|
100
|
+
vec3 g = vec3(0.0);
|
|
101
|
+
float f = uFrost * 5.0 * uDpr;
|
|
102
|
+
for (int i = 0; i < 9; i++) {
|
|
103
|
+
float a = float(i) * 2.399;
|
|
104
|
+
vec2 o = f > 0.0 ? vec2(cos(a), sin(a)) * f * sqrt(float(i) / 9.0) : vec2(0.0);
|
|
105
|
+
vec2 q = p + off + o;
|
|
106
|
+
vec2 ch = n * x * x * uChroma * 2.5 * uDpr;
|
|
107
|
+
g += vec3(bg(q + ch).r, bg(q).g, bg(q - ch).b);
|
|
108
|
+
}
|
|
109
|
+
g /= 9.0;
|
|
110
|
+
// Vibrancy: what shows through gets a little richer and brighter.
|
|
111
|
+
float luma = dot(g, vec3(0.299, 0.587, 0.114));
|
|
112
|
+
g = mix(vec3(luma), g, 1.0 + 0.35 * uVibrancy) * (1.0 + 0.07 * uVibrancy) + 0.02 * uVibrancy;
|
|
113
|
+
g = mix(g, uTint, uTintAmt);
|
|
114
|
+
|
|
115
|
+
// Light follows the pointer, like tilting a phone.
|
|
116
|
+
vec2 L = normalize(uLight);
|
|
117
|
+
float facing = max(dot(n, L), 0.0);
|
|
118
|
+
float away = max(dot(n, -L), 0.0);
|
|
119
|
+
// Fresnel: the curved edge reflects more than the flat top.
|
|
120
|
+
g = mix(g, vec3(1.0), pow(x, 3.0) * 0.18);
|
|
121
|
+
// A crisp specular line on the rim, strongest facing the light, with a
|
|
122
|
+
// fainter bounce on the opposite edge.
|
|
123
|
+
float line = smoothstep(1.6 * uDpr, 0.0, abs(d + 0.9 * uDpr));
|
|
124
|
+
g += line * (0.75 * pow(facing, 1.5) + 0.32 * pow(away, 2.0) + 0.06);
|
|
125
|
+
// A broad glint just inside the lit edge.
|
|
126
|
+
g += smoothstep(0.55, 1.0, x) * pow(facing, 3.0) * 0.22;
|
|
127
|
+
// Inner shadow along the far edge gives the slab some thickness.
|
|
128
|
+
g *= 1.0 - smoothstep(0.6, 1.0, x) * pow(away, 1.5) * 0.18;
|
|
129
|
+
float mask = smoothstep(0.75 * uDpr, -0.75 * uDpr, d);
|
|
130
|
+
col = mix(col, g, mask);
|
|
131
|
+
}
|
|
132
|
+
gl_FragColor = vec4(col, 1.0);
|
|
133
|
+
}`;
|
|
134
|
+
|
|
135
|
+
const Tab = ({ icon: Icon, label, active }) => (
|
|
136
|
+
<span className={`flex flex-1 flex-col items-center gap-0.5 rounded-full py-1.5 text-[0.62rem] font-semibold ${active ? "bg-white/25 text-white" : "text-white/85"}`}>
|
|
137
|
+
<Icon className="h-[18px] w-[18px]" strokeWidth={2.2} />
|
|
138
|
+
{label}
|
|
139
|
+
</span>
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const DEFAULT_ITEMS = [
|
|
143
|
+
{
|
|
144
|
+
id: "bar",
|
|
145
|
+
x: 0.5,
|
|
146
|
+
y: 0.84,
|
|
147
|
+
width: 300,
|
|
148
|
+
height: 62,
|
|
149
|
+
radius: 31,
|
|
150
|
+
offset: -36,
|
|
151
|
+
content: (
|
|
152
|
+
<div className="flex h-full items-center gap-1 px-2">
|
|
153
|
+
<Tab icon={Home} label="Home" active />
|
|
154
|
+
<Tab icon={Compass} label="Explore" />
|
|
155
|
+
<Tab icon={Music2} label="Music" />
|
|
156
|
+
<Tab icon={User} label="Profile" />
|
|
157
|
+
</div>
|
|
158
|
+
),
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
id: "search",
|
|
162
|
+
x: 0.5,
|
|
163
|
+
y: 0.84,
|
|
164
|
+
width: 62,
|
|
165
|
+
height: 62,
|
|
166
|
+
radius: 31,
|
|
167
|
+
offset: 155,
|
|
168
|
+
content: (
|
|
169
|
+
<div className="grid h-full place-items-center text-white">
|
|
170
|
+
<Search className="h-5 w-5" strokeWidth={2.4} />
|
|
171
|
+
</div>
|
|
172
|
+
),
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
id: "widget",
|
|
176
|
+
x: 0.3,
|
|
177
|
+
y: 0.3,
|
|
178
|
+
width: 170,
|
|
179
|
+
height: 150,
|
|
180
|
+
radius: 34,
|
|
181
|
+
content: (
|
|
182
|
+
<div className="flex h-full flex-col justify-between p-4 text-white">
|
|
183
|
+
<div className="flex items-center justify-between text-xs font-semibold text-white/85">
|
|
184
|
+
Cupertino <CloudSun className="h-5 w-5" />
|
|
185
|
+
</div>
|
|
186
|
+
<div>
|
|
187
|
+
<p className="text-4xl font-light leading-none">24°</p>
|
|
188
|
+
<p className="mt-1 text-[0.7rem] text-white/80">Partly cloudy · H 27° L 18°</p>
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
),
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
id: "add",
|
|
195
|
+
x: 0.74,
|
|
196
|
+
y: 0.3,
|
|
197
|
+
width: 64,
|
|
198
|
+
height: 64,
|
|
199
|
+
radius: 22,
|
|
200
|
+
content: (
|
|
201
|
+
<div className="grid h-full place-items-center text-white">
|
|
202
|
+
<Plus className="h-6 w-6" strokeWidth={2.4} />
|
|
203
|
+
</div>
|
|
204
|
+
),
|
|
205
|
+
},
|
|
206
|
+
];
|
|
207
|
+
|
|
208
|
+
const LiquidGlass = ({ items = DEFAULT_ITEMS, children, image, refraction = 1, frost = 0.2, chroma = 1, vibrancy = 1, tint = "#ffffff", tintAmount = 0.06, merge = 18, draggable = true, height = "32rem", className = "" }) => {
|
|
209
|
+
const wrap = useRef(null);
|
|
210
|
+
const panels = useRef([]);
|
|
211
|
+
const live = useRef({});
|
|
212
|
+
live.current = { refraction, frost, chroma, vibrancy, tint, tintAmount, merge, draggable };
|
|
213
|
+
const list = items.slice(0, 4);
|
|
214
|
+
|
|
215
|
+
useEffect(() => {
|
|
216
|
+
const el = wrap.current;
|
|
217
|
+
const renderer = new WebGLRenderer({ antialias: false, alpha: false, powerPreference: "high-performance" });
|
|
218
|
+
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
219
|
+
renderer.setPixelRatio(dpr);
|
|
220
|
+
Object.assign(renderer.domElement.style, { position: "absolute", inset: "0", width: "100%", height: "100%", display: "block" });
|
|
221
|
+
el.prepend(renderer.domElement);
|
|
222
|
+
const scene = new Scene();
|
|
223
|
+
const camera = new OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
|
224
|
+
const u = {
|
|
225
|
+
uRes: { value: new Vector2(1, 1) },
|
|
226
|
+
uTime: { value: 0 },
|
|
227
|
+
uDpr: { value: dpr },
|
|
228
|
+
uTex: { value: null },
|
|
229
|
+
uHasTex: { value: 0 },
|
|
230
|
+
uCover: { value: new Vector2(1, 1) },
|
|
231
|
+
uBox: { value: Array.from({ length: 4 }, () => new Vector4()) },
|
|
232
|
+
uRad: { value: [0, 0, 0, 0] },
|
|
233
|
+
uCount: { value: 0 },
|
|
234
|
+
uRefract: { value: 1 },
|
|
235
|
+
uFrost: { value: 0.2 },
|
|
236
|
+
uLight: { value: new Vector2(-0.6, 0.8) },
|
|
237
|
+
uVibrancy: { value: 1 },
|
|
238
|
+
uChroma: { value: 1 },
|
|
239
|
+
uTint: { value: new Color(1, 1, 1) },
|
|
240
|
+
uTintAmt: { value: 0.08 },
|
|
241
|
+
uMerge: { value: 18 },
|
|
242
|
+
};
|
|
243
|
+
const material = new ShaderMaterial({ vertexShader: VERT, fragmentShader: FRAG, uniforms: u, depthTest: false, depthWrite: false });
|
|
244
|
+
const geometry = new PlaneGeometry(2, 2);
|
|
245
|
+
scene.add(new Mesh(geometry, material));
|
|
246
|
+
|
|
247
|
+
const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
|
248
|
+
const view = { w: 1, h: 1, imgAspect: 1 };
|
|
249
|
+
// Panel state in CSS px: target (tx, ty), spring position, velocity, press.
|
|
250
|
+
const st = list.map((it) => ({ it, tx: 0, ty: 0, x: 0, y: 0, vx: 0, vy: 0, press: 0, moved: false, placed: false }));
|
|
251
|
+
let raf = 0;
|
|
252
|
+
let last = 0;
|
|
253
|
+
let time = 0;
|
|
254
|
+
let visible = true;
|
|
255
|
+
let drag = null;
|
|
256
|
+
const light = { x: -0.6, y: 0.8, on: false };
|
|
257
|
+
|
|
258
|
+
const scaleFor = () => Math.min(1, (view.w - 24) / 360);
|
|
259
|
+
const place = () => {
|
|
260
|
+
const s = scaleFor();
|
|
261
|
+
st.forEach((p) => {
|
|
262
|
+
if (p.moved && p.placed) return;
|
|
263
|
+
p.tx = view.w * p.it.x + (p.it.offset ?? 0) * s;
|
|
264
|
+
p.ty = view.h * p.it.y;
|
|
265
|
+
if (!p.placed) {
|
|
266
|
+
p.x = p.tx;
|
|
267
|
+
p.y = p.ty;
|
|
268
|
+
p.placed = true;
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
};
|
|
272
|
+
const cover = () => {
|
|
273
|
+
const a = view.w / view.h;
|
|
274
|
+
const ia = view.imgAspect;
|
|
275
|
+
u.uCover.value.set(ia > a ? a / ia : 1, ia > a ? 1 : ia / a);
|
|
276
|
+
};
|
|
277
|
+
if (image) {
|
|
278
|
+
new TextureLoader().setCrossOrigin("anonymous").load(image, (t) => {
|
|
279
|
+
u.uTex.value = t;
|
|
280
|
+
u.uHasTex.value = 1;
|
|
281
|
+
view.imgAspect = t.image.width / t.image.height;
|
|
282
|
+
cover();
|
|
283
|
+
start();
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const fit = () => {
|
|
288
|
+
const { width, height: hh } = el.getBoundingClientRect();
|
|
289
|
+
if (!width || !hh) return;
|
|
290
|
+
view.w = width;
|
|
291
|
+
view.h = hh;
|
|
292
|
+
renderer.setSize(width, hh, false);
|
|
293
|
+
u.uRes.value.set(width * dpr, hh * dpr);
|
|
294
|
+
cover();
|
|
295
|
+
place();
|
|
296
|
+
start();
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const frame = (now) => {
|
|
300
|
+
raf = 0;
|
|
301
|
+
const o = live.current;
|
|
302
|
+
const dt = last ? Math.min(0.05, (now - last) / 1000) : 1 / 60;
|
|
303
|
+
last = now;
|
|
304
|
+
time += dt;
|
|
305
|
+
const s = scaleFor();
|
|
306
|
+
let busy = !u.uHasTex.value && !reduced; // the default wallpaper drifts
|
|
307
|
+
st.forEach((p, i) => {
|
|
308
|
+
// Jelly spring: a little underdamped so panels wobble into place.
|
|
309
|
+
const k = 260;
|
|
310
|
+
const c = 2 * Math.sqrt(k) * 0.62;
|
|
311
|
+
p.vx += (k * (p.tx - p.x) - c * p.vx) * dt;
|
|
312
|
+
p.vy += (k * (p.ty - p.y) - c * p.vy) * dt;
|
|
313
|
+
p.x += p.vx * dt;
|
|
314
|
+
p.y += p.vy * dt;
|
|
315
|
+
p.press += ((drag?.i === i ? 1 : 0) - p.press) * (1 - Math.exp(-dt / 0.08));
|
|
316
|
+
const speed = Math.hypot(p.vx, p.vy);
|
|
317
|
+
if (speed > 0.5 || Math.abs(p.tx - p.x) > 0.2 || Math.abs(p.ty - p.y) > 0.2 || Math.abs(p.press - (drag?.i === i ? 1 : 0)) > 0.01) busy = true;
|
|
318
|
+
// Stretch along the motion, squash across it; grow a touch while held.
|
|
319
|
+
const sx = 1 + Math.min(0.18, Math.abs(p.vx) / 4000) - Math.min(0.1, Math.abs(p.vy) / 6000);
|
|
320
|
+
const sy = 1 + Math.min(0.18, Math.abs(p.vy) / 4000) - Math.min(0.1, Math.abs(p.vx) / 6000);
|
|
321
|
+
const grow = 1 + p.press * 0.06;
|
|
322
|
+
// A width of 1 or less is a share of the box (handy for nav bars).
|
|
323
|
+
const baseW = Math.min(p.it.width <= 1 ? p.it.width * view.w : p.it.width * s, p.it.maxWidth ?? Infinity);
|
|
324
|
+
const hw = (baseW * sx * grow) / 2;
|
|
325
|
+
const hh = (p.it.height * s * sy * grow) / 2;
|
|
326
|
+
u.uBox.value[i].set(p.x * dpr, (view.h - p.y) * dpr, hw * dpr, hh * dpr);
|
|
327
|
+
u.uRad.value[i] = Math.min(p.it.radius * s * grow, hw, hh) * dpr;
|
|
328
|
+
const node = panels.current[i];
|
|
329
|
+
if (node) {
|
|
330
|
+
node.style.width = `${hw * 2}px`;
|
|
331
|
+
node.style.height = `${hh * 2}px`;
|
|
332
|
+
node.style.transform = `translate3d(${(p.x - hw).toFixed(2)}px, ${(p.y - hh).toFixed(2)}px, 0)`;
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
u.uCount.value = st.length;
|
|
336
|
+
// Ease the light toward the pointer direction from the centre.
|
|
337
|
+
const lv = u.uLight.value;
|
|
338
|
+
const lx = light.on ? light.x : -0.6;
|
|
339
|
+
const ly = light.on ? light.y : 0.8;
|
|
340
|
+
lv.set(lv.x + (lx - lv.x) * (1 - Math.exp(-dt / 0.25)), lv.y + (ly - lv.y) * (1 - Math.exp(-dt / 0.25)));
|
|
341
|
+
if (Math.abs(lx - lv.x) + Math.abs(ly - lv.y) > 0.002) busy = true;
|
|
342
|
+
u.uVibrancy.value = o.vibrancy;
|
|
343
|
+
u.uTime.value = time;
|
|
344
|
+
u.uRefract.value = o.refraction;
|
|
345
|
+
u.uFrost.value = o.frost;
|
|
346
|
+
u.uChroma.value = o.chroma;
|
|
347
|
+
u.uTint.value.set(o.tint).convertLinearToSRGB();
|
|
348
|
+
u.uTintAmt.value = o.tintAmount;
|
|
349
|
+
u.uMerge.value = o.merge * dpr;
|
|
350
|
+
renderer.render(scene, camera);
|
|
351
|
+
if (visible && busy) raf = requestAnimationFrame(frame);
|
|
352
|
+
else last = 0;
|
|
353
|
+
};
|
|
354
|
+
function start() {
|
|
355
|
+
if (!raf && visible) raf = requestAnimationFrame(frame);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const local = (e) => {
|
|
359
|
+
const r = el.getBoundingClientRect();
|
|
360
|
+
return [e.clientX - r.left, e.clientY - r.top];
|
|
361
|
+
};
|
|
362
|
+
const onDown = (e) => {
|
|
363
|
+
if (!live.current.draggable) return;
|
|
364
|
+
const node = e.target.closest("[data-glass]");
|
|
365
|
+
if (!node) return;
|
|
366
|
+
e.preventDefault();
|
|
367
|
+
const i = Number(node.dataset.glass);
|
|
368
|
+
const [x, y] = local(e);
|
|
369
|
+
drag = { i, dx: st[i].tx - x, dy: st[i].ty - y };
|
|
370
|
+
el.setPointerCapture(e.pointerId);
|
|
371
|
+
start();
|
|
372
|
+
};
|
|
373
|
+
const onMove = (e) => {
|
|
374
|
+
const [x, y] = local(e);
|
|
375
|
+
// The light comes from where the pointer is, relative to the centre.
|
|
376
|
+
const lx = x - view.w / 2;
|
|
377
|
+
const ly = view.h / 2 - y;
|
|
378
|
+
const len = Math.hypot(lx, ly) || 1;
|
|
379
|
+
light.x = lx / len;
|
|
380
|
+
light.y = ly / len;
|
|
381
|
+
light.on = true;
|
|
382
|
+
start();
|
|
383
|
+
if (!drag) return;
|
|
384
|
+
const p = st[drag.i];
|
|
385
|
+
p.tx = Math.max(0, Math.min(view.w, x + drag.dx));
|
|
386
|
+
p.ty = Math.max(0, Math.min(view.h, y + drag.dy));
|
|
387
|
+
p.moved = true;
|
|
388
|
+
start();
|
|
389
|
+
};
|
|
390
|
+
const onUp = () => {
|
|
391
|
+
drag = null;
|
|
392
|
+
start();
|
|
393
|
+
};
|
|
394
|
+
el.addEventListener("pointerdown", onDown);
|
|
395
|
+
el.addEventListener("pointermove", onMove);
|
|
396
|
+
el.addEventListener("pointerup", onUp);
|
|
397
|
+
el.addEventListener("pointercancel", onUp);
|
|
398
|
+
const ro = new ResizeObserver(fit);
|
|
399
|
+
ro.observe(el);
|
|
400
|
+
const io = new IntersectionObserver(([entry]) => {
|
|
401
|
+
visible = entry.isIntersecting;
|
|
402
|
+
if (visible) start();
|
|
403
|
+
});
|
|
404
|
+
io.observe(el);
|
|
405
|
+
fit();
|
|
406
|
+
|
|
407
|
+
return () => {
|
|
408
|
+
cancelAnimationFrame(raf);
|
|
409
|
+
ro.disconnect();
|
|
410
|
+
io.disconnect();
|
|
411
|
+
el.removeEventListener("pointerdown", onDown);
|
|
412
|
+
el.removeEventListener("pointermove", onMove);
|
|
413
|
+
el.removeEventListener("pointerup", onUp);
|
|
414
|
+
el.removeEventListener("pointercancel", onUp);
|
|
415
|
+
u.uTex.value?.dispose();
|
|
416
|
+
geometry.dispose();
|
|
417
|
+
material.dispose();
|
|
418
|
+
renderer.dispose();
|
|
419
|
+
renderer.forceContextLoss();
|
|
420
|
+
renderer.domElement.remove();
|
|
421
|
+
};
|
|
422
|
+
// Rebuild when the panels or the wallpaper change; the look is live.
|
|
423
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
424
|
+
}, [image, list.length]);
|
|
425
|
+
|
|
426
|
+
return (
|
|
427
|
+
<div ref={wrap} className={`relative w-full select-none overflow-hidden bg-[#0f1220] ${draggable ? "touch-none" : ""} ${className}`} style={{ height }}>
|
|
428
|
+
{children && <div className="pointer-events-none absolute inset-0 z-[5] [&_a]:pointer-events-auto [&_button]:pointer-events-auto">{children}</div>}
|
|
429
|
+
{list.map((it, i) => (
|
|
430
|
+
<div
|
|
431
|
+
key={it.id ?? i}
|
|
432
|
+
ref={(e) => (panels.current[i] = e)}
|
|
433
|
+
data-glass={i}
|
|
434
|
+
className={`absolute left-0 top-0 z-10 overflow-hidden [text-shadow:0_1px_2px_rgba(0,0,0,0.25)] ${draggable ? "cursor-grab active:cursor-grabbing" : ""}`}
|
|
435
|
+
style={{ borderRadius: it.radius }}
|
|
436
|
+
>
|
|
437
|
+
{it.content}
|
|
438
|
+
</div>
|
|
439
|
+
))}
|
|
440
|
+
</div>
|
|
441
|
+
);
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
export default LiquidGlass;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { Cloud, Code2, Cpu, Database, Figma, Github, Globe, Layers, Lock, Palette, Rocket, Sparkles, Zap } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* Orbit Icons: icons circling a center on a few rings, each ring at its own
|
|
6
|
+
* speed and direction. Tip the orbits back for depth: icons on the far side
|
|
7
|
+
* shrink, dim and pass behind the near ones. Hovering eases the motion to a
|
|
8
|
+
* stop (and shows labels); the orbits lean a little toward the pointer. It
|
|
9
|
+
* scales to its container, so it fits phones.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const DEFAULT_RINGS = [
|
|
13
|
+
{ speed: 22, items: [{ icon: Code2, label: "Code" }, { icon: Zap, label: "Fast" }, { icon: Palette, label: "Design" }] },
|
|
14
|
+
{ speed: -14, items: [{ icon: Github, label: "GitHub" }, { icon: Figma, label: "Figma" }, { icon: Sparkles, label: "AI" }, { icon: Cpu, label: "Compute" }] },
|
|
15
|
+
{ speed: 9, items: [{ icon: Globe, label: "Web" }, { icon: Database, label: "Data" }, { icon: Cloud, label: "Cloud" }, { icon: Lock, label: "Auth" }, { icon: Rocket, label: "Deploy" }, { icon: Layers, label: "Stack" }] },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const OrbitIcons = ({ rings = DEFAULT_RINGS, size = 28, tilt = 0.55, speed = 1, pauseOnHover = true, showRings = true, accentColor = "#a3e635", center, className = "" }) => {
|
|
19
|
+
const box = useRef(null);
|
|
20
|
+
const nodes = useRef([]);
|
|
21
|
+
const ringEls = useRef([]);
|
|
22
|
+
const [hover, setHover] = useState(false);
|
|
23
|
+
const live = useRef({});
|
|
24
|
+
live.current = { tilt, speed, pauseOnHover, rings };
|
|
25
|
+
const flat = rings.flatMap((r, ri) => r.items.map((it, k) => ({ ...it, ri, k, n: r.items.length })));
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
const el = box.current;
|
|
29
|
+
const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
|
30
|
+
const s = { angles: live.current.rings.map((_, i) => i * 0.7), rate: 1, half: 100, lean: 0, leanX: 0, tx: 0, ty: 0, hover: false };
|
|
31
|
+
let raf = 0;
|
|
32
|
+
let last = 0;
|
|
33
|
+
let visible = true;
|
|
34
|
+
|
|
35
|
+
const fit = () => {
|
|
36
|
+
s.half = el.getBoundingClientRect().width / 2;
|
|
37
|
+
el.style.setProperty("--orbit-item", `${Math.max(30, Math.min(52, s.half * 0.21)).toFixed(1)}px`);
|
|
38
|
+
};
|
|
39
|
+
const frame = (now) => {
|
|
40
|
+
raf = 0;
|
|
41
|
+
const o = live.current;
|
|
42
|
+
const dt = last ? Math.min(0.05, (now - last) / 1000) : 1 / 60;
|
|
43
|
+
last = now;
|
|
44
|
+
// Ease the spin down on hover rather than stopping dead.
|
|
45
|
+
const goal = (o.pauseOnHover && s.hover) || reduced ? 0 : 1;
|
|
46
|
+
s.rate += (goal - s.rate) * (1 - Math.exp(-dt / 0.35));
|
|
47
|
+
s.lean += (s.ty - s.lean) * (1 - Math.exp(-dt / 0.4));
|
|
48
|
+
s.leanX += (s.tx - s.leanX) * (1 - Math.exp(-dt / 0.4));
|
|
49
|
+
const tiltA = Math.max(0, Math.min(1.2, o.tilt * 1.15 + s.lean * 0.18));
|
|
50
|
+
const squash = Math.cos(tiltA);
|
|
51
|
+
const depthAmt = Math.sin(tiltA);
|
|
52
|
+
o.rings.forEach((ring, ri) => {
|
|
53
|
+
s.angles[ri] = (s.angles[ri] ?? 0) + ((ring.speed * Math.PI) / 180) * o.speed * s.rate * dt;
|
|
54
|
+
});
|
|
55
|
+
const radii = o.rings.map((_, ri) => s.half * (0.34 + (0.6 * (ri + 1)) / o.rings.length) * 0.94);
|
|
56
|
+
ringEls.current.forEach((e, ri) => {
|
|
57
|
+
if (!e) return;
|
|
58
|
+
const r = radii[ri];
|
|
59
|
+
e.style.width = `${r * 2}px`;
|
|
60
|
+
e.style.height = `${r * 2 * squash}px`;
|
|
61
|
+
e.style.transform = `translate(-50%, -50%) rotate(${(s.leanX * 6).toFixed(2)}deg)`;
|
|
62
|
+
});
|
|
63
|
+
nodes.current.forEach((e, idx) => {
|
|
64
|
+
const it = flat[idx];
|
|
65
|
+
if (!e || !it) return;
|
|
66
|
+
const r = radii[it.ri];
|
|
67
|
+
const a = s.angles[it.ri] + (it.k / it.n) * Math.PI * 2;
|
|
68
|
+
const z = Math.sin(a); // +1 is the near side (bottom)
|
|
69
|
+
const lean = (s.leanX * 6 * Math.PI) / 180;
|
|
70
|
+
let x = Math.cos(a) * r;
|
|
71
|
+
let y = Math.sin(a) * r * squash;
|
|
72
|
+
[x, y] = [x * Math.cos(lean) - y * Math.sin(lean), x * Math.sin(lean) + y * Math.cos(lean)];
|
|
73
|
+
const sc = 1 + z * 0.16 * depthAmt;
|
|
74
|
+
e.style.transform = `translate(-50%, -50%) translate3d(${x.toFixed(2)}px, ${y.toFixed(2)}px, 0) scale(${sc.toFixed(3)})`;
|
|
75
|
+
e.style.zIndex = String(Math.round(50 + z * 40));
|
|
76
|
+
e.style.opacity = String((1 - (1 - z) * 0.22 * depthAmt).toFixed(3));
|
|
77
|
+
});
|
|
78
|
+
const busy = s.rate > 0.001 || Math.abs(s.ty - s.lean) > 0.001 || Math.abs(s.tx - s.leanX) > 0.001;
|
|
79
|
+
if (visible && busy) raf = requestAnimationFrame(frame);
|
|
80
|
+
else last = 0;
|
|
81
|
+
};
|
|
82
|
+
const start = () => {
|
|
83
|
+
if (!raf && visible) raf = requestAnimationFrame(frame);
|
|
84
|
+
};
|
|
85
|
+
const onMove = (e) => {
|
|
86
|
+
const r = el.getBoundingClientRect();
|
|
87
|
+
s.tx = ((e.clientX - r.left) / r.width) * 2 - 1;
|
|
88
|
+
s.ty = ((e.clientY - r.top) / r.height) * 2 - 1;
|
|
89
|
+
start();
|
|
90
|
+
};
|
|
91
|
+
const onEnter = (e) => {
|
|
92
|
+
if (e.pointerType === "mouse") {
|
|
93
|
+
s.hover = true;
|
|
94
|
+
setHover(true);
|
|
95
|
+
}
|
|
96
|
+
start();
|
|
97
|
+
};
|
|
98
|
+
const onLeave = () => {
|
|
99
|
+
s.hover = false;
|
|
100
|
+
s.tx = 0;
|
|
101
|
+
s.ty = 0;
|
|
102
|
+
setHover(false);
|
|
103
|
+
start();
|
|
104
|
+
};
|
|
105
|
+
el.addEventListener("pointermove", onMove);
|
|
106
|
+
el.addEventListener("pointerenter", onEnter);
|
|
107
|
+
el.addEventListener("pointerleave", onLeave);
|
|
108
|
+
const ro = new ResizeObserver(() => {
|
|
109
|
+
fit();
|
|
110
|
+
start();
|
|
111
|
+
});
|
|
112
|
+
ro.observe(el);
|
|
113
|
+
const io = new IntersectionObserver(([en]) => {
|
|
114
|
+
visible = en.isIntersecting;
|
|
115
|
+
if (visible) start();
|
|
116
|
+
});
|
|
117
|
+
io.observe(el);
|
|
118
|
+
fit();
|
|
119
|
+
start();
|
|
120
|
+
return () => {
|
|
121
|
+
cancelAnimationFrame(raf);
|
|
122
|
+
ro.disconnect();
|
|
123
|
+
io.disconnect();
|
|
124
|
+
el.removeEventListener("pointermove", onMove);
|
|
125
|
+
el.removeEventListener("pointerenter", onEnter);
|
|
126
|
+
el.removeEventListener("pointerleave", onLeave);
|
|
127
|
+
};
|
|
128
|
+
// flat is derived from rings; the loop reads both through refs and live.
|
|
129
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
130
|
+
}, [flat.length]);
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<div ref={box} className={`relative aspect-square w-full select-none ${className}`} style={{ maxWidth: `${size}rem`, "--orbit-accent": accentColor }}>
|
|
134
|
+
{showRings &&
|
|
135
|
+
rings.map((_, ri) => (
|
|
136
|
+
<span
|
|
137
|
+
key={ri}
|
|
138
|
+
ref={(e) => (ringEls.current[ri] = e)}
|
|
139
|
+
aria-hidden="true"
|
|
140
|
+
className="pointer-events-none absolute left-1/2 top-1/2 rounded-[50%] border border-dashed border-current opacity-15"
|
|
141
|
+
/>
|
|
142
|
+
))}
|
|
143
|
+
<div className="absolute left-1/2 top-1/2 z-[50] grid aspect-square w-[22%] -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full border border-white/10 bg-neutral-950 text-white shadow-[0_0_60px_-10px_var(--orbit-accent)]">
|
|
144
|
+
{center ?? <span className="text-base font-black tracking-tight sm:text-xl" style={{ color: accentColor }}>Apex</span>}
|
|
145
|
+
</div>
|
|
146
|
+
{flat.map((it, idx) => {
|
|
147
|
+
const Icon = it.icon;
|
|
148
|
+
return (
|
|
149
|
+
<div key={idx} ref={(e) => (nodes.current[idx] = e)} className="group absolute left-1/2 top-1/2 will-change-transform">
|
|
150
|
+
<div
|
|
151
|
+
className="grid place-items-center rounded-2xl border border-white/10 bg-neutral-900/90 text-white shadow-[0_10px_30px_-12px_rgba(0,0,0,0.7)] backdrop-blur transition-colors duration-300 group-hover:border-[var(--orbit-accent)]"
|
|
152
|
+
style={{ width: "var(--orbit-item, 2.75rem)", aspectRatio: "1" }}
|
|
153
|
+
title={it.label}
|
|
154
|
+
>
|
|
155
|
+
{typeof Icon === "function" || (Icon && typeof Icon === "object" && Icon.$$typeof) ? <Icon className="h-[46%] w-[46%]" style={{ color: it.color }} /> : Icon}
|
|
156
|
+
</div>
|
|
157
|
+
{it.label && (
|
|
158
|
+
<span className="pointer-events-none absolute left-1/2 top-full mt-1.5 -translate-x-1/2 whitespace-nowrap rounded-full bg-black/70 px-2 py-0.5 text-[0.65rem] font-semibold text-white transition-opacity duration-300" style={{ opacity: hover ? 1 : 0 }}>
|
|
159
|
+
{it.label}
|
|
160
|
+
</span>
|
|
161
|
+
)}
|
|
162
|
+
</div>
|
|
163
|
+
);
|
|
164
|
+
})}
|
|
165
|
+
</div>
|
|
166
|
+
);
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
export default OrbitIcons;
|
|
@@ -6,7 +6,8 @@ import { Camera, Heart, Home, MessageCircle, Plus, Settings, Share2, User } from
|
|
|
6
6
|
* Radial Menu: a round button that fans its actions out in a circle (or an
|
|
7
7
|
* arc). Items spring out one after another and tuck back in on close.
|
|
8
8
|
* Keyboard friendly: arrows move between items, Escape closes, and clicking
|
|
9
|
-
* outside closes too.
|
|
9
|
+
* outside closes too. The ring shrinks to fit narrow containers, and the
|
|
10
|
+
* fade-in is a CSS animation so no item blinks when its spring settles.
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
13
|
const DEFAULT_ITEMS = [
|
|
@@ -25,11 +26,24 @@ const RadialMenu = ({ items = DEFAULT_ITEMS, radius = 6.5, arc = 360, startAngle
|
|
|
25
26
|
const [picked, setPicked] = useState(null);
|
|
26
27
|
const root = useRef(null);
|
|
27
28
|
const btns = useRef([]);
|
|
29
|
+
const [fitR, setFitR] = useState(null);
|
|
28
30
|
const dark = theme !== "light";
|
|
29
31
|
const n = items.length;
|
|
30
32
|
// Full circle spreads evenly; a partial arc includes both ends.
|
|
31
33
|
const step = arc >= 360 ? 360 / n : arc / Math.max(1, n - 1);
|
|
32
34
|
|
|
35
|
+
// Keep the ring inside the box on small screens (item half size + a little room).
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const el = root.current;
|
|
38
|
+
const ro = new ResizeObserver(([entry]) => {
|
|
39
|
+
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
|
40
|
+
const room = entry.contentRect.width / 2 - 30;
|
|
41
|
+
setFitR(Math.max(48, Math.min(radius * rem, room)));
|
|
42
|
+
});
|
|
43
|
+
ro.observe(el);
|
|
44
|
+
return () => ro.disconnect();
|
|
45
|
+
}, [radius]);
|
|
46
|
+
|
|
33
47
|
useEffect(() => {
|
|
34
48
|
if (!open) return undefined;
|
|
35
49
|
const down = (e) => !root.current?.contains(e.target) && setOpen(false);
|
|
@@ -56,11 +70,13 @@ const RadialMenu = ({ items = DEFAULT_ITEMS, radius = 6.5, arc = 360, startAngle
|
|
|
56
70
|
|
|
57
71
|
return (
|
|
58
72
|
<div ref={root} className={`relative inline-grid place-items-center ${className}`} style={{ width: `${radius * 2 + 4}rem`, height: `${radius * 2 + 4}rem`, maxWidth: "100%" }}>
|
|
73
|
+
<style>{"@keyframes apex-radial-in{from{opacity:0}to{opacity:1}}"}</style>
|
|
59
74
|
<AnimatePresence>
|
|
60
75
|
{open &&
|
|
61
76
|
items.map((item, i) => {
|
|
62
77
|
const a = ((startAngle + i * step) * Math.PI) / 180;
|
|
63
78
|
const Icon = item.icon;
|
|
79
|
+
const r = fitR ?? radius * 16;
|
|
64
80
|
return (
|
|
65
81
|
<motion.button
|
|
66
82
|
key={`${id}-${i}`}
|
|
@@ -71,10 +87,10 @@ const RadialMenu = ({ items = DEFAULT_ITEMS, radius = 6.5, arc = 360, startAngle
|
|
|
71
87
|
onClick={() => choose(item, i)}
|
|
72
88
|
onKeyDown={(e) => onItemKey(e, i)}
|
|
73
89
|
className={`group absolute grid h-12 w-12 place-items-center rounded-full shadow-lg outline-none ring-1 focus-visible:ring-2 ${dark ? "bg-neutral-900 text-white ring-white/10 hover:bg-neutral-800 focus-visible:ring-white/50" : "bg-white text-neutral-800 ring-black/10 hover:bg-neutral-50 focus-visible:ring-black/40"}`}
|
|
74
|
-
initial={{ x: 0, y: 0, scale: 0.3
|
|
75
|
-
animate={{ x:
|
|
90
|
+
initial={{ x: 0, y: 0, scale: 0.3 }}
|
|
91
|
+
animate={{ x: Math.cos(a) * r, y: Math.sin(a) * r, scale: 1, transition: { type: "spring", stiffness: 380, damping: 26, delay: i * 0.03 } }}
|
|
76
92
|
exit={{ x: 0, y: 0, scale: 0.3, opacity: 0, transition: { duration: 0.18, delay: (n - 1 - i) * 0.015 } }}
|
|
77
|
-
style={picked === i ? { color: accentColor } :
|
|
93
|
+
style={{ animation: `apex-radial-in 220ms ease-out ${i * 30}ms backwards`, ...(picked === i ? { color: accentColor } : null) }}
|
|
78
94
|
>
|
|
79
95
|
{Icon ? <Icon className="h-5 w-5" /> : item.label[0]}
|
|
80
96
|
<span className={`pointer-events-none absolute top-full mt-1.5 whitespace-nowrap rounded-md px-1.5 py-0.5 text-[0.65rem] font-medium opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100 ${dark ? "bg-white text-black" : "bg-neutral-900 text-white"}`}>{item.label}</span>
|