apex-ui-kit 2.8.0 → 2.10.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 CHANGED
@@ -4,7 +4,7 @@
4
4
  [![npm downloads](https://img.shields.io/npm/dm/apex-ui-kit?color=a3e635)](https://www.npmjs.com/package/apex-ui-kit)
5
5
  [![license](https://img.shields.io/npm/l/apex-ui-kit?color=a3e635)](./Licence.md)
6
6
 
7
- **115 free, animated UI components for React.** Pick one, add it with a single command, and the source lands in your project so you can read it and change anything.
7
+ **118 free, animated UI components for React.** Pick one, add it with a single command, and the source lands in your project so you can read it and change anything.
8
8
 
9
9
  Built with React, Tailwind CSS and Framer Motion, plus GSAP, Canvas and WebGL where a component needs them.
10
10
 
@@ -15,7 +15,7 @@ Built with React, Tailwind CSS and Framer Motion, plus GSAP, Canvas and WebGL wh
15
15
 
16
16
  ## Why ApexUI
17
17
 
18
- - **115 components** across backgrounds, cards, text effects, navigation, buttons, interactive UI, AI interfaces, loaders and maps.
18
+ - **118 components** across backgrounds, cards, text effects, navigation, buttons, interactive UI, AI interfaces, loaders and maps.
19
19
  - **You own the code.** The CLI copies plain `.jsx` files into your project, no runtime package to keep in sync.
20
20
  - **Every docs page has** a live preview, a playground to tweak props, a Desktop / Tablet / Mobile preview, the full props table and a ready prompt you can paste into ChatGPT or Claude.
21
21
  - **Smooth on real devices.** Animations stick to transform and opacity, respect reduced motion, and work with mouse, touch and keyboard.
@@ -196,7 +196,7 @@ Every component's props, variants and copy-ready code are on its docs page.
196
196
  | [Magnetic Button](https://apex-ui.in/components/magnetic-button) | `magnetic-button` |
197
197
  | [Star Border](https://apex-ui.in/components/star-border) | `star-border` |
198
198
 
199
- ### Interactive (30)
199
+ ### Interactive (33)
200
200
 
201
201
  | Component | CLI name |
202
202
  | --- | --- |
@@ -215,10 +215,12 @@ Every component's props, variants and copy-ready code are on its docs page.
215
215
  | [Elastic Slider](https://apex-ui.in/components/elastic-slider) | `elastic-slider` |
216
216
  | [FAQ Accordion](https://apex-ui.in/components/faq-accordion) | `faq-accordion` |
217
217
  | [Flip Book](https://apex-ui.in/components/flip-book) | `flip-book` |
218
+ | [Fluid Cursor](https://apex-ui.in/components/fluid-cursor) | `fluid-cursor` |
218
219
  | [Glass Lens](https://apex-ui.in/components/glass-lens) | `glass-lens` |
219
220
  | [Image Compare](https://apex-ui.in/components/image-compare) | `image-compare` |
220
221
  | [Infinite Canvas](https://apex-ui.in/components/infinite-canvas) | `infinite-canvas` |
221
222
  | [Liquid Blob 3D](https://apex-ui.in/components/liquid-blob-3d) | `liquid-blob-3d` |
223
+ | [Liquid Glass](https://apex-ui.in/components/liquid-glass) | `liquid-glass` |
222
224
  | [Magnet Lines](https://apex-ui.in/components/magnet-lines) | `magnet-lines` |
223
225
  | [Magnetic Cursor](https://apex-ui.in/components/magnetic-cursor) | `magnetic-cursor` |
224
226
  | [Marquee 3D](https://apex-ui.in/components/marquee-3d) | `marquee-3d` |
@@ -226,6 +228,7 @@ Every component's props, variants and copy-ready code are on its docs page.
226
228
  | [Particle Morph](https://apex-ui.in/components/particle-morph) | `particle-morph` |
227
229
  | [Pointer Follower](https://apex-ui.in/components/pointer-follower) | `pointer-follower` |
228
230
  | [Receipt Printer](https://apex-ui.in/components/receipt-printer) | `receipt-printer` |
231
+ | [Sketch Reveal](https://apex-ui.in/components/sketch-reveal) | `sketch-reveal` |
229
232
  | [Stepper](https://apex-ui.in/components/stepper) | `stepper` |
230
233
  | [Toast Stack](https://apex-ui.in/components/toast-stack) | `toast-stack` |
231
234
  | [ToolTip](https://apex-ui.in/components/tool-tip) | `tooltip` |
@@ -259,8 +262,10 @@ Every component's props, variants and copy-ready code are on its docs page.
259
262
  | [Dot Globe](https://apex-ui.in/components/dot-globe) | `dot-globe` |
260
263
  | [World Map](https://apex-ui.in/components/world-map) | `world-map` |
261
264
 
262
- ## What's new in 2.8
265
+ ## What's new in 2.10
263
266
 
267
+ - New in 2.10: Fluid Cursor (a real-time WebGL fluid sim that trails the cursor) and Sketch Reveal (a photo turns into a glowing sketch and burns away into the next one as you scroll).
268
+ - New in 2.9: Liquid Glass (iOS style WebGL glass), 10 live page templates on the site, Arc Gallery auto scroll, Scroll Stack on the page scroll, Sunrise trees and birds.
264
269
  - New in 2.8: Glass Lens (WebGL liquid glass), Dither Waves (WebGL 1-bit background), Infinite Canvas, Image Accordion, Orbit Icons, Command Palette, Crossed Marquee (tech-stack bands with simple-icons) and Theme Switcher (8 themes with a circle reveal).
265
270
  - Toast Stack and Radial Menu no longer blink in production builds; Radial Menu, 3D Text Ring, Animated Tabs and Gooey Nav now fit 320px phones.
266
271
  - 38 new components since 2.6, including WebGL and three.js (Sunrise Background, Horizon Glow, Circular Carousel, Arc Gallery, Galaxy 3D, Warp Tunnel, Wireframe Knot 3D, Marquee 3D, Distortion Image, Liquid Blob 3D, Particle Morph, Retro Grid 3D), Scroll Stack, Particle Text, Edge Blur, Magnetic Button, Radial Menu, the AI voice family (Wave, Bars, Ring, Blob), Gooey Nav (WebGL metaball pill), Coverflow 3D, Card Swap 3D, 3D Text Ring, Elastic Slider, Stepper and Pixel Transition.
@@ -3,9 +3,10 @@ import { Mesh, OrthographicCamera, PlaneGeometry, Scene, ShaderMaterial, SRGBCol
3
3
 
4
4
  /*
5
5
  * Arc Gallery: an endless row of image cards laid along a gentle arc and
6
- * drawn in WebGL. Drag, flick or scroll the wheel to move it; it eases to a
7
- * stop and settles on a card. While it moves the cards ripple slightly,
8
- * the edges fade out, and a title sits under each card. Arrow keys step.
6
+ * drawn in WebGL. It drifts on its own (pausing on hover); drag, flick or
7
+ * swipe sideways to move it and it eases to a stop on a card. While it
8
+ * moves the cards ripple and bow, the edges fade out, and a title sits under
9
+ * each card. Arrow keys step. Vertical wheel scrolling is left to the page.
9
10
  */
10
11
 
11
12
  const IK = "https://ik.imagekit.io/ny6yinyut/apexui/assets";
@@ -54,13 +55,13 @@ void main(){
54
55
  gl_FragColor = vec4(col, edge * f * uReady);
55
56
  }`;
56
57
 
57
- const ArcGallery = ({ items = DEFAULT_ITEMS, bend = 1, cardHeight = 0.62, aspectRatio = 0.78, gap = 0.22, cornerRadius = 0.05, wave = 1, pull = 1, fadeEdges = 0.14, ease = 0.08, snap = true, showTitles = true, height = "26rem", onChange, className = "" }) => {
58
+ const ArcGallery = ({ items = DEFAULT_ITEMS, bend = 1, cardHeight = 0.62, aspectRatio = 0.78, gap = 0.22, cornerRadius = 0.05, wave = 1, pull = 1, fadeEdges = 0.14, ease = 0.08, snap = true, autoScroll = 40, pauseOnHover = true, showTitles = true, height = "26rem", onChange, className = "" }) => {
58
59
  const wrap = useRef(null);
59
60
  const labels = useRef([]);
60
61
  const api = useRef(null);
61
62
  const [active, setActive] = useState(0);
62
63
  const live = useRef({});
63
- live.current = { bend, wave, pull, fadeEdges, ease, snap, onChange };
64
+ live.current = { bend, wave, pull, fadeEdges, ease, snap, autoScroll, pauseOnHover, onChange };
64
65
  const list = items.length ? items : DEFAULT_ITEMS;
65
66
  const key = list.map((i) => i.image).join("|");
66
67
 
@@ -84,7 +85,6 @@ const ArcGallery = ({ items = DEFAULT_ITEMS, bend = 1, cardHeight = 0.62, aspect
84
85
  const textures = list.map((item) => {
85
86
  const entry = { tex: null, aspect: 1, at: 0 };
86
87
  loader.load(item.image, (t) => {
87
- t.colorSpace = SRGBColorSpace;
88
88
  entry.tex = t;
89
89
  entry.aspect = t.image.width / t.image.height;
90
90
  entry.at = performance.now();
@@ -116,7 +116,8 @@ const ArcGallery = ({ items = DEFAULT_ITEMS, bend = 1, cardHeight = 0.62, aspect
116
116
  return { mesh, m };
117
117
  };
118
118
 
119
- const s = { scroll: 0, target: 0, vel: 0, pull: 0, drag: null, time: 0 };
119
+ const s = { scroll: 0, target: 0, vel: 0, pull: 0, drag: null, time: 0, hover: false, hold: 0 };
120
+ const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
120
121
  let cardW = 1;
121
122
  let cardH = 1;
122
123
  let spacing = 1;
@@ -172,6 +173,10 @@ const ArcGallery = ({ items = DEFAULT_ITEMS, bend = 1, cardHeight = 0.62, aspect
172
173
  const o = live.current;
173
174
  const dt = last ? Math.min(0.05, (now - last) / 1000) : 1 / 60;
174
175
  last = now;
176
+ // Auto scroll drifts the row along, pausing while hovered, dragged or
177
+ // just after someone moved it by hand.
178
+ const auto = o.autoScroll && !reduced && !s.drag && !(o.pauseOnHover && s.hover) && now > s.hold;
179
+ if (auto) s.target += o.autoScroll * dt;
175
180
  const before = s.scroll;
176
181
  const follow = 1 - Math.pow(1 - Math.min(1, Math.max(0.01, o.ease)), dt * 60);
177
182
  s.scroll += (s.target - s.scroll) * follow;
@@ -185,7 +190,7 @@ const ArcGallery = ({ items = DEFAULT_ITEMS, bend = 1, cardHeight = 0.62, aspect
185
190
  const pullGoal = Math.max(-1, Math.min(1, s.vel / (spacing * 7))) * 0.32 * o.pull;
186
191
  s.pull += (pullGoal - s.pull) * (1 - Math.exp(-dt / 0.12));
187
192
  if (Math.abs(s.pull) < 0.0005 && !pullGoal) s.pull = 0;
188
- let busy = s.pull !== 0 || s.scroll !== s.target || s.vel !== 0 || !!s.drag;
193
+ let busy = auto || s.pull !== 0 || s.scroll !== s.target || s.vel !== 0 || !!s.drag;
189
194
 
190
195
  const first = Math.floor((s.scroll - view.w / 2) / spacing) - 1;
191
196
  slots.forEach((slot, k) => {
@@ -251,6 +256,7 @@ const ArcGallery = ({ items = DEFAULT_ITEMS, bend = 1, cardHeight = 0.62, aspect
251
256
  const onDown = (e) => {
252
257
  if (e.button !== 0) return;
253
258
  s.drag = { x: e.clientX, start: s.target, moved: false, samples: [] };
259
+ wake();
254
260
  };
255
261
  const onMove = (e) => {
256
262
  const g = s.drag;
@@ -269,6 +275,7 @@ const ArcGallery = ({ items = DEFAULT_ITEMS, bend = 1, cardHeight = 0.62, aspect
269
275
  const onUp = (e) => {
270
276
  const g = s.drag;
271
277
  s.drag = null;
278
+ s.hold = performance.now() + 1800;
272
279
  if (!g) return;
273
280
  if (g.moved && g.samples.length > 1) {
274
281
  const a = g.samples[0];
@@ -286,10 +293,12 @@ const ArcGallery = ({ items = DEFAULT_ITEMS, bend = 1, cardHeight = 0.62, aspect
286
293
  settle();
287
294
  };
288
295
  const onWheel = (e) => {
289
- const d = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
290
- if (!d) return;
296
+ // Only sideways wheel / trackpad swipes move the row; vertical
297
+ // scrolling belongs to the page.
298
+ if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return;
291
299
  e.preventDefault();
292
- s.target += d * 1.2;
300
+ s.target += e.deltaX * 1.2;
301
+ s.hold = performance.now() + 1800;
293
302
  clearTimeout(snapTimer);
294
303
  snapTimer = setTimeout(settle, 140);
295
304
  wake();
@@ -299,6 +308,13 @@ const ArcGallery = ({ items = DEFAULT_ITEMS, bend = 1, cardHeight = 0.62, aspect
299
308
  canvas.addEventListener("pointerup", onUp);
300
309
  canvas.addEventListener("pointercancel", onUp);
301
310
  canvas.addEventListener("wheel", onWheel, { passive: false });
311
+ const onEnter = () => (s.hover = true);
312
+ const onLeave = () => {
313
+ s.hover = false;
314
+ wake();
315
+ };
316
+ canvas.addEventListener("pointerenter", onEnter);
317
+ canvas.addEventListener("pointerleave", onLeave);
302
318
  const io = new IntersectionObserver(([entry]) => {
303
319
  visible = entry.isIntersecting;
304
320
  wake();
@@ -316,6 +332,8 @@ const ArcGallery = ({ items = DEFAULT_ITEMS, bend = 1, cardHeight = 0.62, aspect
316
332
  canvas.removeEventListener("pointerup", onUp);
317
333
  canvas.removeEventListener("pointercancel", onUp);
318
334
  canvas.removeEventListener("wheel", onWheel);
335
+ canvas.removeEventListener("pointerenter", onEnter);
336
+ canvas.removeEventListener("pointerleave", onLeave);
319
337
  textures.forEach((t) => t.tex?.dispose());
320
338
  slots.forEach((slot) => slot.m.dispose());
321
339
  geometry.dispose();
@@ -124,7 +124,6 @@ const CircularCarousel = ({
124
124
  },
125
125
  });
126
126
  loader.load(item.src, (t) => {
127
- t.colorSpace = SRGBColorSpace;
128
127
  m.uniforms.uTex.value = t;
129
128
  const ia = t.image.width / t.image.height;
130
129
  // Cover crop: show the middle of the image at the card's shape.
@@ -92,8 +92,9 @@ const FlipClock = ({ mode = "seconds", target, seconds = 3 * 86400 + 4 * 3600 +
92
92
 
93
93
  return (
94
94
  // The clock is sized in em; on narrow screens the font shrinks with the
95
- // container (cqw) so every unit fits instead of being cut off.
96
- <div className="flex w-full justify-center" style={{ containerType: "inline-size" }}>
95
+ // container (cqw) so every unit fits instead of being cut off. The min
96
+ // width keeps it from collapsing inside a shrink-to-fit parent.
97
+ <div className="flex w-full justify-center" style={{ containerType: "inline-size", minWidth: `min(${(size * parts.length * 2.55).toFixed(2)}rem, 92vw)` }}>
97
98
  <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: `min(${size}rem, ${(100 / (parts.length * 2.55)).toFixed(2)}cqw)` }}>
98
99
  {parts.map(([label, value], i) => (
99
100
  <React.Fragment key={label}>
@@ -0,0 +1,418 @@
1
+ import React, { useEffect, useRef } from "react";
2
+ import { Color, HalfFloatType, LinearFilter, Mesh, OrthographicCamera, PlaneGeometry, RGBAFormat, Scene, ShaderMaterial, Vector2, WebGLRenderer, WebGLRenderTarget } from "three";
3
+
4
+ /*
5
+ * Fluid Cursor: a real-time fluid simulation that trails the pointer, drawn
6
+ * in WebGL. Moving the cursor pushes coloured ink through the fluid; it
7
+ * swirls (vorticity), spreads (pressure) and slowly fades. A click blows a
8
+ * burst. It sits over its parent as a transparent layer and never blocks
9
+ * clicks, so content underneath stays usable.
10
+ *
11
+ * Props: colors (cycled; a rainbow when empty), simResolution, dyeResolution,
12
+ * dissipation (how fast the ink fades), velocityDissipation, curl (swirl),
13
+ * splatRadius, splatForce, idle (soft splashes when nobody moves),
14
+ * className, children.
15
+ */
16
+
17
+ const BASE_VERT = `
18
+ uniform vec2 texelSize;
19
+ varying vec2 vUv; varying vec2 vL; varying vec2 vR; varying vec2 vT; varying vec2 vB;
20
+ void main(){
21
+ vUv = uv;
22
+ vL = vUv - vec2(texelSize.x, 0.0);
23
+ vR = vUv + vec2(texelSize.x, 0.0);
24
+ vT = vUv + vec2(0.0, texelSize.y);
25
+ vB = vUv - vec2(0.0, texelSize.y);
26
+ gl_Position = vec4(position.xy, 0.0, 1.0);
27
+ }`;
28
+
29
+ const HEAD = `precision highp float;
30
+ varying vec2 vUv; varying vec2 vL; varying vec2 vR; varying vec2 vT; varying vec2 vB;
31
+ `;
32
+
33
+ const SPLAT = `${HEAD}
34
+ uniform sampler2D uTarget; uniform float aspect; uniform vec3 color; uniform vec2 point; uniform float radius;
35
+ void main(){
36
+ vec2 p = vUv - point;
37
+ p.x *= aspect;
38
+ vec3 splat = exp(-dot(p, p) / radius) * color;
39
+ gl_FragColor = vec4(texture2D(uTarget, vUv).xyz + splat, 1.0);
40
+ }`;
41
+
42
+ const ADVECT = `${HEAD}
43
+ uniform sampler2D uVelocity; uniform sampler2D uSource; uniform vec2 velTexel; uniform float dt; uniform float dissipation;
44
+ void main(){
45
+ vec2 coord = vUv - dt * texture2D(uVelocity, vUv).xy * velTexel;
46
+ gl_FragColor = texture2D(uSource, coord) / (1.0 + dissipation * dt);
47
+ }`;
48
+
49
+ const DIVERGENCE = `${HEAD}
50
+ uniform sampler2D uVelocity;
51
+ void main(){
52
+ float L = texture2D(uVelocity, vL).x;
53
+ float R = texture2D(uVelocity, vR).x;
54
+ float T = texture2D(uVelocity, vT).y;
55
+ float B = texture2D(uVelocity, vB).y;
56
+ vec2 C = texture2D(uVelocity, vUv).xy;
57
+ // Walls: reflect velocity at the edges.
58
+ if (vL.x < 0.0) L = -C.x;
59
+ if (vR.x > 1.0) R = -C.x;
60
+ if (vT.y > 1.0) T = -C.y;
61
+ if (vB.y < 0.0) B = -C.y;
62
+ gl_FragColor = vec4(0.5 * (R - L + T - B), 0.0, 0.0, 1.0);
63
+ }`;
64
+
65
+ const CURL = `${HEAD}
66
+ uniform sampler2D uVelocity;
67
+ void main(){
68
+ float L = texture2D(uVelocity, vL).y;
69
+ float R = texture2D(uVelocity, vR).y;
70
+ float T = texture2D(uVelocity, vT).x;
71
+ float B = texture2D(uVelocity, vB).x;
72
+ gl_FragColor = vec4(0.5 * (R - L - T + B), 0.0, 0.0, 1.0);
73
+ }`;
74
+
75
+ const VORTICITY = `${HEAD}
76
+ uniform sampler2D uVelocity; uniform sampler2D uCurl; uniform float curl; uniform float dt;
77
+ void main(){
78
+ float L = texture2D(uCurl, vL).x;
79
+ float R = texture2D(uCurl, vR).x;
80
+ float T = texture2D(uCurl, vT).x;
81
+ float B = texture2D(uCurl, vB).x;
82
+ float C = texture2D(uCurl, vUv).x;
83
+ vec2 force = 0.5 * vec2(abs(T) - abs(B), abs(R) - abs(L));
84
+ force /= length(force) + 0.0001;
85
+ force *= curl * C;
86
+ force.y *= -1.0;
87
+ vec2 vel = texture2D(uVelocity, vUv).xy + force * dt;
88
+ gl_FragColor = vec4(clamp(vel, -1000.0, 1000.0), 0.0, 1.0);
89
+ }`;
90
+
91
+ const SCALE = `${HEAD}
92
+ uniform sampler2D uTexture; uniform float value;
93
+ void main(){ gl_FragColor = value * texture2D(uTexture, vUv); }`;
94
+
95
+ const PRESSURE = `${HEAD}
96
+ uniform sampler2D uPressure; uniform sampler2D uDivergence;
97
+ void main(){
98
+ float L = texture2D(uPressure, vL).x;
99
+ float R = texture2D(uPressure, vR).x;
100
+ float T = texture2D(uPressure, vT).x;
101
+ float B = texture2D(uPressure, vB).x;
102
+ float div = texture2D(uDivergence, vUv).x;
103
+ gl_FragColor = vec4((L + R + B + T - div) * 0.25, 0.0, 0.0, 1.0);
104
+ }`;
105
+
106
+ const GRADIENT = `${HEAD}
107
+ uniform sampler2D uPressure; uniform sampler2D uVelocity;
108
+ void main(){
109
+ float L = texture2D(uPressure, vL).x;
110
+ float R = texture2D(uPressure, vR).x;
111
+ float T = texture2D(uPressure, vT).x;
112
+ float B = texture2D(uPressure, vB).x;
113
+ vec2 vel = texture2D(uVelocity, vUv).xy - vec2(R - L, T - B);
114
+ gl_FragColor = vec4(vel, 0.0, 1.0);
115
+ }`;
116
+
117
+ // Shaded ink: a little light on the slopes of the dye gives it body.
118
+ const DISPLAY = `${HEAD}
119
+ uniform sampler2D uTexture; uniform vec2 dyeTexel;
120
+ void main(){
121
+ vec3 c = texture2D(uTexture, vUv).rgb;
122
+ vec3 lc = texture2D(uTexture, vUv - vec2(dyeTexel.x, 0.0)).rgb;
123
+ vec3 rc = texture2D(uTexture, vUv + vec2(dyeTexel.x, 0.0)).rgb;
124
+ vec3 tc = texture2D(uTexture, vUv + vec2(0.0, dyeTexel.y)).rgb;
125
+ vec3 bc = texture2D(uTexture, vUv - vec2(0.0, dyeTexel.y)).rgb;
126
+ float dx = length(rc) - length(lc);
127
+ float dy = length(tc) - length(bc);
128
+ vec3 n = normalize(vec3(dx, dy, length(dyeTexel)));
129
+ float diffuse = clamp(dot(n, vec3(0.0, 0.0, 1.0)) + 0.7, 0.7, 1.0);
130
+ c *= diffuse;
131
+ float a = clamp(max(c.r, max(c.g, c.b)), 0.0, 1.0);
132
+ gl_FragColor = vec4(c, a);
133
+ }`;
134
+
135
+ const hsvToRgb = (h, s, v) => {
136
+ const i = Math.floor(h * 6);
137
+ const f = h * 6 - i;
138
+ const p = v * (1 - s);
139
+ const q = v * (1 - f * s);
140
+ const t = v * (1 - (1 - f) * s);
141
+ return [
142
+ [v, t, p],
143
+ [q, v, p],
144
+ [p, v, t],
145
+ [p, q, v],
146
+ [t, p, v],
147
+ [v, p, q],
148
+ ][i % 6];
149
+ };
150
+
151
+ const FluidCursor = ({ colors = [], simResolution = 128, dyeResolution = 1024, dissipation = 1.6, velocityDissipation = 1.2, curl = 18, splatRadius = 0.22, splatForce = 6000, idle = true, className = "", children }) => {
152
+ const wrap = useRef(null);
153
+ const live = useRef({});
154
+ live.current = { colors, dissipation, velocityDissipation, curl, splatRadius, splatForce, idle };
155
+
156
+ useEffect(() => {
157
+ const el = wrap.current;
158
+ const renderer = new WebGLRenderer({ alpha: true, premultipliedAlpha: true, antialias: false, powerPreference: "high-performance" });
159
+ if (!renderer.capabilities.isWebGL2 && !renderer.extensions.get("OES_texture_half_float")) {
160
+ renderer.dispose();
161
+ return undefined;
162
+ }
163
+ renderer.setPixelRatio(1);
164
+ renderer.autoClear = false;
165
+ Object.assign(renderer.domElement.style, { position: "absolute", inset: "0", width: "100%", height: "100%", display: "block", pointerEvents: "none" });
166
+ el.prepend(renderer.domElement);
167
+
168
+ const scene = new Scene();
169
+ const camera = new OrthographicCamera(-1, 1, 1, -1, 0, 1);
170
+ const quad = new Mesh(new PlaneGeometry(2, 2));
171
+ scene.add(quad);
172
+ const texel = { value: new Vector2() };
173
+ const mat = (frag, uniforms) => new ShaderMaterial({ vertexShader: BASE_VERT, fragmentShader: frag, uniforms: { texelSize: texel, ...uniforms }, depthTest: false, depthWrite: false });
174
+ const m = {
175
+ splat: mat(SPLAT, { uTarget: { value: null }, aspect: { value: 1 }, color: { value: new Color() }, point: { value: new Vector2() }, radius: { value: 0.01 } }),
176
+ advect: mat(ADVECT, { uVelocity: { value: null }, uSource: { value: null }, velTexel: { value: new Vector2() }, dt: { value: 0.016 }, dissipation: { value: 1 } }),
177
+ divergence: mat(DIVERGENCE, { uVelocity: { value: null } }),
178
+ curl: mat(CURL, { uVelocity: { value: null } }),
179
+ vorticity: mat(VORTICITY, { uVelocity: { value: null }, uCurl: { value: null }, curl: { value: 18 }, dt: { value: 0.016 } }),
180
+ scale: mat(SCALE, { uTexture: { value: null }, value: { value: 0.8 } }),
181
+ pressure: mat(PRESSURE, { uPressure: { value: null }, uDivergence: { value: null } }),
182
+ gradient: mat(GRADIENT, { uPressure: { value: null }, uVelocity: { value: null } }),
183
+ display: mat(DISPLAY, { uTexture: { value: null }, dyeTexel: { value: new Vector2() } }),
184
+ };
185
+ m.display.transparent = true;
186
+
187
+ const target = (w, h) => new WebGLRenderTarget(w, h, { type: HalfFloatType, format: RGBAFormat, minFilter: LinearFilter, magFilter: LinearFilter, depthBuffer: false, stencilBuffer: false });
188
+ const double = (w, h) => {
189
+ const d = { read: target(w, h), write: target(w, h), w, h };
190
+ d.swap = () => ([d.read, d.write] = [d.write, d.read]);
191
+ d.dispose = () => {
192
+ d.read.dispose();
193
+ d.write.dispose();
194
+ };
195
+ return d;
196
+ };
197
+ let fbo = null;
198
+ const sizeFor = (res, w, h) => {
199
+ const a = w / h;
200
+ const min = Math.round(res);
201
+ const max = Math.round(res * Math.max(a, 1 / a));
202
+ return a > 1 ? [max, min] : [min, max];
203
+ };
204
+ const build = () => {
205
+ const { width, height } = el.getBoundingClientRect();
206
+ if (!width || !height) return false;
207
+ renderer.setSize(width, height, false);
208
+ fbo?.all.forEach((t) => t.dispose());
209
+ const [sw, sh] = sizeFor(simResolution, width, height);
210
+ const [dw, dh] = sizeFor(Math.min(dyeResolution, Math.max(width, height)), width, height);
211
+ const velocity = double(sw, sh);
212
+ const dye = double(dw, dh);
213
+ const pressure = double(sw, sh);
214
+ const divergence = target(sw, sh);
215
+ const curlT = target(sw, sh);
216
+ fbo = { velocity, dye, pressure, divergence, curl: curlT, all: [velocity, dye, pressure, divergence, curlT], aspect: width / height };
217
+ return true;
218
+ };
219
+
220
+ const pass = (material, out, w, h) => {
221
+ texel.value.set(1 / w, 1 / h);
222
+ quad.material = material;
223
+ renderer.setRenderTarget(out);
224
+ renderer.render(scene, camera);
225
+ };
226
+
227
+ const splat = (x, y, dx, dy, color) => {
228
+ const o = live.current;
229
+ const f = fbo;
230
+ m.splat.uniforms.aspect.value = f.aspect;
231
+ m.splat.uniforms.point.value.set(x, y);
232
+ m.splat.uniforms.radius.value = (o.splatRadius / 100) * (f.aspect > 1 ? f.aspect : 1);
233
+ m.splat.uniforms.uTarget.value = f.velocity.read.texture;
234
+ m.splat.uniforms.color.value.setRGB(dx, dy, 0);
235
+ pass(m.splat, f.velocity.write, f.velocity.w, f.velocity.h);
236
+ f.velocity.swap();
237
+ m.splat.uniforms.uTarget.value = f.dye.read.texture;
238
+ m.splat.uniforms.color.value.setRGB(color[0], color[1], color[2]);
239
+ pass(m.splat, f.dye.write, f.dye.w, f.dye.h);
240
+ f.dye.swap();
241
+ };
242
+
243
+ let hue = Math.random();
244
+ let pick = 0;
245
+ const nextColor = (strength = 0.3) => {
246
+ const list = live.current.colors;
247
+ if (list?.length) {
248
+ const c = new Color(list[pick++ % list.length]);
249
+ return [c.r * strength, c.g * strength, c.b * strength];
250
+ }
251
+ hue = (hue + 0.07) % 1;
252
+ return hsvToRgb(hue, 1, 1).map((v) => v * strength);
253
+ };
254
+
255
+ const step = (dt) => {
256
+ const o = live.current;
257
+ const f = fbo;
258
+ const { velocity, dye, pressure } = f;
259
+ // Swirl.
260
+ m.curl.uniforms.uVelocity.value = velocity.read.texture;
261
+ pass(m.curl, f.curl, velocity.w, velocity.h);
262
+ m.vorticity.uniforms.uVelocity.value = velocity.read.texture;
263
+ m.vorticity.uniforms.uCurl.value = f.curl.texture;
264
+ m.vorticity.uniforms.curl.value = o.curl;
265
+ m.vorticity.uniforms.dt.value = dt;
266
+ pass(m.vorticity, velocity.write, velocity.w, velocity.h);
267
+ velocity.swap();
268
+ // Make it incompressible.
269
+ m.divergence.uniforms.uVelocity.value = velocity.read.texture;
270
+ pass(m.divergence, f.divergence, velocity.w, velocity.h);
271
+ m.scale.uniforms.uTexture.value = pressure.read.texture;
272
+ m.scale.uniforms.value.value = 0.8;
273
+ pass(m.scale, pressure.write, pressure.w, pressure.h);
274
+ pressure.swap();
275
+ m.pressure.uniforms.uDivergence.value = f.divergence.texture;
276
+ for (let i = 0; i < 20; i++) {
277
+ m.pressure.uniforms.uPressure.value = pressure.read.texture;
278
+ pass(m.pressure, pressure.write, pressure.w, pressure.h);
279
+ pressure.swap();
280
+ }
281
+ m.gradient.uniforms.uPressure.value = pressure.read.texture;
282
+ m.gradient.uniforms.uVelocity.value = velocity.read.texture;
283
+ pass(m.gradient, velocity.write, velocity.w, velocity.h);
284
+ velocity.swap();
285
+ // Carry velocity and ink along.
286
+ m.advect.uniforms.velTexel.value.set(1 / velocity.w, 1 / velocity.h);
287
+ m.advect.uniforms.dt.value = dt;
288
+ m.advect.uniforms.uVelocity.value = velocity.read.texture;
289
+ m.advect.uniforms.uSource.value = velocity.read.texture;
290
+ m.advect.uniforms.dissipation.value = o.velocityDissipation;
291
+ pass(m.advect, velocity.write, velocity.w, velocity.h);
292
+ velocity.swap();
293
+ m.advect.uniforms.uVelocity.value = velocity.read.texture;
294
+ m.advect.uniforms.uSource.value = dye.read.texture;
295
+ m.advect.uniforms.dissipation.value = o.dissipation;
296
+ pass(m.advect, dye.write, dye.w, dye.h);
297
+ dye.swap();
298
+ };
299
+
300
+ const draw = () => {
301
+ m.display.uniforms.uTexture.value = fbo.dye.read.texture;
302
+ m.display.uniforms.dyeTexel.value.set(1 / fbo.dye.w, 1 / fbo.dye.h);
303
+ texel.value.set(1 / fbo.dye.w, 1 / fbo.dye.h);
304
+ quad.material = m.display;
305
+ renderer.setRenderTarget(null);
306
+ renderer.clear();
307
+ renderer.render(scene, camera);
308
+ };
309
+
310
+ const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
311
+ const pointer = { x: 0, y: 0, dx: 0, dy: 0, moved: false, inside: false, color: nextColor(), lastMove: 0 };
312
+ let raf = 0;
313
+ let last = 0;
314
+ let visible = true;
315
+ let nextIdle = 0;
316
+
317
+ const frame = (now) => {
318
+ raf = 0;
319
+ const o = live.current;
320
+ const dt = last ? Math.min((now - last) / 1000, 0.016) : 0.016;
321
+ last = now;
322
+ if (pointer.moved) {
323
+ pointer.moved = false;
324
+ splat(pointer.x, pointer.y, pointer.dx * o.splatForce, pointer.dy * o.splatForce, pointer.color);
325
+ }
326
+ // Idle: soft splashes from the edges so the space never looks empty.
327
+ if (o.idle && now - pointer.lastMove > 2500 && now > nextIdle) {
328
+ nextIdle = now + 1400 + Math.random() * 1200;
329
+ const x = Math.random();
330
+ const y = Math.random() * 0.6 + 0.2;
331
+ const ang = Math.random() * Math.PI * 2;
332
+ splat(x, y, Math.cos(ang) * 600, Math.sin(ang) * 600, nextColor(0.35));
333
+ }
334
+ step(dt);
335
+ draw();
336
+ if (visible) raf = requestAnimationFrame(frame);
337
+ };
338
+ const start = () => {
339
+ if (!raf && visible && !reduced) {
340
+ last = 0;
341
+ raf = requestAnimationFrame(frame);
342
+ }
343
+ };
344
+
345
+ // Listen on the window so the layer can sit over clickable content.
346
+ const onMove = (e) => {
347
+ const r = el.getBoundingClientRect();
348
+ const inside = e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom;
349
+ if (!inside) {
350
+ pointer.inside = false;
351
+ return;
352
+ }
353
+ const x = (e.clientX - r.left) / r.width;
354
+ const y = 1 - (e.clientY - r.top) / r.height;
355
+ if (!pointer.inside) {
356
+ pointer.inside = true;
357
+ pointer.x = x;
358
+ pointer.y = y;
359
+ return;
360
+ }
361
+ pointer.dx = (x - pointer.x) * (fbo.aspect < 1 ? fbo.aspect : 1);
362
+ pointer.dy = (y - pointer.y) / (fbo.aspect > 1 ? fbo.aspect : 1);
363
+ pointer.x = x;
364
+ pointer.y = y;
365
+ pointer.moved = Math.abs(pointer.dx) + Math.abs(pointer.dy) > 0;
366
+ pointer.lastMove = performance.now();
367
+ if (Math.random() < 0.08) pointer.color = nextColor();
368
+ };
369
+ const onDown = (e) => {
370
+ const r = el.getBoundingClientRect();
371
+ if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) return;
372
+ const x = (e.clientX - r.left) / r.width;
373
+ const y = 1 - (e.clientY - r.top) / r.height;
374
+ const c = nextColor(0.6);
375
+ for (let i = 0; i < 6; i++) {
376
+ const ang = (i / 6) * Math.PI * 2;
377
+ splat(x, y, Math.cos(ang) * 1400, Math.sin(ang) * 1400, c);
378
+ }
379
+ pointer.lastMove = performance.now();
380
+ };
381
+ window.addEventListener("pointermove", onMove, { passive: true });
382
+ window.addEventListener("pointerdown", onDown, { passive: true });
383
+
384
+ const ro = new ResizeObserver(() => {
385
+ if (build()) start();
386
+ });
387
+ ro.observe(el);
388
+ const io = new IntersectionObserver(([entry]) => {
389
+ visible = entry.isIntersecting;
390
+ if (visible) start();
391
+ });
392
+ io.observe(el);
393
+ if (build()) start();
394
+
395
+ return () => {
396
+ cancelAnimationFrame(raf);
397
+ ro.disconnect();
398
+ io.disconnect();
399
+ window.removeEventListener("pointermove", onMove);
400
+ window.removeEventListener("pointerdown", onDown);
401
+ fbo?.all.forEach((t) => t.dispose());
402
+ Object.values(m).forEach((x) => x.dispose());
403
+ quad.geometry.dispose();
404
+ renderer.dispose();
405
+ renderer.forceContextLoss();
406
+ renderer.domElement.remove();
407
+ };
408
+ // Resolutions rebuild the simulation; the rest is read live.
409
+ }, [simResolution, dyeResolution]);
410
+
411
+ return (
412
+ <div ref={wrap} className={`pointer-events-none absolute inset-0 overflow-hidden ${className}`}>
413
+ {children}
414
+ </div>
415
+ );
416
+ };
417
+
418
+ export default FluidCursor;