PointerField
A field of drifting glow motes that yield to the cursor — repelled within a falloff radius and lit in the accent as they scatter — while deflecting off your measured content boxes.
Install
$ npx shadcn@latest add wisp.pouriah.com/r/pointer-field.jsonPointer · canvaspointer
The field parts around the cursor.
move across the stage
"use client";
import * as React from "react";
import {
AVOID_SELECTOR,
cx,
DEFAULT_LANTERN,
DEFAULT_WISP,
deflect,
hsl,
inAnyBox,
prefersStill,
useCanvasScene,
useObstacles,
useTokenColor,
useWispGain,
type Hsl,
} from "@pouriahlabs/wisp-ui";
/**
* PointerField — a drifting mote field that yields to the cursor.
*
* The motes wander like Constellation's nodes and deflect off the same measured
* content boxes, so nothing is ever drawn over a word. What this scene adds is a
* second force: a soft repulsion around the pointer. Inside a falloff radius the
* motes flee the cursor and light up in the accent, then ease back onto their
* drift as it moves on — the field parts around your hand and closes behind it.
*
* The pointer is tracked on `window`, not on this layer, so the wrapper can stay
* `pointer-events-none` and keep the decorative pass-through contract: the
* effect reads the cursor without ever intercepting a click meant for the
* content beneath it. Under reduced motion the repulsion is off and the scene is
* a single still frame of motes at rest — the pointer is never consulted.
*/
export interface PointerFieldProps extends React.HTMLAttributes<HTMLDivElement> {
/** Token for the resting motes. */
fromToken?: string;
/** Token motes shift to as the pointer pushes them — its accent. */
toToken?: string;
/** Selector for content the motes must not cross. */
avoid?: string;
/** Horizontal breathing room around measured content, CSS px. */
padX?: number;
/** Vertical breathing room around measured content, CSS px. */
padY?: number;
/** Roughly one mote per this much area, px². */
density?: number;
/** How far the pointer's influence reaches, CSS px. */
radius?: number;
}
interface Mote {
x: number;
y: number;
/** Current velocity, px/s — the drift plus whatever the pointer has added. */
vx: number;
vy: number;
/** The velocity this mote eases back to once the pointer lets go. */
driftX: number;
driftY: number;
radius: number;
}
/** A pointer position in canvas-local CSS px, or inactive when the cursor has
* left the window. Held in a ref so a move never triggers a React render. */
interface Pointer {
x: number;
y: number;
active: boolean;
}
export function PointerField({
fromToken = "--wisp",
toToken = "--lantern",
avoid = AVOID_SELECTOR,
padX = 8,
padY = 6,
density = 3400,
radius = 120,
className,
...props
}: PointerFieldProps) {
const ref = React.useRef<HTMLCanvasElement>(null);
const boxes = useObstacles(ref, avoid, { padX, padY });
const base = useTokenColor(fromToken, DEFAULT_WISP);
const accent = useTokenColor(toToken, DEFAULT_LANTERN);
const gain = useWispGain();
const motes = React.useRef<Mote[]>([]);
const pointer = React.useRef<Pointer>({ x: 0, y: 0, active: false });
// Each mote's glow is one radial gradient; baking a sprite per colour and
// stamping it is far cheaper than allocating a gradient per mote per frame.
// Rebuilt only when a theme swap moves the tokens.
const sprites = React.useRef<{ key: string; base: HTMLCanvasElement; accent: HTMLCanvasElement } | null>(
null,
);
// Track the cursor on `window` rather than on this layer. The wrapper stays
// `pointer-events-none` so it never swallows a click meant for the content
// underneath. The canvas rect is measured lazily and invalidated on
// scroll/resize rather than read per move — `getBoundingClientRect` on every
// `pointermove` is a forced layout read, the exact cost SpotCard's cache
// exists to avoid. `pointerleave` on the root element marks the pointer gone
// so the field settles back to a plain drift when the cursor is away.
React.useEffect(() => {
let rect: DOMRect | null = null;
const invalidate = () => {
rect = null;
};
const onMove = (e: PointerEvent) => {
// Still frames never consult the pointer, so don't pay for tracking it.
if (prefersStill()) return;
const canvas = ref.current;
if (!canvas) return;
if (!rect) rect = canvas.getBoundingClientRect();
pointer.current = {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
active: true,
};
};
const onLeave = () => {
pointer.current = { ...pointer.current, active: false };
};
window.addEventListener("pointermove", onMove, { passive: true });
window.addEventListener("scroll", invalidate, { passive: true, capture: true });
window.addEventListener("resize", invalidate);
document.documentElement.addEventListener("pointerleave", onLeave);
window.addEventListener("blur", onLeave);
return () => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("scroll", invalidate, { capture: true });
window.removeEventListener("resize", invalidate);
document.documentElement.removeEventListener("pointerleave", onLeave);
window.removeEventListener("blur", onLeave);
};
}, []);
useCanvasScene(ref, {
setup: ({ width, height }) => {
// Area decides the count so a card, a hero and a full-screen view all read
// at the same density; the cap is a cost ceiling, not a look.
const count = Math.min(120, Math.max(12, Math.round((width * height) / density)));
motes.current = Array.from({ length: count }, () => spawn(width, height, boxes.current));
},
draw: ({ ctx, width, height, dt, still }) => {
ctx.clearRect(0, 0, width, height);
const key = `${base.join()}|${accent.join()}`;
if (!sprites.current || sprites.current.key !== key) {
sprites.current = { key, base: glowSprite(base), accent: glowSprite(accent) };
}
// The pointer is consulted only when it is present *and* the scene is
// actually animating: a still frame paints the field at rest, with no
// cursor interaction, which is what the reduced-motion contract asks for.
const p = pointer.current;
const pointerLive = !still && p.active;
const radiusSq = radius * radius;
ctx.globalCompositeOperation = "lighter";
for (const mote of motes.current) {
// How hard the pointer is pushing this mote right now, 0..1. Also drives
// its colour and brightness, so a scattering mote reads as lit, not just
// displaced. Squared falloff keeps the edge of the radius gentle.
let push = 0;
if (pointerLive) {
const dx = mote.x - p.x;
const dy = mote.y - p.y;
const distSq = dx * dx + dy * dy;
if (distSq < radiusSq && distSq > 0.01) {
const t = 1 - Math.sqrt(distSq) / radius;
push = t * t;
const dist = Math.sqrt(distSq);
// Repel along the line from cursor to mote, as an acceleration so
// the shove is smooth rather than a teleport.
mote.vx += (dx / dist) * push * PUSH_ACCEL * dt;
mote.vy += (dy / dist) * push * PUSH_ACCEL * dt;
}
}
// Ease the velocity back toward the mote's own drift. With the pointer
// gone this is what returns the field to calm; under a live pointer it
// is the restoring force the repulsion balances against.
const k = Math.min(1, RETURN_RATE * dt);
mote.vx += (mote.driftX - mote.vx) * k;
mote.vy += (mote.driftY - mote.vy) * k;
mote.x += mote.vx * dt;
mote.y += mote.vy * dt;
// Bounce off the walls, reversing the drift too so the ease-back doesn't
// immediately pull the mote back through the edge it just hit.
if (mote.x < 4) {
mote.x = 4;
mote.vx = Math.abs(mote.vx);
mote.driftX = Math.abs(mote.driftX);
} else if (mote.x > width - 4) {
mote.x = width - 4;
mote.vx = -Math.abs(mote.vx);
mote.driftX = -Math.abs(mote.driftX);
}
if (mote.y < 4) {
mote.y = 4;
mote.vy = Math.abs(mote.vy);
mote.driftY = Math.abs(mote.driftY);
} else if (mote.y > height - 4) {
mote.y = height - 4;
mote.vy = -Math.abs(mote.vy);
mote.driftY = -Math.abs(mote.driftY);
}
// Deflect off measured content along the shortest axis out — the pointer
// can shove a mote toward a headline, but never into it.
for (const box of boxes.current) {
if (mote.x <= box.l || mote.x >= box.r || mote.y <= box.t || mote.y >= box.b) continue;
const out = deflect(box, mote.x, mote.y);
mote.x = out.x;
mote.y = out.y;
if (out.axis === "x") {
mote.vx = out.sign * Math.abs(mote.vx);
mote.driftX = out.sign * Math.abs(mote.driftX);
} else {
mote.vy = out.sign * Math.abs(mote.vy);
mote.driftY = out.sign * Math.abs(mote.driftY);
}
break;
}
// Motes under the pointer glow brighter and shift to the accent, so the
// parting field is legible rather than a silent rearrangement.
const sprite = push > 0.15 ? sprites.current.accent : sprites.current.base;
const glow = mote.radius * 4;
ctx.globalAlpha = (0.7 + push * 0.3) * gain;
ctx.drawImage(sprite, mote.x - glow, mote.y - glow, glow * 2, glow * 2);
}
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = "source-over";
},
});
return (
<div
aria-hidden
className={cx("pointer-events-none absolute inset-0 overflow-hidden", className)}
{...props}
>
<canvas ref={ref} className="absolute inset-0 h-full w-full" />
</div>
);
}
/** Outward acceleration at the mote's position under full pointer influence,
* px/s². The ease-back below caps how fast a mote actually travels. */
const PUSH_ACCEL = 2600;
/** How quickly a mote's velocity relaxes back onto its drift, per second. */
const RETURN_RATE = 2.6;
/** Radius the glow is baked at. Motes are far smaller, so it only ever scales
* down, where bilinear sampling stays clean. */
const GLOW_RADIUS = 48;
/** Bake a mote's radial glow — bright core to transparent edge — into an
* offscreen canvas once, at full alpha, so the draw loop can stamp it. */
function glowSprite(color: Hsl): HTMLCanvasElement {
const canvas = document.createElement("canvas");
canvas.width = canvas.height = GLOW_RADIUS * 2;
const g = canvas.getContext("2d")!;
const grad = g.createRadialGradient(
GLOW_RADIUS,
GLOW_RADIUS,
0,
GLOW_RADIUS,
GLOW_RADIUS,
GLOW_RADIUS,
);
grad.addColorStop(0, hsl(color, color[2] + 24, 1));
grad.addColorStop(1, hsl(color, color[2], 0));
g.fillStyle = grad;
g.fillRect(0, 0, GLOW_RADIUS * 2, GLOW_RADIUS * 2);
return canvas;
}
function spawn(
width: number,
height: number,
boxes: ReadonlyArray<{ l: number; r: number; t: number; b: number }>,
): Mote {
let x = 0;
let y = 0;
// Never seed a mote inside content; give up after a bounded search so a
// layout with almost no free space can't spin here.
for (let tries = 0; tries < 24; tries++) {
x = Math.random() * width;
y = Math.random() * height;
if (!inAnyBox(boxes, x, y)) break;
}
const driftX = (Math.random() - 0.5) * 18;
const driftY = (Math.random() - 0.5) * 18;
return {
x,
y,
vx: driftX,
vy: driftY,
driftX,
driftY,
radius: 1.1 + Math.random() * 1.5,
};
}
Certified against the contract
- ✓Reduced motion paints one composed still frame — never a blank box
- ✓Pauses off-screen and in hidden tabs
- ✓Re-reads design tokens when the theme changes
- ✓SSR-safe: no hydration mismatch, no layout shift
- ✓Decorative layers are aria-hidden and pointer-events-none
- ✓Device pixel ratio clamped
- ✓Zero runtime dependencies beyond React