Cursor Repulsion Burst
Up to 1400 particles in one instanced draw call, each circling its own home on its own phase. The pointer opens a hole in the field with a linear falloff inside its radius, and a press fires a shock wave whose front tints the particles it is passing through. The pointer is damped on the CPU before it reaches a uniform, and bursts run in a pool of three slots so overlapping presses produce overlapping waves rather than one wave restarting.
Cursor Repulsion Burst
Live preview
The real component, running in this page.
Install
Copies the source into your project. Nothing is imported from us at runtime.
npx shadcn@latest add https://kinetic-seven-taupe.vercel.app/r/cursor-repulsion-burst.jsonAlso installs prng from this registry.
Prompt
The whole technique, written for an agent.
Build it with your agent
A single self-contained brief: the technique, the constants and why they hold, the pitfalls, and the definition of done. Paste it and the component gets written in your own conventions instead of copied in ours.
# Build: Cursor Repulsion Burst
Up to 1400 particles in one instanced draw call, each circling its own home on its own phase. The pointer opens a hole in the field with a linear falloff inside its radius, and a press fires a shock wave whose front tints the particles it is passing through. The pointer is damped on the CPU before it reaches a uniform, and bursts run in a pool of three slots so overlapping presses produce overlapping waves rather than one wave restarting.
Build this as a React component in my project. Match my existing conventions —
file layout, styling system, and naming — rather than the ones implied below.
Everything after this line is specification, not code to copy verbatim.
## What it does
An orbiting particle field the pointer pushes out of the way, and a press detonates into a shock wave with a heat band.
- Category: cursor
- Runtime cost: webgl
- Dependencies: ogl@^1.0.11
## How it works
1. Three displacements are stacked in field space in the vertex shader before anything becomes a position: an idle orbit, cursor repulsion, then up to three shock waves. Each stage reads the position the previous one produced, so a particle already pushed aside by the cursor is the particle the wave throws.
2. All distances are measured in aspect-corrected space — x multiplied by `uResolution.x / uResolution.y` — and the resulting displacement is divided back out before it is added to the position. Without the correction the repulsion hole is an ellipse on any container that is not square, and the shock ring reaches the side edges before the top.
3. Repulsion is `max(radius - distance, 0) * repel`: linear inside the radius and exactly zero outside it, so the field beyond the pointer is untouched and the whole idea costs one `length()` per particle. A `smoothstep` would be softer but would also mean every particle on screen is permanently displaced a little, which reads as the field sagging toward the cursor.
4. The direction is `v / max(length(v), floor)` rather than `normalize(v)`. A true normalize is undefined at the origin and unstable just outside it, so the particle sitting under the cursor picks a new direction every frame from floating-point noise and buzzes. Dividing by a floor makes the vector shorter than unit length near the centre, so the push fades in and the direction stays put.
5. A burst slot is `vec4(originX, originY, progress, active)`. The displacement is `(0.25 + seed * 0.75) * push * easeOutCubic(t) * settle(t) * reach(d)`, where `settle` walks the whole thing back to zero by t = 1 so the slot can be freed without the field snapping home, and `reach` limits the throw to the neighbourhood of the origin so the burst is not a camera jolt.
6. The heat band is `exp(-b * b)` where `b = (distance - burstRadius * ease) / band`. The shock radius expands on the same cubic ease as the displacement, so only the particles the front is passing through right now take the tint. A flat tint over the whole burst just recolours the field; a Gaussian band travelling through it reads as heat.
7. Only `active` and `progress` change per frame per slot, so a burst costs three vec4 uniforms and a three-iteration loop — no per-particle CPU state and no buffer upload.
## Constants — use these values
These are tuned. Each one has a reason; do not round them off or replace them
with values that "look cleaner".
- `smoothing = 0.0008`
Fraction of the pointer gap still remaining after one second: a tight ~90ms tail, because a repulsion hole that lags is read as broken rather than as smooth — the hole has a visible edge, unlike a glow. Raise it past ~0.01 and the field is still opening where the cursor was half a second ago.
- `cursorRadius = 0.22`
Field units, where 1 is the container HEIGHT. Roughly a fifth of the panel, so the hole is obviously deliberate but the field still dominates. Past ~0.45 the pointer clears most of the visible particles and there is nothing left to displace.
- `repel = 0.85`
Displacement at the centre of the field, as a fraction of the radius. Below ~0.4 the hole never actually opens — particles crowd instead of clearing. Above 1.0 the inner particles are pushed past the rim and pile into a bright ring, which is the same artefact as a hard-edged mask.
- `BURST_SLOTS = 3`
Concurrent waves. One slot means a second press restarts the wave from zero, which visibly sucks the first one back into its origin. Three covers a fast double or triple click, and each slot is a vec4 and a loop iteration, so the ceiling is set by taste rather than by cost.
- `burstDuration = 1.1`
Seconds of life. Under ~0.6 the wave is gone before the eye finds it; over ~2 the field spends most of its time recovering and a second press lands in a field that is still deformed.
- `burstRadius / BURST_BAND = 0.55 / 0.085`
How far the front travels, and how thick it is. The band is roughly 15% of the travel, which is a front rather than a wash — take it past ~0.25 and the Gaussian covers the whole burst at once, which is indistinguishable from tinting every particle.
- `burstPush = 0.17`
Peak displacement in field units, multiplied by a per-particle 0.25-1.0. The spread is what gives the wave a ragged front; a uniform magnitude produces a clean expanding circle, which reads as a UI ripple rather than as a detonation.
- `MAX_DPR = 1.5`
Additive fill is the entire cost here and it scales with the square of the ratio. These are soft round dots with no fine detail, so a third sample per axis resolves nothing a viewer can see and costs 78% more fragments.
## API
```ts
function CursorRepulsionBurst(props: { density?: number; maxCount?: number; sizeRange?: readonly [number, number]; cursorRadius?: number; repel?: number; burstPush?: number; burstRadius?: number; burstDuration?: number; smoothing?: number; breathe?: number; opacity?: number; colorA?: string; colorB?: string; heatColor?: string; background?: string; seed?: number | string; className?: string }): JSX.Element
```
- `density: number` — default `1100`
Particles per megapixel of container area.
- `maxCount: number` — default `1400`
Population ceiling, and the size the buffers are allocated at.
- `sizeRange: readonly [number, number]` — default `[1.6, 5.2]`
Particle size in CSS pixels, biased toward the small end.
- `cursorRadius: number` — default `0.22`
Repulsion radius in field units, where 1 is the container height.
- `repel: number` — default `0.85`
Displacement at the centre of the repulsion field, as a fraction of its radius.
- `burstPush: number` — default `0.17`
Peak burst displacement in field units, before the per-particle spread.
- `burstRadius: number` — default `0.55`
How far a shock front travels before it dies.
- `burstDuration: number` — default `1.1`
Life of one burst in seconds.
- `smoothing: number` — default `0.0008`
Fraction of the pointer gap still remaining after one second.
- `breathe: number` — default `0.22`
Depth of the orbit-radius breathing, 0 to 0.5.
- `opacity: number` — default `0.9`
Overall alpha multiplier.
- `colorA: string` — default `'#8fb6ff'`
First tint. Any value ogl's Color accepts.
- `colorB: string` — default `'#e6ecf7'`
Second tint; each particle mixes between the two.
- `heatColor: string` — default `'#ff8a3c'`
Colour of the shock front.
- `background: string` — default `'#06070b'`
Opaque clear colour the additive field is composited onto.
- `seed: number | string` — default `'cursor-repulsion-burst'`
Seeds the scatter, sizes, phases and burst magnitudes.
- `className: string`
Merged onto the root container via `cn`.
## Reference implementation of the core
This is the part that is hard to get right. Adapt it; do not treat the rest of
the component as fixed.
```tsx
/** Floor on the population. Below this the field reads as a few stray specks. */
const MIN_COUNT = 220;
/** Slots in the burst pool. Three overlapping waves is the most anyone produces. */
const BURST_SLOTS = 3;
/** Width of the Gaussian heat band, in field units. */
const BURST_BAND = 0.085;
/** Fraction of the presence gap left after a second. ~200ms in and out. */
const PRESENCE_SMOOTHING = 0.007;
/** Size distribution exponent. Above 1 crowds the population into small sizes. */
const SIZE_BIAS = 1.8;
/**
* Fill cost scales with dpr squared and these are soft round dots with no fine
* detail, so a third sample per axis resolves nothing a viewer can see.
*/
const MAX_DPR = 1.5;
/** Field-space bounds. Wider than 0..1 so orbits never bare the edges. */
const HOME_MIN = -0.06;
const HOME_MAX = 1.06;
/** The reduced-motion still: a frozen frame with the pointer and one wave in it. */
const STILL_ELAPSED = 6.5;
const STILL_CURSOR: readonly [number, number] = [0.34, 0.56];
const STILL_BURST: readonly [number, number, number] = [0.66, 0.42, 0.42];
export function CursorRepulsionBurst({
density = 1100,
maxCount = 1400,
sizeRange = [1.6, 5.2],
cursorRadius = 0.22,
repel = 0.85,
burstPush = 0.17,
burstRadius = 0.55,
burstDuration = 1.1,
smoothing = 0.0008,
breathe = 0.22,
opacity = 0.9,
colorA = '#8fb6ff',
colorB = '#e6ecf7',
heatColor = '#ff8a3c',
background = '#06070b',
seed = 'cursor-repulsion-burst',
className,
}: CursorRepulsionBurstProps) {
const host = useRef<HTMLDivElement>(null);
const stage = useRef<Stage | null>(null);
const [unsupported, setUnsupported] = useState(false);
const { width, height } = useResizeObserver(host);
const size = useRef({ width, height });
size.current = { width, height };
const reduced = useReducedMotion();
const visible = useVisibility();
const { inView } = useInView(host, { rootMargin: '10%' });
const ready = width > 1 && height > 1;
const [minSize = 1.6, maxSize = 5.2] = sizeRange;
/* Raw pointer target in field space, or null until a real move has happened —
which is not the same as a pointer resting at the bottom-left corner. */
const target = useRef<{ x: number; y: number } | null>(null);
const inside = useRef(false);
/** The damped follower, seeded at the centre so the first move eases outward. */
const follow = useRef({ x: 0.5, y: 0.5 });
const presence = useRef(0);
const slots = useRef<BurstSlot[]>(
Array.from({ length: BURST_SLOTS }, () => ({ x: 0.5, y: 0.5, t: 0, active: false })),
);
const nextSlot = useRef(0);
/* Live numbers the loop reads. Changing one must not rebuild the renderer:
Chromium drops the OLDEST WebGL context past roughly 16 per process, so a
rebuild per prop change can blank an unrelated canvas elsewhere on the page. */
const live = useRef({
cursorRadius,
repel,
burstPush,
burstRadius,
burstDuration,
smoothing,
breathe,
opacity,
});
live.current = {
cursorRadius,
repel,
burstPush,
burstRadius,
burstDuration,
smoothing,
breathe,
opacity,
};
useEffect(() => {
const el = host.current;
if (!el || !ready) return;
if (!hasWebGL()) {
setUnsupported(true);
return;
}
/* ogl creates its own canvas, and it is appended and removed per effect
run. Reusing one React-owned canvas breaks under StrictMode: the first
cleanup force-loses the context, and `getContext` on that same element
afterwards returns the dead one rather than a fresh context — the panel
is then permanently blank in development and fine in production. */
const renderer = new Renderer({
dpr: pixelRatio(MAX_DPR),
// Opaque: the pass is additive, and additive onto a transparent canvas
// would have to brighten the page behind it, which the GPU cannot see.
alpha: false,
// Nothing occludes anything, so a depth buffer is memory plus a clear.
depth: false,
antialias: false,
// Fill-bound, not shader-bound. The discrete GPU buys nothing here.
powerPreference: 'low-power',
});
const gl = renderer.gl;
const canvas = gl.canvas;
const bg = new Color(background);
gl.clearColor(bg.r, bg.g, bg.b, 1);
const quad = new Float32Array([-0.5, -0.5, 0.5, -0.5, 0.5, 0.5, -0.5, 0.5]);
const index = new Uint16Array([0, 1, 2, 0, 2, 3]);
const total = Math.max(MIN_COUNT, Math.round(maxCount));
const homes = new Float32Array(total * 2);
const params = new Float32Array(total * 4);
const seeds = new Float32Array(total * 4);
const rng = createRng(seed);
const lo = Math.max(0.5, Math.min(minSize, maxSize));
const hi = Math.max(lo + 0.25, Math.max(minSize, maxSize));
for (let i = 0; i < total; i += 1) {
homes[i * 2] = randRange(rng, HOME_MIN, HOME_MAX);
homes[i * 2 + 1] = randRange(rng, HOME_MIN, HOME_MAX);
// Biased small: a linear distribution puts a quarter of the population in
// the largest quarter of the range, and the field reads as gravel.
const sizeT = Math.pow(rng(), SIZE_BIAS);
params[i * 4] = lo + (hi - lo) * sizeT;
params[i * 4 + 1] = randRange(rng, 0.004, 0.03);
// Signed rate with a floor on the magnitude: a particle orbiting at
// 0.001 rad/s is a particle that is simply not moving.
params[i * 4 + 2] = (rng() < 0.5 ? -1 : 1) * randRange(rng, 0.12, 0.46);
// Bigger particles are dimmer, so total light stays roughly constant.
params[i * 4 + 3] = 0.34 + 0.5 * (1 - sizeT);
seeds[i * 4] = rng() * Math.PI * 2;
// Burst magnitude. Per-particle, so the wave has a ragged front rather
// than a clean expanding circle — a clean circle reads as a UI ripple.
seeds[i * 4 + 1] = rng();
seeds[i * 4 + 2] = randRange(rng, 0.35, 1);
seeds[i * 4 + 3] = rng();
}
const geometry = new Geometry(gl, {
index: { data: index },
position: { size: 2, data: quad },
aHome: { size: 2, data: homes, instanced: 1 },
aParams: { size: 4, data: params, instanced: 1 },
aSeed: { size: 4, data: seeds, instanced: 1 },
});
const uniforms: Uniforms = {
uResolution: { value: [1, 1] },
uPixelRatio: { value: renderer.dpr },
uElapsed: { value: 0 },
uCursor: { value: [0.5, 0.5] },
uCursorPresence: { value: 0 },
uCursorRadius: { value: cursorRadius },
uRepel: { value: repel },
uBreathe: { value: clamp(breathe, 0, 0.5) },
uBursts: { value: new Array<number>(BURST_SLOTS * 4).fill(0) },
uBurstRadius: { value: burstRadius },
uBurstPush: { value: burstPush },
uBurstBand: { value: BURST_BAND },
uColorA: { value: new Color(colorA) },
uColorB: { value: new Color(colorB) },
uHeatColor: { value: new Color(heatColor) },
uOpacity: { value: opacity },
};
const program = new Program(gl, {
vertex: CURSOR_REPULSION_BURST_VERT,
fragment: CURSOR_REPULSION_BURST_FRAG,
uniforms,
transparent: true,
depthTest: false,
depthWrite: false,
// ogl culls BACK faces by default and the quad's winding is not
// guaranteed to face the way the driver expects. A culled particle is
// not dim — it is absent.
cullFace: false,
});
// Additive, overriding the source-over that `transparent: true` selected.
// Order independent, so a thousand overlapping instances need no sort,
// which is what makes one draw call possible at all.
program.setBlendFunc(gl.ONE, gl.ONE);
const mesh = new Mesh(gl, { geometry, program });
el.appendChild(canvas);
canvas.style.display = 'block';
/* Listeners on the CANVAS, not the window. A document-level handler
outlives the renderer it was written for, and the rect it measures is not
the rect being drawn into — after a scroll the hole sits at an offset
from the cursor. Passive, because nothing here calls preventDefault and a
non-passive move handler makes the compositor wait for JS on every event. */
const toField = (event: PointerEvent) => {
const rect = canvas.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return null;
return {
x: (event.clientX - rect.left) / rect.width,
// Flipped: field y is up, client y is down.
y: 1 - (event.clientY - rect.top) / rect.height,
};
};
const onMove = (event: PointerEvent) => {
const point = toField(event);
if (!point) return;
target.current = point;
inside.current = true;
};
const onDown = (event: PointerEvent) => {
const point = toField(event);
if (!point) return;
// A press is also a position: on touch there is no hover, so without this
// the wave would fire from wherever the pointer last happened to be.
target.current = point;
inside.current = true;
const slot = slots.current[nextSlot.current % BURST_SLOTS];
if (!slot) return;
nextSlot.current = (nextSlot.current + 1) % BURST_SLOTS;
slot.x = point.x;
slot.y = point.y;
slot.t = 0;
slot.active = true;
};
const onLeave = () => {
// Position is left where it is. Tweening it back to the centre drags a
// visible furrow across the field on the way out.
inside.current = false;
};
canvas.addEventListener('pointermove', onMove, { passive: true });
canvas.addEventListener('pointerdown', onDown, { passive: true });
canvas.addEventListener('pointerleave', onLeave, { passive: true });
canvas.addEventListener('pointercancel', onLeave, { passive: true });
const resize = (w: number, h: number) => {
renderer.setSize(w, h);
// `setSize` writes literal px into the canvas's inline style, which wins
// over any class. Re-asserting percentages keeps it fluid between
// resize notifications.
canvas.style.width = '100%';
canvas.style.height = '100%';
uniforms.uResolution.value = [gl.drawingBufferWidth, gl.drawingBufferHeight];
uniforms.uPixelRatio.value = renderer.dpr;
// Population from area, not a fixed number: the same density reads right
// in a 320px card and a 2000px hero. The buffers already hold `total`
// particles in scattered order, so a prefix of them is still spatially
// even — no rebuild, no reallocation, no new GPU memory.
const megapixels = (w * h) / 1_000_000;
const count = clamp(Math.round(megapixels * density), MIN_COUNT, total);
geometry.setInstancedCount(count);
};
const draw = (elapsed: number) => {
uniforms.uElapsed.value = elapsed;
renderer.render({ scene: mesh });
};
resize(size.current.width, size.current.height);
stage.current = { renderer, geometry, program, mesh, uniforms, resize, draw };
draw(0);
return () => {
stage.current = null;
canvas.removeEventListener('pointermove', onMove);
canvas.removeEventListener('pointerdown', onDown);
canvas.removeEventListener('pointerleave', onLeave);
canvas.removeEventListener('pointercancel', onLeave);
// ogl has no `dispose()`: `remove()` on the geometry deletes its VAOs and
// every buffer, `remove()` on the program deletes the shaders and the
// program, and only then is the context worth handing back. Dropping the
// context is the part that matters — the browser's per-process budget is
// about sixteen, and a lost one takes an unrelated canvas with it.
geometry.remove();
program.remove();
loseContext(gl);
canvas.width = 0;
canvas.height = 0;
canvas.remove();
};
}, [ready, density, maxCount, minSize, maxSize, seed]);
// Colours are pushed into the live uniforms rather than being effect deps, so
// recolouring never costs a context.
useEffect(() => {
const s = stage.current;
if (!s) return;
s.uniforms.uColorA.value.set(colorA);
s.uniforms.uColorB.value.set(colorB);
s.uniforms.uHeatColor.value.set(heatColor);
const bg = new Color(background);
s.renderer.gl.clearColor(bg.r, bg.g, bg.b, 1);
}, [colorA, colorB, heatColor, background]);
// Resize is its own effect: a reflow costs a `setSize` and an instanced-count
// change, never a buffer rebuild.
useEffect(() => {
stage.current?.resize(width, height);
}, [width, height]);
useRaf(
(dt, elapsed) => {
const s = stage.current;
if (!s) return;
const cfg = live.current;
const aim = target.current;
if (aim) {
follow.current.x = damp(follow.current.x, aim.x, cfg.smoothing, dt);
follow.current.y = damp(follow.current.y, aim.y, cfg.smoothing, dt);
}
presence.current = damp(
presence.current,
inside.current && aim !== null ? 1 : 0,
PRESENCE_SMOOTHING,
dt,
);
// Bursts advance on wall-clock seconds, not on frames, so a wave lasts
// `burstDuration` on a 30Hz phone and on a 144Hz monitor alike.
const flat = s.uniforms.uBursts.value;
const life = Math.max(cfg.burstDuration, 0.05);
let i = 0;
for (const slot of slots.current) {
if (slot.active) {
slot.t += dt / life;
if (slot.t >= 1) {
slot.t = 1;
slot.active = false;
}
}
flat[i * 4] = slot.x;
flat[i * 4 + 1] = slot.y;
flat[i * 4 + 2] = slot.t;
flat[i * 4 + 3] = slot.active ? 1 : 0;
i += 1;
}
const u = s.uniforms;
// Mutated in place. Assigning a fresh tuple allocates two arrays a frame
// and defeats ogl's redundant-uniform cache, which compares by length
// first and then element by element against the array it kept.
u.uCursor.value[0] = follow.current.x;
u.uCursor.value[1] = follow.current.y;
u.uCursorPresence.value = presence.current;
u.uCursorRadius.value = cfg.cursorRadius;
u.uRepel.value = cfg.repel;
u.uBurstPush.value = cfg.burstPush;
u.uBurstRadius.value = cfg.burstRadius;
u.uBreathe.value = clamp(cfg.breathe, 0, 0.5);
u.uOpacity.value = cfg.opacity;
s.draw(elapsed);
},
ready && inView && visible && !reduced && !unsupported,
);
/* Reduced motion is a composed still, not an empty box: one frame with the
pointer parked off-centre and a single wave frozen part-way out, so the
panel shows what the effect produces. The listeners stay attached and their
target is never consumed, so moving or clicking changes nothing. */
useEffect(() => {
const s = stage.current;
if (!s || !reduced || !ready) return;
const [cx = 0.34, cy = 0.56] = STILL_CURSOR;
const [bx = 0.66, by = 0.42, bt = 0.42] = STILL_BURST;
s.uniforms.uCursor.value[0] = cx;
s.uniforms.uCursor.value[1] = cy;
s.uniforms.uCursorPresence.value = 1;
const flat = s.uniforms.uBursts.value;
flat.fill(0);
flat[0] = bx;
flat[1] = by;
flat[2] = bt;
flat[3] = 1;
s.draw(STILL_ELAPSED);
}, [reduced, ready, width, height]);
```
## Requirements
These are not optional. A version of this component that skips any of them is
not finished.
**Cleanup.** Removes the four pointer listeners from the canvas, nulls the stage ref so an in-flight frame cannot touch a dead renderer, then `geometry.remove()` (deletes the VAOs and every buffer), `program.remove()` (deletes the shaders and the program), `loseContext(gl)`, zeroes the canvas dimensions and removes it from the DOM. ogl has no `dispose()`, and dropping the context is the part that matters — the per-process budget is about sixteen, and exceeding it kills the oldest context, which belongs to somebody else.
**Reduced motion.** The render loop never starts. One frame is drawn with the pointer parked at (0.34, 0.56) at full presence and a single wave frozen 42% of the way out, so the panel is a composed still of the effect rather than a static dot field. The listeners stay attached but their target is never consumed, so moving or pressing changes nothing on screen.
**Frame-rate independence.** Any smoothing must be a function of delta time.
`x += (target - x) * 0.1` converges twice as fast at 120 Hz as at 60 Hz; use
`x = target + (x - target) * Math.pow(smoothing, dt)` instead, where
`smoothing` is the fraction of the gap remaining after one second.
**Strict mode.** Assume effects run twice in development. Setup must be
idempotent and teardown must fully undo it.
## Known pitfalls — avoid these specifically
- Writing pointer events straight into `uCursor`. The hole lands exactly where the last event was: a high-rate trackpad delivers a staircase, and after a stalled frame it is a jump that shoves the whole field sideways in one step. The listener records a target and the loop damps toward it.
- Damping with a per-frame constant (`x += (t - x) * 0.1`) instead of `damp(..., dt)`. The same code produces a noticeably tighter follow at 120Hz than at 60Hz, so the effect is tuned for whatever machine it was written on.
- Using `normalize()` for the repulsion direction. It is undefined at the origin, so the particle under the cursor jitters between random directions every frame — a visible buzzing speck exactly where the eye is.
- Aspect-correcting the distances but not dividing the displacement back out. Particles are then pushed further horizontally than vertically, and the hole slowly shears the field sideways as the pointer moves.
- Restarting one burst slot on every press. The in-flight wave is yanked back to its origin before the new one starts, which looks like a rendering fault rather than a second click. Three round-robin slots let waves overlap.
- Advancing burst progress per frame instead of by `dt / duration`. The wave is then twice as fast on a 120Hz display and half speed on a throttled tab.
- Deactivating a slot while its displacement is still non-zero. Every particle in it snaps home on one frame. The magnitude is multiplied by a settle term that reaches zero exactly when the slot expires.
- Handing ogl a Float32Array for `uniform vec4 uBursts[3]`. ogl resolves the active uniform name `uBursts[0]`, checks `Array.isArray(value)` to decide it is an array uniform, and a Float32Array fails that test — the uniform is silently skipped with a console warning and every burst is invisible. It must be a plain array.
- Leaving ogl's default BACK-face culling on. The quad winding is not guaranteed to face the way the driver expects, and a culled particle is not dim, it is absent — so the field renders empty on some machines and fine on others.
- Owning the canvas in React while calling `loseContext()` on cleanup. Under `reactStrictMode` the effect runs twice and the second `getContext` on that element hands back the dead context: blank in development, fine in production.
- Rebuilding the renderer when a colour changes. Chromium drops the oldest WebGL context past roughly sixteen per process, so recolouring one field can blank a different canvas on the same page. Colours are written into live uniforms.
## Notes
- `pointerdown` sets the pointer target as well as firing the wave. On touch there is no hover, so without it the burst would originate from wherever the pointer last happened to be — usually the previous tap.
- Blending is additive with an opaque clear colour, so a thousand overlapping instances need no depth sort and the whole field is one draw call. The fragment output is premultiplied because the destination is only ever added to.
- The population is derived from container area rather than fixed, and selected with `setInstancedCount` out of buffers allocated once at `maxCount`. Resizing changes the count without touching a byte of GPU memory.
- The scatter runs on `createRng(seed)`, never `Math.random()`, so the field is identical on every load and a visual regression is a real regression.
- Particles inside the repulsion radius also brighten, which stops the hole reading as a bald patch — the field gets denser and hotter at the rim instead.
## Definition of done
The field drifts on its own with no pointer on the page. Move the pointer in and a clear hole opens around it, tracking with a short even lag that is the same on a 60Hz and a 120Hz display, with the rim slightly brighter than the rest of the field. The particle directly under the cursor does not jitter. Press and a wave throws the nearby particles outward with a ragged front, a warm band travelling through them, and they drift back without snapping. Press three times quickly and you get three overlapping waves, not one wave restarting. Leave the panel and the hole closes where it stands. Resize to a wide container and the hole is still round. With reduced motion the panel is a single composed still.
When you are finished, tell me which of the requirements above you implemented
and where, so I can check them.
Source
10 files, exactly as installed.
'use client';
import { useEffect, useRef, useState } from 'react';
import { Color, Geometry, Mesh, Program, Renderer } from 'ogl';
import { cn } from '@/lib/utils';
import { useInView } from '@/hooks/use-in-view';
import { useRaf } from '@/hooks/use-raf';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
import { useResizeObserver } from '@/hooks/use-resize-observer';
import { useVisibility } from '@/hooks/use-visibility';
import { clamp, damp } from '@/lib/damp';
import { createRng, randRange } from '@/lib/prng';
import { hasWebGL, loseContext, pixelRatio } from '@/lib/webgl';
import {
CURSOR_REPULSION_BURST_FRAG,
CURSOR_REPULSION_BURST_VERT,
} from '@/components/motion/shaders/cursor-repulsion-burst';
/* ==========================================================================
Cursor Repulsion Burst
--------------------------------------------------------------------------
A field of orbiting particles that the pointer pushes out of the way and a
press detonates. One instanced draw call, three displacements stacked in the
vertex shader, and all of the state that has to be smooth kept on the CPU.
The pointer NEVER reaches a uniform directly. The listener records a target
and the render loop damps toward it with `damp(current, target, smoothing,
dt)`, which converges at the same rate on a 60Hz and a 120Hz display. Feeding
raw events straight to `uCursor` puts the repulsion hole exactly where the
last event landed: on a high-rate trackpad that is a staircase, and after a
stall it is a jump that shoves the whole field sideways in one frame.
Bursts live in a pool of THREE slots, round-robin. A single slot means a
second press restarts the wave from zero, which visibly sucks the first one
back into the origin; three slots let waves overlap, which is what a person
clicking twice expects to see.
========================================================================== */
export interface CursorRepulsionBurstProps {
/** Particles per megapixel of container area. The count is derived, not fixed. */
density?: number;
/** Hard ceiling on the population, and the size the buffers are allocated at. */
maxCount?: number;
/** Particle size range in CSS pixels, biased toward the small end. */
sizeRange?: readonly [number, number];
/** Repulsion radius, in field units where 1 is the container HEIGHT. */
cursorRadius?: number;
/** Displacement at the centre of the repulsion field, as a fraction of its radius. */
repel?: number;
/** Peak burst displacement in field units. */
burstPush?: number;
/** How far a shock front travels before it dies, in field units. */
burstRadius?: number;
/** Life of one burst in seconds. */
burstDuration?: number;
/** Fraction of the pointer gap still remaining after one second. */
smoothing?: number;
/** Depth of the orbit-radius breathing, 0..0.5. */
breathe?: number;
/** Overall alpha multiplier. */
opacity?: number;
/** First tint. Any value `ogl`'s `Color` accepts. */
colorA?: string;
/** Second tint; each particle mixes between the two. */
colorB?: string;
/** Colour of the shock front. Only particles the front is passing take it. */
heatColor?: string;
/** Opaque clear colour the field is composited onto. */
background?: string;
/** Seed for the scatter. Same seed, same field every reload. */
seed?: number | string;
className?: string;
}
interface Uniforms {
/** ogl types `uniforms` as an open record; the index signature keeps it assignable. */
[name: string]: { value: unknown };
uResolution: { value: [number, number] };
uPixelRatio: { value: number };
uElapsed: { value: number };
uCursor: { value: [number, number] };
uCursorPresence: { value: number };
uCursorRadius: { value: number };
uRepel: { value: number };
uBreathe: { value: number };
/**
* Three vec4 slots, flat. It MUST be a plain array: ogl walks
* `uBursts[0]`'s name components and falls back to the whole uniform only
* when `Array.isArray(value)` is true. A Float32Array fails that test, and
* the uniform is then silently skipped with a console warning.
*/
uBursts: { value: number[] };
uBurstRadius: { value: number };
uBurstPush: { value: number };
uBurstBand: { value: number };
uColorA: { value: Color };
uColorB: { value: Color };
uHeatColor: { value: Color };
uOpacity: { value: number };
}
interface Stage {
renderer: Renderer;
geometry: Geometry;
program: Program;
mesh: Mesh;
uniforms: Uniforms;
resize: (width: number, height: number) => void;
draw: (elapsed: number) => void;
}
interface BurstSlot {
x: number;
y: number;
/** 0..1 progress. Only meaningful while `active`. */
t: number;
active: boolean;
}
// #region critical
/** Floor on the population. Below this the field reads as a few stray specks. */
const MIN_COUNT = 220;
/** Slots in the burst pool. Three overlapping waves is the most anyone produces. */
const BURST_SLOTS = 3;
/** Width of the Gaussian heat band, in field units. */
const BURST_BAND = 0.085;
/** Fraction of the presence gap left after a second. ~200ms in and out. */
const PRESENCE_SMOOTHING = 0.007;
/** Size distribution exponent. Above 1 crowds the population into small sizes. */
const SIZE_BIAS = 1.8;
/**
* Fill cost scales with dpr squared and these are soft round dots with no fine
* detail, so a third sample per axis resolves nothing a viewer can see.
*/
const MAX_DPR = 1.5;
/** Field-space bounds. Wider than 0..1 so orbits never bare the edges. */
const HOME_MIN = -0.06;
const HOME_MAX = 1.06;
/** The reduced-motion still: a frozen frame with the pointer and one wave in it. */
const STILL_ELAPSED = 6.5;
const STILL_CURSOR: readonly [number, number] = [0.34, 0.56];
const STILL_BURST: readonly [number, number, number] = [0.66, 0.42, 0.42];
export function CursorRepulsionBurst({
density = 1100,
maxCount = 1400,
sizeRange = [1.6, 5.2],
cursorRadius = 0.22,
repel = 0.85,
burstPush = 0.17,
burstRadius = 0.55,
burstDuration = 1.1,
smoothing = 0.0008,
breathe = 0.22,
opacity = 0.9,
colorA = '#8fb6ff',
colorB = '#e6ecf7',
heatColor = '#ff8a3c',
background = '#06070b',
seed = 'cursor-repulsion-burst',
className,
}: CursorRepulsionBurstProps) {
const host = useRef<HTMLDivElement>(null);
const stage = useRef<Stage | null>(null);
const [unsupported, setUnsupported] = useState(false);
const { width, height } = useResizeObserver(host);
const size = useRef({ width, height });
size.current = { width, height };
const reduced = useReducedMotion();
const visible = useVisibility();
const { inView } = useInView(host, { rootMargin: '10%' });
const ready = width > 1 && height > 1;
const [minSize = 1.6, maxSize = 5.2] = sizeRange;
/* Raw pointer target in field space, or null until a real move has happened —
which is not the same as a pointer resting at the bottom-left corner. */
const target = useRef<{ x: number; y: number } | null>(null);
const inside = useRef(false);
/** The damped follower, seeded at the centre so the first move eases outward. */
const follow = useRef({ x: 0.5, y: 0.5 });
const presence = useRef(0);
const slots = useRef<BurstSlot[]>(
Array.from({ length: BURST_SLOTS }, () => ({ x: 0.5, y: 0.5, t: 0, active: false })),
);
const nextSlot = useRef(0);
/* Live numbers the loop reads. Changing one must not rebuild the renderer:
Chromium drops the OLDEST WebGL context past roughly 16 per process, so a
rebuild per prop change can blank an unrelated canvas elsewhere on the page. */
const live = useRef({
cursorRadius,
repel,
burstPush,
burstRadius,
burstDuration,
smoothing,
breathe,
opacity,
});
live.current = {
cursorRadius,
repel,
burstPush,
burstRadius,
burstDuration,
smoothing,
breathe,
opacity,
};
useEffect(() => {
const el = host.current;
if (!el || !ready) return;
if (!hasWebGL()) {
setUnsupported(true);
return;
}
/* ogl creates its own canvas, and it is appended and removed per effect
run. Reusing one React-owned canvas breaks under StrictMode: the first
cleanup force-loses the context, and `getContext` on that same element
afterwards returns the dead one rather than a fresh context — the panel
is then permanently blank in development and fine in production. */
const renderer = new Renderer({
dpr: pixelRatio(MAX_DPR),
// Opaque: the pass is additive, and additive onto a transparent canvas
// would have to brighten the page behind it, which the GPU cannot see.
alpha: false,
// Nothing occludes anything, so a depth buffer is memory plus a clear.
depth: false,
antialias: false,
// Fill-bound, not shader-bound. The discrete GPU buys nothing here.
powerPreference: 'low-power',
});
const gl = renderer.gl;
const canvas = gl.canvas;
const bg = new Color(background);
gl.clearColor(bg.r, bg.g, bg.b, 1);
const quad = new Float32Array([-0.5, -0.5, 0.5, -0.5, 0.5, 0.5, -0.5, 0.5]);
const index = new Uint16Array([0, 1, 2, 0, 2, 3]);
const total = Math.max(MIN_COUNT, Math.round(maxCount));
const homes = new Float32Array(total * 2);
const params = new Float32Array(total * 4);
const seeds = new Float32Array(total * 4);
const rng = createRng(seed);
const lo = Math.max(0.5, Math.min(minSize, maxSize));
const hi = Math.max(lo + 0.25, Math.max(minSize, maxSize));
for (let i = 0; i < total; i += 1) {
homes[i * 2] = randRange(rng, HOME_MIN, HOME_MAX);
homes[i * 2 + 1] = randRange(rng, HOME_MIN, HOME_MAX);
// Biased small: a linear distribution puts a quarter of the population in
// the largest quarter of the range, and the field reads as gravel.
const sizeT = Math.pow(rng(), SIZE_BIAS);
params[i * 4] = lo + (hi - lo) * sizeT;
params[i * 4 + 1] = randRange(rng, 0.004, 0.03);
// Signed rate with a floor on the magnitude: a particle orbiting at
// 0.001 rad/s is a particle that is simply not moving.
params[i * 4 + 2] = (rng() < 0.5 ? -1 : 1) * randRange(rng, 0.12, 0.46);
// Bigger particles are dimmer, so total light stays roughly constant.
params[i * 4 + 3] = 0.34 + 0.5 * (1 - sizeT);
seeds[i * 4] = rng() * Math.PI * 2;
// Burst magnitude. Per-particle, so the wave has a ragged front rather
// than a clean expanding circle — a clean circle reads as a UI ripple.
seeds[i * 4 + 1] = rng();
seeds[i * 4 + 2] = randRange(rng, 0.35, 1);
seeds[i * 4 + 3] = rng();
}
const geometry = new Geometry(gl, {
index: { data: index },
position: { size: 2, data: quad },
aHome: { size: 2, data: homes, instanced: 1 },
aParams: { size: 4, data: params, instanced: 1 },
aSeed: { size: 4, data: seeds, instanced: 1 },
});
const uniforms: Uniforms = {
uResolution: { value: [1, 1] },
uPixelRatio: { value: renderer.dpr },
uElapsed: { value: 0 },
uCursor: { value: [0.5, 0.5] },
uCursorPresence: { value: 0 },
uCursorRadius: { value: cursorRadius },
uRepel: { value: repel },
uBreathe: { value: clamp(breathe, 0, 0.5) },
uBursts: { value: new Array<number>(BURST_SLOTS * 4).fill(0) },
uBurstRadius: { value: burstRadius },
uBurstPush: { value: burstPush },
uBurstBand: { value: BURST_BAND },
uColorA: { value: new Color(colorA) },
uColorB: { value: new Color(colorB) },
uHeatColor: { value: new Color(heatColor) },
uOpacity: { value: opacity },
};
const program = new Program(gl, {
vertex: CURSOR_REPULSION_BURST_VERT,
fragment: CURSOR_REPULSION_BURST_FRAG,
uniforms,
transparent: true,
depthTest: false,
depthWrite: false,
// ogl culls BACK faces by default and the quad's winding is not
// guaranteed to face the way the driver expects. A culled particle is
// not dim — it is absent.
cullFace: false,
});
// Additive, overriding the source-over that `transparent: true` selected.
// Order independent, so a thousand overlapping instances need no sort,
// which is what makes one draw call possible at all.
program.setBlendFunc(gl.ONE, gl.ONE);
const mesh = new Mesh(gl, { geometry, program });
el.appendChild(canvas);
canvas.style.display = 'block';
/* Listeners on the CANVAS, not the window. A document-level handler
outlives the renderer it was written for, and the rect it measures is not
the rect being drawn into — after a scroll the hole sits at an offset
from the cursor. Passive, because nothing here calls preventDefault and a
non-passive move handler makes the compositor wait for JS on every event. */
const toField = (event: PointerEvent) => {
const rect = canvas.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return null;
return {
x: (event.clientX - rect.left) / rect.width,
// Flipped: field y is up, client y is down.
y: 1 - (event.clientY - rect.top) / rect.height,
};
};
const onMove = (event: PointerEvent) => {
const point = toField(event);
if (!point) return;
target.current = point;
inside.current = true;
};
const onDown = (event: PointerEvent) => {
const point = toField(event);
if (!point) return;
// A press is also a position: on touch there is no hover, so without this
// the wave would fire from wherever the pointer last happened to be.
target.current = point;
inside.current = true;
const slot = slots.current[nextSlot.current % BURST_SLOTS];
if (!slot) return;
nextSlot.current = (nextSlot.current + 1) % BURST_SLOTS;
slot.x = point.x;
slot.y = point.y;
slot.t = 0;
slot.active = true;
};
const onLeave = () => {
// Position is left where it is. Tweening it back to the centre drags a
// visible furrow across the field on the way out.
inside.current = false;
};
canvas.addEventListener('pointermove', onMove, { passive: true });
canvas.addEventListener('pointerdown', onDown, { passive: true });
canvas.addEventListener('pointerleave', onLeave, { passive: true });
canvas.addEventListener('pointercancel', onLeave, { passive: true });
const resize = (w: number, h: number) => {
renderer.setSize(w, h);
// `setSize` writes literal px into the canvas's inline style, which wins
// over any class. Re-asserting percentages keeps it fluid between
// resize notifications.
canvas.style.width = '100%';
canvas.style.height = '100%';
uniforms.uResolution.value = [gl.drawingBufferWidth, gl.drawingBufferHeight];
uniforms.uPixelRatio.value = renderer.dpr;
// Population from area, not a fixed number: the same density reads right
// in a 320px card and a 2000px hero. The buffers already hold `total`
// particles in scattered order, so a prefix of them is still spatially
// even — no rebuild, no reallocation, no new GPU memory.
const megapixels = (w * h) / 1_000_000;
const count = clamp(Math.round(megapixels * density), MIN_COUNT, total);
geometry.setInstancedCount(count);
};
const draw = (elapsed: number) => {
uniforms.uElapsed.value = elapsed;
renderer.render({ scene: mesh });
};
resize(size.current.width, size.current.height);
stage.current = { renderer, geometry, program, mesh, uniforms, resize, draw };
draw(0);
return () => {
stage.current = null;
canvas.removeEventListener('pointermove', onMove);
canvas.removeEventListener('pointerdown', onDown);
canvas.removeEventListener('pointerleave', onLeave);
canvas.removeEventListener('pointercancel', onLeave);
// ogl has no `dispose()`: `remove()` on the geometry deletes its VAOs and
// every buffer, `remove()` on the program deletes the shaders and the
// program, and only then is the context worth handing back. Dropping the
// context is the part that matters — the browser's per-process budget is
// about sixteen, and a lost one takes an unrelated canvas with it.
geometry.remove();
program.remove();
loseContext(gl);
canvas.width = 0;
canvas.height = 0;
canvas.remove();
};
}, [ready, density, maxCount, minSize, maxSize, seed]);
// Colours are pushed into the live uniforms rather than being effect deps, so
// recolouring never costs a context.
useEffect(() => {
const s = stage.current;
if (!s) return;
s.uniforms.uColorA.value.set(colorA);
s.uniforms.uColorB.value.set(colorB);
s.uniforms.uHeatColor.value.set(heatColor);
const bg = new Color(background);
s.renderer.gl.clearColor(bg.r, bg.g, bg.b, 1);
}, [colorA, colorB, heatColor, background]);
// Resize is its own effect: a reflow costs a `setSize` and an instanced-count
// change, never a buffer rebuild.
useEffect(() => {
stage.current?.resize(width, height);
}, [width, height]);
useRaf(
(dt, elapsed) => {
const s = stage.current;
if (!s) return;
const cfg = live.current;
const aim = target.current;
if (aim) {
follow.current.x = damp(follow.current.x, aim.x, cfg.smoothing, dt);
follow.current.y = damp(follow.current.y, aim.y, cfg.smoothing, dt);
}
presence.current = damp(
presence.current,
inside.current && aim !== null ? 1 : 0,
PRESENCE_SMOOTHING,
dt,
);
// Bursts advance on wall-clock seconds, not on frames, so a wave lasts
// `burstDuration` on a 30Hz phone and on a 144Hz monitor alike.
const flat = s.uniforms.uBursts.value;
const life = Math.max(cfg.burstDuration, 0.05);
let i = 0;
for (const slot of slots.current) {
if (slot.active) {
slot.t += dt / life;
if (slot.t >= 1) {
slot.t = 1;
slot.active = false;
}
}
flat[i * 4] = slot.x;
flat[i * 4 + 1] = slot.y;
flat[i * 4 + 2] = slot.t;
flat[i * 4 + 3] = slot.active ? 1 : 0;
i += 1;
}
const u = s.uniforms;
// Mutated in place. Assigning a fresh tuple allocates two arrays a frame
// and defeats ogl's redundant-uniform cache, which compares by length
// first and then element by element against the array it kept.
u.uCursor.value[0] = follow.current.x;
u.uCursor.value[1] = follow.current.y;
u.uCursorPresence.value = presence.current;
u.uCursorRadius.value = cfg.cursorRadius;
u.uRepel.value = cfg.repel;
u.uBurstPush.value = cfg.burstPush;
u.uBurstRadius.value = cfg.burstRadius;
u.uBreathe.value = clamp(cfg.breathe, 0, 0.5);
u.uOpacity.value = cfg.opacity;
s.draw(elapsed);
},
ready && inView && visible && !reduced && !unsupported,
);
/* Reduced motion is a composed still, not an empty box: one frame with the
pointer parked off-centre and a single wave frozen part-way out, so the
panel shows what the effect produces. The listeners stay attached and their
target is never consumed, so moving or clicking changes nothing. */
useEffect(() => {
const s = stage.current;
if (!s || !reduced || !ready) return;
const [cx = 0.34, cy = 0.56] = STILL_CURSOR;
const [bx = 0.66, by = 0.42, bt = 0.42] = STILL_BURST;
s.uniforms.uCursor.value[0] = cx;
s.uniforms.uCursor.value[1] = cy;
s.uniforms.uCursorPresence.value = 1;
const flat = s.uniforms.uBursts.value;
flat.fill(0);
flat[0] = bx;
flat[1] = by;
flat[2] = bt;
flat[3] = 1;
s.draw(STILL_ELAPSED);
}, [reduced, ready, width, height]);
// #endregion critical
return (
<div ref={host} className={cn('relative h-full w-full overflow-hidden bg-bg', className)}>
{unsupported ? (
<div
aria-hidden
className="h-full w-full bg-bg"
style={{
backgroundImage:
'radial-gradient(circle at 50% 50%, var(--k-line-strong) 1px, transparent 0), radial-gradient(circle at 50% 50%, var(--k-line) 1px, transparent 0)',
backgroundSize: '34px 34px, 61px 57px',
backgroundPosition: '0 0, 17px 21px',
}}
/>
) : null}
</div>
);
}
/* ==========================================================================
Cursor Repulsion Burst — GLSL
--------------------------------------------------------------------------
One instanced quad per particle, and three displacements stacked on top of
each other in field space before anything reaches clip space:
1. an idle orbit, so the field is alive with no input at all;
2. cursor repulsion with a linear falloff, so the pointer opens a hole;
3. up to three concurrent shock waves, each with a per-particle magnitude
and a Gaussian heat band at its front.
Everything is measured in ASPECT-CORRECTED field space (x multiplied by the
aspect ratio) and converted back before it becomes a position. Skip that and
the repulsion hole is an ellipse on any container that is not square, and the
shock ring arrives at the left and right edges before it reaches the top.
GLSL ES 1.00, so the same source compiles under a WebGL1 context.
========================================================================== */
/**
* position — the shared unit quad, -0.5..0.5 on both axes.
* aHome — rest position in field space; 0..1 covers the container.
* aParams — x: size in CSS px, y: orbit radius in field-height units,
* z: orbit rate in rad/s (signed), w: base alpha.
* aSeed — x: orbit phase, y: burst magnitude 0..1, z: edge softness,
* w: tint mix between the two colours.
*/
export const CURSOR_REPULSION_BURST_VERT = /* glsl */ `
attribute vec2 position;
attribute vec2 aHome;
attribute vec4 aParams;
attribute vec4 aSeed;
uniform vec2 uResolution;
uniform float uPixelRatio;
uniform float uElapsed;
uniform vec2 uCursor;
uniform float uCursorPresence;
uniform float uCursorRadius;
uniform float uRepel;
uniform float uBreathe;
uniform vec4 uBursts[3];
uniform float uBurstRadius;
uniform float uBurstPush;
uniform float uBurstBand;
varying vec2 vQuad;
varying float vAlpha;
varying float vSoftness;
varying float vHeat;
varying float vTint;
/**
* Direction toward v, with the magnitude rolled off inside floorLen instead
* of normalised. A true normalize() is undefined at the origin and violently
* unstable just outside it: a particle sitting exactly under the cursor would
* pick a new direction every frame from floating-point noise and buzz. Dividing
* by a floor makes the vector shorter than unit length near the centre, so the
* push fades in with distance and the direction stays whatever it already was.
*/
vec2 safeDir(vec2 v, float floorLen) {
return v / max(length(v), floorLen);
}
void main() {
float aspect = uResolution.x / max(uResolution.y, 1.0);
vec2 stretch = vec2(aspect, 1.0);
/* 1. IDLE ORBIT. Each particle circles its own home on its own phase and its
own signed rate, so the field never syncs up into a visible pulse. The
radius breathes on a second, slower sinusoid — a constant radius reads as
machinery, a breathing one reads as drift. Dividing x by the aspect ratio
is what keeps the orbit a circle rather than an ellipse. */
float phase = aSeed.x;
float breath = 1.0 - uBreathe + uBreathe * sin(uElapsed * 0.42 + phase * 3.0);
float angle = uElapsed * aParams.z + phase;
vec2 orbit = vec2(cos(angle), sin(angle)) * aParams.y * breath;
orbit.x /= max(aspect, 0.0001);
vec2 p = aHome + orbit;
/* 2. CURSOR REPULSION. Linear inside the radius, exactly zero outside it, so
the field beyond the pointer is untouched and the cost of the whole idea is
one length() per particle. A smoothstep here would look softer and would
also mean every particle on screen is always being displaced a little,
which reads as the whole field sagging toward the cursor. */
vec2 toCursor = (p - uCursor) * stretch;
float cursorDist = length(toCursor);
float push = max(uCursorRadius - cursorDist, 0.0) * uRepel * uCursorPresence;
p += safeDir(toCursor, uCursorRadius * 0.15) * push / stretch;
/* Particles inside the radius also brighten, which is what stops the hole
from reading as a bald patch: the field gets denser at the rim. */
float near = 1.0 - smoothstep(0.0, uCursorRadius, cursorDist);
/* 3. BURSTS. Three slots, so a fast triple-click produces three overlapping
waves instead of one wave restarting from zero twice. w is the active flag;
multiplying by it rather than branching keeps every lane of the warp doing
the same work, which is cheaper here than the branch it replaces. */
float heat = 0.0;
for (int i = 0; i < 3; i++) {
vec4 slot = uBursts[i];
float on = step(0.5, slot.w);
float t = clamp(slot.z, 0.0, 1.0);
vec2 fromOrigin = (p - slot.xy) * stretch;
float originDist = length(fromOrigin);
/* Cubic ease-out: almost all of the travel happens in the first third of
the life, which is what a shock front does. A linear ramp reads as a
scripted move, and an ease-in reads as an elevator. */
float ease = 1.0 - pow(1.0 - t, 3.0);
/* ...and then the displacement is walked back to zero by t = 1, so the slot
can be deactivated without every particle in it snapping home. */
float settle = 1.0 - smoothstep(0.5, 1.0, t);
/* Only the neighbourhood of the origin is thrown. Without this the burst
displaces the entire field uniformly, which reads as the camera jolting. */
float reach = 1.0 - smoothstep(0.0, uBurstRadius, originDist);
float magnitude = (0.25 + aSeed.y * 0.75) * uBurstPush * ease * settle * reach * on;
p += safeDir(fromOrigin, uBurstRadius * 0.05) * magnitude / stretch;
/* The heat band. The shock radius expands on the same ease, and a Gaussian
in (distance - shockRadius) lights only the particles the front is
passing through right now. A flat tint over the whole burst would just
recolour the field; a band reads as something travelling through it. */
float band = (originDist - uBurstRadius * ease) / max(uBurstBand, 0.0001);
heat += exp(-band * band) * (1.0 - t) * on;
}
vHeat = clamp(heat, 0.0, 1.0);
/* The quad is expanded in PIXELS and only then converted to clip space, so a
particle is square in a container of any aspect ratio. */
float size = aParams.x * uPixelRatio;
vec2 clip = p * 2.0 - 1.0;
vec2 offset = position * size / max(uResolution, vec2(1.0)) * 2.0;
vQuad = position;
vSoftness = aSeed.z;
vTint = aSeed.w;
vAlpha = aParams.w * (0.62 + 0.38 * near * uCursorPresence);
gl_Position = vec4(clip + offset, 0.0, 1.0);
}
`;
export const CURSOR_REPULSION_BURST_FRAG = /* glsl */ `
precision mediump float;
uniform vec3 uColorA;
uniform vec3 uColorB;
uniform vec3 uHeatColor;
uniform float uOpacity;
varying vec2 vQuad;
varying float vAlpha;
varying float vSoftness;
varying float vHeat;
varying float vTint;
void main() {
/* A disc with a VARIABLE core: the flat centre shrinks as softness rises, so
the same geometry covers hard specks and soft motes without a second
material. A fixed core plus a variable edge cannot do the soft end — the
particle keeps a visible hard middle no matter how far the edge is pushed. */
float d = length(vQuad);
float core = mix(0.34, 0.02, clamp(vSoftness, 0.0, 1.0));
float falloff = 1.0 - smoothstep(core, 0.5, d);
if (falloff <= 0.0) discard;
vec3 col = mix(uColorA, uColorB, clamp(vTint, 0.0, 1.0));
col = mix(col, uHeatColor, vHeat);
/* Squared, because additive blending sums overlapping particles and a linear
falloff piles up into visible bright polygons where several quads cross. */
float alpha = falloff * falloff * vAlpha * uOpacity;
/* Premultiplied for an additive pass: the destination is only ever added to,
so the colour has to arrive already scaled by its own coverage. */
gl_FragColor = vec4(col * alpha, alpha);
}
`;
/* ==========================================================================
Frame-rate-independent interpolation.
--------------------------------------------------------------------------
The everywhere-idiom `x += (target - x) * 0.1` is a bug: it converges twice
as fast on a 120 Hz display as on a 60 Hz one, so the same code feels
different on different hardware and visibly speeds up when the browser
drops frames. `damp` fixes it by raising the retention factor to the power
of elapsed time, which is the exact continuous form of the same curve.
========================================================================== */
/**
* Fraction of the remaining distance to close this frame.
*
* @param smoothing Fraction of the gap that REMAINS after one second.
* Smaller is snappier. 0.001 ≈ very fast, 0.01 ≈ fast, 0.1 ≈ soft,
* 0.5 ≈ slow drift.
* @param dt Seconds elapsed since the previous frame.
*/
export function dampFactor(smoothing: number, dt: number): number {
return 1 - Math.pow(smoothing, dt);
}
/** Move `current` toward `target` at a rate independent of frame rate. */
export function damp(
current: number,
target: number,
smoothing: number,
dt: number,
): number {
return current + (target - current) * dampFactor(smoothing, dt);
}
/**
* The per-frame lerp constant an author measured at 60fps, converted into the
* `smoothing` argument `damp` wants. Lets a source that says "0.1 per frame"
* be ported without re-tuning by eye:
*
* damp(x, t, perFrameToSmoothing(0.1), dt)
*/
export function perFrameToSmoothing(alpha: number, baseFps = 60): number {
return Math.pow(1 - alpha, baseFps);
}
export function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
export function clamp(v: number, min: number, max: number): number {
return v < min ? min : v > max ? max : v;
}
export function inverseLerp(a: number, b: number, v: number): number {
return a === b ? 0 : (v - a) / (b - a);
}
export function mapRange(
v: number,
inMin: number,
inMax: number,
outMin: number,
outMax: number,
doClamp = true,
): number {
const t = inverseLerp(inMin, inMax, v);
const r = lerp(outMin, outMax, doClamp ? clamp(t, 0, 1) : t);
return r;
}
/** GLSL `smoothstep`, same semantics, for parity between JS and shader code. */
export function smoothstep(edge0: number, edge1: number, x: number): number {
const t = clamp(inverseLerp(edge0, edge1, x), 0, 1);
return t * t * (3 - 2 * t);
}
/**
* Gaussian falloff, `exp(-d²)`. Yields 1.000 / 0.368 / 0.018 / 0.0001 at
* distances 0 / 1 / 2 / 3 — a continuous focus scalar with no thresholds and
* no branches, which is why carousels that use it feel analogue rather than
* stepped.
*/
export function gaussian(distance: number, falloff = 1): number {
const d = distance * falloff;
return Math.exp(-(d * d));
}
/* ==========================================================================
Seeded randomness.
--------------------------------------------------------------------------
`Math.random()` in a particle field means the server and the client
disagree, which React reports as a hydration mismatch, and it means the
layout is different on every reload so a visual regression is invisible.
Every component that scatters something uses a seeded generator instead.
========================================================================== */
export type Rng = () => number;
/**
* mulberry32 — 32-bit, fast, and good enough for visual scatter. Returns a
* function yielding floats in [0, 1).
*/
export function mulberry32(seed: number): Rng {
let a = seed >>> 0;
return function next() {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** Turn any string into a 32-bit seed, so `seed="hero"` is reproducible. */
export function hashSeed(input: string): number {
let h = 2166136261 >>> 0;
for (let i = 0; i < input.length; i++) {
h ^= input.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return h >>> 0;
}
export function createRng(seed: number | string): Rng {
return mulberry32(typeof seed === 'string' ? hashSeed(seed) : seed);
}
export function randRange(rng: Rng, min: number, max: number): number {
return min + rng() * (max - min);
}
export function randInt(rng: Rng, min: number, maxExclusive: number): number {
return Math.floor(randRange(rng, min, maxExclusive));
}
/**
* Uniform point on a unit sphere. Naive angle-pairing clusters at the poles;
* this inverts the cosine so density is even.
*/
export function randOnSphere(rng: Rng): [number, number, number] {
const u = rng() * 2 - 1;
const theta = rng() * Math.PI * 2;
const r = Math.sqrt(1 - u * u);
return [r * Math.cos(theta), r * Math.sin(theta), u];
}
/** Uniform point in a unit disc — `sqrt` prevents crowding at the centre. */
export function randInDisc(rng: Rng): [number, number] {
const r = Math.sqrt(rng());
const theta = rng() * Math.PI * 2;
return [r * Math.cos(theta), r * Math.sin(theta)];
}
/** In-place Fisher-Yates using the supplied generator. */
export function shuffle<T>(rng: Rng, arr: T[]): T[] {
for (let i = arr.length - 1; i > 0; i--) {
const j = randInt(rng, 0, i + 1);
const a = arr[i] as T;
const b = arr[j] as T;
arr[i] = b;
arr[j] = a;
}
return arr;
}
'use client';
import { useEffect, useRef, useState, type RefObject } from 'react';
/* ==========================================================================
IntersectionObserver, sharing one observer per option set.
--------------------------------------------------------------------------
A hundred cards each constructing their own observer is a hundred observers
the compositor has to service. Keying a shared observer by its options
collapses that to one per distinct configuration — in practice, two or
three for the whole site.
========================================================================== */
export interface InViewState {
inView: boolean;
/** 0-1. The preview slot allocator ranks candidates by this. */
ratio: number;
}
export interface UseInViewOptions {
root?: Element | null;
rootMargin?: string;
/** Single number or list, same meaning as the native option. */
threshold?: number | number[];
/** Stop observing after the first time it becomes visible. */
once?: boolean;
}
type Entry = {
observer: IntersectionObserver;
handlers: Map<Element, (e: IntersectionObserverEntry) => void>;
};
const pools = new Map<string, Entry>();
function poolFor(options: UseInViewOptions): Entry {
const key = JSON.stringify({
rootMargin: options.rootMargin ?? '0px',
threshold: options.threshold ?? 0,
});
const existing = pools.get(key);
if (existing) return existing;
const handlers = new Map<Element, (e: IntersectionObserverEntry) => void>();
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) handlers.get(entry.target)?.(entry);
},
{
root: options.root ?? null,
rootMargin: options.rootMargin ?? '0px',
threshold: options.threshold ?? 0,
},
);
const created: Entry = { observer, handlers };
pools.set(key, created);
return created;
}
export function useInView(
ref: RefObject<Element | null>,
options: UseInViewOptions = {},
): InViewState {
const [state, setState] = useState<InViewState>({ inView: false, ratio: 0 });
const { root = null, rootMargin = '0px', once = false } = options;
const thresholdKey = JSON.stringify(options.threshold ?? 0);
const done = useRef(false);
useEffect(() => {
const el = ref.current;
if (!el || typeof IntersectionObserver === 'undefined') return;
if (once && done.current) return;
const pool = poolFor({
root,
rootMargin,
threshold: JSON.parse(thresholdKey) as number | number[],
});
const handle = (entry: IntersectionObserverEntry) => {
setState({ inView: entry.isIntersecting, ratio: entry.intersectionRatio });
if (once && entry.isIntersecting) {
done.current = true;
pool.handlers.delete(el);
pool.observer.unobserve(el);
}
};
pool.handlers.set(el, handle);
pool.observer.observe(el);
return () => {
pool.handlers.delete(el);
pool.observer.unobserve(el);
};
}, [ref, root, rootMargin, thresholdKey, once]);
return state;
}
'use client';
import { useEffect, useRef } from 'react';
/* ==========================================================================
One shared requestAnimationFrame loop.
--------------------------------------------------------------------------
Forty components each calling requestAnimationFrame means forty callbacks
the browser schedules separately and forty chances to read layout after a
write. One loop, one ordered pass, one place to clamp delta time.
Delta is clamped to 100 ms. Without that, returning to a backgrounded tab
delivers a multi-second dt and every physics integration in the page
explodes on the first frame back.
========================================================================== */
export type RafCallback = (dt: number, elapsed: number) => void;
const callbacks = new Set<RafCallback>();
let frame = 0;
let last = 0;
let start = 0;
const MAX_DT = 0.1;
const DEFAULT_DT = 1 / 60;
function tick(now: number) {
frame = requestAnimationFrame(tick);
const dt = last === 0 ? DEFAULT_DT : Math.min((now - last) / 1000, MAX_DT);
last = now;
const elapsed = (now - start) / 1000;
// Copy: a callback may unsubscribe itself (or another) mid-iteration.
for (const cb of Array.from(callbacks)) {
if (callbacks.has(cb)) cb(dt, elapsed);
}
}
function subscribe(cb: RafCallback) {
callbacks.add(cb);
if (frame === 0) {
start = performance.now();
last = 0;
frame = requestAnimationFrame(tick);
}
return () => {
callbacks.delete(cb);
if (callbacks.size === 0 && frame !== 0) {
cancelAnimationFrame(frame);
frame = 0;
last = 0;
}
};
}
/**
* Run `cb` every frame while `enabled`.
*
* The callback is held in a ref, so it does not need to be memoised and the
* subscription is never torn down just because the closure changed identity.
*/
export function useRaf(cb: RafCallback, enabled = true): void {
const ref = useRef(cb);
ref.current = cb;
useEffect(() => {
if (!enabled) return;
return subscribe((dt, elapsed) => ref.current(dt, elapsed));
}, [enabled]);
}
'use client';
import { useSyncExternalStore } from 'react';
/* ==========================================================================
prefers-reduced-motion, SSR-safe.
--------------------------------------------------------------------------
useSyncExternalStore rather than useState + useEffect: the server snapshot
is an explicit `false`, so the markup React renders on the server and the
markup it hydrates with agree, and the real value lands in the same commit
as hydration instead of one frame later. A useEffect version flashes the
animated variant before removing it — the exact thing the user asked not to
see.
Reduced motion is not "duration: 0". For most components here it means a
different render: the final state, a static poster, or a paused frame.
========================================================================== */
const QUERY = '(prefers-reduced-motion: reduce)';
function subscribe(onChange: () => void) {
if (typeof window === 'undefined' || !window.matchMedia) return () => {};
const mql = window.matchMedia(QUERY);
mql.addEventListener('change', onChange);
return () => mql.removeEventListener('change', onChange);
}
function getSnapshot() {
if (typeof window === 'undefined' || !window.matchMedia) return false;
return window.matchMedia(QUERY).matches;
}
function getServerSnapshot() {
return false;
}
export function useReducedMotion(): boolean {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
/** Generic media-query hook, same SSR guarantees. */
export function useMediaQuery(query: string): boolean {
return useSyncExternalStore(
(onChange) => {
if (typeof window === 'undefined' || !window.matchMedia) return () => {};
const mql = window.matchMedia(query);
mql.addEventListener('change', onChange);
return () => mql.removeEventListener('change', onChange);
},
() => {
if (typeof window === 'undefined' || !window.matchMedia) return false;
return window.matchMedia(query).matches;
},
() => false,
);
}
'use client';
import { useEffect, useState, type RefObject } from 'react';
/* ==========================================================================
Element size, from one shared ResizeObserver.
--------------------------------------------------------------------------
Sized from the element rather than the window because a preview in a grid
cell resizes when the grid reflows, not when the viewport does. Reading
`window.innerWidth` is the reason so many canvas components are correct on
load and wrong after a sidebar opens.
========================================================================== */
export interface Size {
width: number;
height: number;
}
type Handler = (size: Size) => void;
let observer: ResizeObserver | null = null;
const handlers = new Map<Element, Handler>();
function ensureObserver(): ResizeObserver | null {
if (typeof ResizeObserver === 'undefined') return null;
observer ??= new ResizeObserver((entries) => {
for (const entry of entries) {
const box = entry.contentBoxSize?.[0];
const size: Size = box
? { width: box.inlineSize, height: box.blockSize }
: { width: entry.contentRect.width, height: entry.contentRect.height };
handlers.get(entry.target)?.(size);
}
});
return observer;
}
export function useResizeObserver(ref: RefObject<Element | null>): Size {
const [size, setSize] = useState<Size>({ width: 0, height: 0 });
useEffect(() => {
const el = ref.current;
const ro = ensureObserver();
if (!el || !ro) return;
// Seed synchronously: waiting for the first observer callback leaves one
// frame where a canvas is sized 0x0, which some drivers log as an error.
const rect = el.getBoundingClientRect();
setSize({ width: rect.width, height: rect.height });
handlers.set(el, (next) =>
setSize((prev) =>
prev.width === next.width && prev.height === next.height ? prev : next,
),
);
ro.observe(el);
return () => {
handlers.delete(el);
ro.unobserve(el);
};
}, [ref]);
return size;
}
'use client';
import { useSyncExternalStore } from 'react';
/* ==========================================================================
Document visibility, shared.
--------------------------------------------------------------------------
A backgrounded tab still runs `setInterval`, still holds WebGL contexts,
and — on some platforms — still gets throttled rAF ticks with multi-second
deltas. Anything expensive should stop when the tab is hidden and resume
when it comes back, rather than trying to catch up.
`useSyncExternalStore` with an explicit server snapshot so SSR markup and
the first client render agree.
========================================================================== */
function subscribe(onChange: () => void): () => void {
document.addEventListener('visibilitychange', onChange);
return () => document.removeEventListener('visibilitychange', onChange);
}
function getSnapshot(): boolean {
return document.visibilityState === 'visible';
}
/** Assume visible on the server: the alternative flashes a paused state. */
function getServerSnapshot(): boolean {
return true;
}
export function useVisibility(): boolean {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
import type * as THREE from 'three';
/* ==========================================================================
WebGL context lifecycle.
--------------------------------------------------------------------------
Chromium allows roughly 16 live WebGL contexts per RENDERER PROCESS (8 on
low-end devices). Creating the 17th does not fail — it fires
`webglcontextlost` on the OLDEST one, so an unrelated component elsewhere
on the page goes black. Same-origin iframes share the renderer process, so
isolating previews in iframes does not raise the ceiling.
The only real fix is to release contexts on unmount, and `renderer.dispose()`
ALONE DOES NOT DO THAT. It frees GPU objects but leaves the context itself
alive until GC decides otherwise, which may be never. `forceContextLoss()`
is the call that actually hands it back.
========================================================================== */
/**
* Fully release a three.js renderer. Call this in the `useEffect` cleanup of
* any component that constructs a `WebGLRenderer` — the preview host drives
* unmount via `<Activity mode="hidden">`, so ordinary cleanup IS the disposal
* path and nothing component-specific is required.
*/
export function disposeRenderer(renderer: THREE.WebGLRenderer | null | undefined): void {
if (!renderer) return;
// Stop the loop first: a queued frame that renders into a lost context
// throws, and the throw escapes the cleanup function.
renderer.setAnimationLoop(null);
renderer.dispose();
renderer.forceContextLoss();
const canvas = renderer.domElement;
if (canvas) {
// Zeroing the backing store releases the drawing buffer immediately
// instead of holding width×height×4 bytes until the node is collected.
canvas.width = 0;
canvas.height = 0;
}
}
/**
* Release a raw WebGL context (ogl, or hand-written GL). The extension is the
* only portable way to do it; it is absent in some headless configurations,
* hence the optional call.
*/
export function loseContext(gl: WebGLRenderingContext | WebGL2RenderingContext | null | undefined): void {
if (!gl) return;
gl.getExtension('WEBGL_lose_context')?.loseContext();
}
/** Recursively dispose geometries, materials and textures under a root. */
export function disposeObject(root: THREE.Object3D | null | undefined): void {
if (!root) return;
root.traverse((obj) => {
const mesh = obj as THREE.Mesh;
mesh.geometry?.dispose?.();
const material = mesh.material;
if (!material) return;
const materials = Array.isArray(material) ? material : [material];
for (const m of materials) {
for (const value of Object.values(m as unknown as Record<string, unknown>)) {
if (value && typeof value === 'object' && 'isTexture' in value) {
(value as THREE.Texture).dispose();
}
}
m.dispose?.();
}
});
}
/**
* Device pixel ratio capped for fill-rate. Above 2 the visual gain is nil and
* the cost is quadratic; on a 3x phone an uncapped full-screen shader is the
* single most common cause of a janky first paint.
*/
export function pixelRatio(max = 2): number {
if (typeof window === 'undefined') return 1;
return Math.min(window.devicePixelRatio || 1, max);
}
/**
* Whether WebGL is actually available. Some browsers expose the constructor
* but fail to create a context (blocklisted GPU, no hardware acceleration);
* a component that assumes success renders a permanently blank box.
*/
export function hasWebGL(): boolean {
if (typeof document === 'undefined') return false;
try {
const canvas = document.createElement('canvas');
const gl =
canvas.getContext('webgl2') ??
canvas.getContext('webgl') ??
canvas.getContext('experimental-webgl');
if (!gl) return false;
(gl as WebGLRenderingContext).getExtension('WEBGL_lose_context')?.loseContext();
return true;
} catch {
return false;
}
}
API
Every parameter, its type and what happens when you change it.
function CursorRepulsionBurst(props: { density?: number; maxCount?: number; sizeRange?: readonly [number, number]; cursorRadius?: number; repel?: number; burstPush?: number; burstRadius?: number; burstDuration?: number; smoothing?: number; breathe?: number; opacity?: number; colorA?: string; colorB?: string; heatColor?: string; background?: string; seed?: number | string; className?: string }): JSX.Element| Parameter | Type | Default | Description |
|---|---|---|---|
| density | number | = 1100 | Particles per megapixel of container area. |
| maxCount | number | = 1400 | Population ceiling, and the size the buffers are allocated at. |
| sizeRange | readonly [number, number] | = [1.6, 5.2] | Particle size in CSS pixels, biased toward the small end. |
| cursorRadius | number | = 0.22 | Repulsion radius in field units, where 1 is the container height. |
| repel | number | = 0.85 | Displacement at the centre of the repulsion field, as a fraction of its radius. |
| burstPush | number | = 0.17 | Peak burst displacement in field units, before the per-particle spread. |
| burstRadius | number | = 0.55 | How far a shock front travels before it dies. |
| burstDuration | number | = 1.1 | Life of one burst in seconds. |
| smoothing | number | = 0.0008 | Fraction of the pointer gap still remaining after one second. |
| breathe | number | = 0.22 | Depth of the orbit-radius breathing, 0 to 0.5. |
| opacity | number | = 0.9 | Overall alpha multiplier. |
| colorA | string | = '#8fb6ff' | First tint. Any value ogl's Color accepts. |
| colorB | string | = '#e6ecf7' | Second tint; each particle mixes between the two. |
| heatColor | string | = '#ff8a3c' | Colour of the shock front. |
| background | string | = '#06070b' | Opaque clear colour the additive field is composited onto. |
| seed | number | string | = 'cursor-repulsion-burst' | Seeds the scatter, sizes, phases and burst magnitudes. |
| className | string | — | Merged onto the root container via cn. |
How it works
The technique in order, not the code in order.
- 01
Three displacements are stacked in field space in the vertex shader before anything becomes a position: an idle orbit, cursor repulsion, then up to three shock waves. Each stage reads the position the previous one produced, so a particle already pushed aside by the cursor is the particle the wave throws.
- 02
All distances are measured in aspect-corrected space — x multiplied by
uResolution.x / uResolution.y— and the resulting displacement is divided back out before it is added to the position. Without the correction the repulsion hole is an ellipse on any container that is not square, and the shock ring reaches the side edges before the top. - 03
Repulsion is
max(radius - distance, 0) * repel: linear inside the radius and exactly zero outside it, so the field beyond the pointer is untouched and the whole idea costs onelength()per particle. Asmoothstepwould be softer but would also mean every particle on screen is permanently displaced a little, which reads as the field sagging toward the cursor. - 04
The direction is
v / max(length(v), floor)rather thannormalize(v). A true normalize is undefined at the origin and unstable just outside it, so the particle sitting under the cursor picks a new direction every frame from floating-point noise and buzzes. Dividing by a floor makes the vector shorter than unit length near the centre, so the push fades in and the direction stays put. - 05
A burst slot is
vec4(originX, originY, progress, active). The displacement is(0.25 + seed * 0.75) * push * easeOutCubic(t) * settle(t) * reach(d), wheresettlewalks the whole thing back to zero by t = 1 so the slot can be freed without the field snapping home, andreachlimits the throw to the neighbourhood of the origin so the burst is not a camera jolt. - 06
The heat band is
exp(-b * b)whereb = (distance - burstRadius * ease) / band. The shock radius expands on the same cubic ease as the displacement, so only the particles the front is passing through right now take the tint. A flat tint over the whole burst just recolours the field; a Gaussian band travelling through it reads as heat. - 07
Only
activeandprogresschange per frame per slot, so a burst costs three vec4 uniforms and a three-iteration loop — no per-particle CPU state and no buffer upload.
Load-bearing constants
| Constant | Value | Why this number |
|---|---|---|
| smoothing | = 0.0008 | Fraction of the pointer gap still remaining after one second: a tight ~90ms tail, because a repulsion hole that lags is read as broken rather than as smooth — the hole has a visible edge, unlike a glow. Raise it past ~0.01 and the field is still opening where the cursor was half a second ago. |
| cursorRadius | = 0.22 | Field units, where 1 is the container HEIGHT. Roughly a fifth of the panel, so the hole is obviously deliberate but the field still dominates. Past ~0.45 the pointer clears most of the visible particles and there is nothing left to displace. |
| repel | = 0.85 | Displacement at the centre of the field, as a fraction of the radius. Below ~0.4 the hole never actually opens — particles crowd instead of clearing. Above 1.0 the inner particles are pushed past the rim and pile into a bright ring, which is the same artefact as a hard-edged mask. |
| BURST_SLOTS | = 3 | Concurrent waves. One slot means a second press restarts the wave from zero, which visibly sucks the first one back into its origin. Three covers a fast double or triple click, and each slot is a vec4 and a loop iteration, so the ceiling is set by taste rather than by cost. |
| burstDuration | = 1.1 | Seconds of life. Under ~0.6 the wave is gone before the eye finds it; over ~2 the field spends most of its time recovering and a second press lands in a field that is still deformed. |
| burstRadius / BURST_BAND | = 0.55 / 0.085 | How far the front travels, and how thick it is. The band is roughly 15% of the travel, which is a front rather than a wash — take it past ~0.25 and the Gaussian covers the whole burst at once, which is indistinguishable from tinting every particle. |
| burstPush | = 0.17 | Peak displacement in field units, multiplied by a per-particle 0.25-1.0. The spread is what gives the wave a ragged front; a uniform magnitude produces a clean expanding circle, which reads as a UI ripple rather than as a detonation. |
| MAX_DPR | = 1.5 | Additive fill is the entire cost here and it scales with the square of the ratio. These are soft round dots with no fine detail, so a third sample per axis resolves nothing a viewer can see and costs 78% more fragments. |
Behaviour
What it does about motion sensitivity, teardown and being finished.
Reduced motion
The render loop never starts. One frame is drawn with the pointer parked at (0.34, 0.56) at full presence and a single wave frozen 42% of the way out, so the panel is a composed still of the effect rather than a static dot field. The listeners stay attached but their target is never consumed, so moving or pressing changes nothing on screen.
Cleanup
Removes the four pointer listeners from the canvas, nulls the stage ref so an in-flight frame cannot touch a dead renderer, then geometry.remove() (deletes the VAOs and every buffer), program.remove() (deletes the shaders and the program), loseContext(gl), zeroes the canvas dimensions and removes it from the DOM. ogl has no dispose(), and dropping the context is the part that matters — the per-process budget is about sixteen, and exceeding it kills the oldest context, which belongs to somebody else.
Definition of done
The field drifts on its own with no pointer on the page. Move the pointer in and a clear hole opens around it, tracking with a short even lag that is the same on a 60Hz and a 120Hz display, with the rim slightly brighter than the rest of the field. The particle directly under the cursor does not jitter. Press and a wave throws the nearby particles outward with a ragged front, a warm band travelling through them, and they drift back without snapping. Press three times quickly and you get three overlapping waves, not one wave restarting. Leave the panel and the hole closes where it stands. Resize to a wide container and the hole is still round. With reduced motion the panel is a single composed still.
Pitfalls
- Writing pointer events straight into
uCursor. The hole lands exactly where the last event was: a high-rate trackpad delivers a staircase, and after a stalled frame it is a jump that shoves the whole field sideways in one step. The listener records a target and the loop damps toward it. - Damping with a per-frame constant (
x += (t - x) * 0.1) instead ofdamp(..., dt). The same code produces a noticeably tighter follow at 120Hz than at 60Hz, so the effect is tuned for whatever machine it was written on. - Using
normalize()for the repulsion direction. It is undefined at the origin, so the particle under the cursor jitters between random directions every frame — a visible buzzing speck exactly where the eye is. - Aspect-correcting the distances but not dividing the displacement back out. Particles are then pushed further horizontally than vertically, and the hole slowly shears the field sideways as the pointer moves.
- Restarting one burst slot on every press. The in-flight wave is yanked back to its origin before the new one starts, which looks like a rendering fault rather than a second click. Three round-robin slots let waves overlap.
- Advancing burst progress per frame instead of by
dt / duration. The wave is then twice as fast on a 120Hz display and half speed on a throttled tab. - Deactivating a slot while its displacement is still non-zero. Every particle in it snaps home on one frame. The magnitude is multiplied by a settle term that reaches zero exactly when the slot expires.
- Handing ogl a Float32Array for
uniform vec4 uBursts[3]. ogl resolves the active uniform nameuBursts[0], checksArray.isArray(value)to decide it is an array uniform, and a Float32Array fails that test — the uniform is silently skipped with a console warning and every burst is invisible. It must be a plain array. - Leaving ogl's default BACK-face culling on. The quad winding is not guaranteed to face the way the driver expects, and a culled particle is not dim, it is absent — so the field renders empty on some machines and fine on others.
- Owning the canvas in React while calling
loseContext()on cleanup. UnderreactStrictModethe effect runs twice and the secondgetContexton that element hands back the dead context: blank in development, fine in production. - Rebuilding the renderer when a colour changes. Chromium drops the oldest WebGL context past roughly sixteen per process, so recolouring one field can blank a different canvas on the same page. Colours are written into live uniforms.
Notes
pointerdownsets the pointer target as well as firing the wave. On touch there is no hover, so without it the burst would originate from wherever the pointer last happened to be — usually the previous tap.- Blending is additive with an opaque clear colour, so a thousand overlapping instances need no depth sort and the whole field is one draw call. The fragment output is premultiplied because the destination is only ever added to.
- The population is derived from container area rather than fixed, and selected with
setInstancedCountout of buffers allocated once atmaxCount. Resizing changes the count without touching a byte of GPU memory. - The scatter runs on
createRng(seed), neverMath.random(), so the field is identical on every load and a visual regression is a real regression. - Particles inside the repulsion radius also brighten, which stops the hole reading as a bald patch — the field gets denser and hotter at the rim instead.