Scatter On Press
A tile of points that opens away from a press and closes back into itself on release.
The canvas in this preview is the file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useEffect, useRef } from "react";
/**
* Vibary · Scatter On Press
*
* A tile made of points. Press it and it opens away from your finger;
* let go and it closes back into exactly the tile it was.
*
* The technique: the press is a force applied for as long as it is held,
* not a burst fired once. Every point is on a spring to its own home,
* and the press adds an outward push that falls off with distance, so
* the scatter is the *balance* of the two — which makes the gesture
* legible in two ways at no cost. Hold time matters, because reaching
* the balance takes about a quarter of a second: a tap opens the tile a
* tenth of the way and a held press opens it fully. And nothing can ever
* be blown off the surface, because the balance is a fixed distance —
* simulated, a point 14px from the press settles 61px out whether it is
* held for a second or a minute. A canned burst has neither property:
* it plays the same length whatever you do, and its travel is whatever
* the random velocities happened to be.
*
* The return is exactly critically damped, damping = 2·sqrt(stiffness),
* so the tile closes once and stays closed. Anything softer leaves a
* hundred and fifty points jittering after the gesture is over, which is
* the difference between a solid object and a bag of sand.
*
* Self-contained: one canvas, no dependencies at all.
* Works with zero props; tune via `count`, `color`, `variant`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type ScatterOnPressProps = {
/** Visual character of the response. */
variant?: "subtle" | "default" | "playful";
/** Points in the tile. */
count?: number;
/** Point colour. Defaults to the inherited text colour. */
color?: string;
/** Fires when a press begins. */
onScatter?: () => void;
/** After this long without input, press on its own. 0 disables. */
idleDemoSeconds?: number;
};
type VariantConfig = {
/** Spring stiffness. Damping is derived from it, at a ratio of 1. */
stiffness: number;
/** Push at the press point, before the distance falloff. */
strength: number;
/** Distance at which the push has halved, in px. */
falloff: number;
/** Point radius in px. */
dot: number;
};
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Gives a little and closes quickly: for a control, not a toy.
subtle: { stiffness: 78, strength: 8800, falloff: 44, dot: 1.7 },
// Opens clearly, closes in one move. All-purpose.
default: { stiffness: 62, strength: 10640, falloff: 55, dot: 2 },
// A wider reach and further travel — the tile really comes apart.
playful: { stiffness: 50, strength: 12500, falloff: 68, dot: 2.3 },
};
/** Inside this, and slow, a point is placed home and leaves the loop. */
const REST = 0.4;
const REST_SPEED = 8;
type Point = {
homeX: number;
homeY: number;
x: number;
y: number;
vx: number;
vy: number;
/** Per-point scale on the push, so the tile is not a rubber balloon. */
give: number;
};
export default function ScatterOnPress({
variant = "default",
count = 150,
color,
onScatter,
idleDemoSeconds = 0,
}: ScatterOnPressProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
// The parent's callback is read through a ref so an inline arrow can't
// rebuild the tile on every render — assigned in an effect, because a
// ref write during render is a side effect mid-render.
const scatterRef = useRef(onScatter);
useEffect(() => {
scatterRef.current = onScatter;
}, [onScatter]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const context = canvas.getContext("2d");
if (!context) return;
const config = VARIANTS[variant];
const wanted = Math.max(12, Math.round(count));
const reduced =
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const ink = color ?? getComputedStyle(canvas).color ?? "#888888";
const damping = 2 * Math.sqrt(config.stiffness);
let width = 0;
let height = 0;
let points: Point[] = [];
let inset = 14;
let corner = 18;
const random = (min: number, max: number) => min + Math.random() * (max - min);
const build = () => {
inset = Math.min(16, Math.min(width, height) * 0.08);
const boxWidth = Math.max(1, width - inset * 2);
const boxHeight = Math.max(1, height - inset * 2);
corner = Math.min(22, Math.min(boxWidth, boxHeight) * 0.22);
// A hex lattice, so the tile reads as one material rather than as
// a grid — and the pitch is solved from the requested count, so
// the number is the same whatever size the box turns out to be.
const pitch = Math.sqrt((boxWidth * boxHeight) / (wanted * 0.866));
const rowPitch = pitch * 0.866;
points = [];
for (let row = 0; row * rowPitch <= boxHeight; row++) {
const y = inset + row * rowPitch + rowPitch * 0.5;
if (y > height - inset) break;
const stagger = row % 2 === 0 ? 0 : pitch * 0.5;
for (let column = 0; ; column++) {
const x = inset + stagger + column * pitch + pitch * 0.5;
if (x > width - inset) break;
// Round the tile's corners by rejecting points outside them.
const cx = Math.max(inset + corner, Math.min(width - inset - corner, x));
const cy = Math.max(inset + corner, Math.min(height - inset - corner, y));
if (Math.hypot(x - cx, y - cy) > corner) continue;
points.push({
homeX: x,
homeY: y,
x,
y,
vx: 0,
vy: 0,
give: random(0.78, 1.28),
});
}
}
};
const resize = () => {
const rect = canvas.getBoundingClientRect();
const ratio = Math.min(window.devicePixelRatio || 1, 2);
width = rect.width;
height = rect.height;
canvas.width = Math.max(1, Math.floor(width * ratio));
canvas.height = Math.max(1, Math.floor(height * ratio));
context.setTransform(ratio, 0, 0, ratio, 0, 0);
build();
};
resize();
const render = () => {
context.clearRect(0, 0, width, height);
context.fillStyle = ink;
for (const point of points) {
// Displacement thins the point: an open tile is the same
// material spread over more surface, so it has to read lighter.
const shift = Math.hypot(point.x - point.homeX, point.y - point.homeY);
const spread = Math.min(1, shift / 70);
context.globalAlpha = 0.72 - spread * 0.34;
context.beginPath();
context.arc(point.x, point.y, config.dot * (1 - spread * 0.22), 0, Math.PI * 2);
context.fill();
}
context.globalAlpha = 1;
};
/** Where a point ends up if the press is held: the force balance. */
const settleAt = (point: Point, pressX: number, pressY: number) => {
const dx = point.homeX - pressX;
const dy = point.homeY - pressY;
const start = Math.hypot(dx, dy) || 0.001;
let distance = 0;
for (let step = 0; step < 24; step++) {
const r = start + distance;
const push = (config.strength * point.give) / (1 + (r / config.falloff) ** 2);
distance = distance * 0.6 + 0.4 * (push / config.stiffness);
}
return {
x: point.homeX + (dx / start) * distance,
y: point.homeY + (dy / start) * distance,
};
};
let pressX = 0;
let pressY = 0;
let pressed = false;
const inside = (x: number, y: number) =>
x >= 0 && y >= 0 && x <= width && y <= height;
// Reduced motion: the tile answers the press, it just does not
// travel there. Down puts every point at the balance it would have
// reached; up puts it home. The information — this thing gives way
// under your finger, and where you press decides how — survives
// intact, and no frame is animated.
if (reduced) {
render();
const draw = () => {
if (pressed) {
for (const point of points) {
const rest = settleAt(point, pressX, pressY);
point.x = rest.x;
point.y = rest.y;
}
} else {
for (const point of points) {
point.x = point.homeX;
point.y = point.homeY;
}
}
render();
};
const onDownStill = (event: PointerEvent) => {
const rect = canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
if (!inside(x, y)) return;
pressX = x;
pressY = y;
pressed = true;
scatterRef.current?.();
draw();
};
const onUpStill = () => {
if (!pressed) return;
pressed = false;
draw();
};
const onResizeStill = () => {
resize();
draw();
};
window.addEventListener("pointerdown", onDownStill);
window.addEventListener("pointerup", onUpStill);
window.addEventListener("pointercancel", onUpStill);
window.addEventListener("resize", onResizeStill);
return () => {
window.removeEventListener("pointerdown", onDownStill);
window.removeEventListener("pointerup", onUpStill);
window.removeEventListener("pointercancel", onUpStill);
window.removeEventListener("resize", onResizeStill);
};
}
let frame = 0;
let sleeping = false;
let last = performance.now();
let lastInput = performance.now();
let demoUntil = 0;
/** Raw client coords, converted to canvas space once per frame. */
let downClientX = 0;
let downClientY = 0;
let downSeen = false;
let moveClientX = 0;
let moveClientY = 0;
let moveSeen = false;
let upSeen = false;
let pointerEventAt = 0;
const tick = (now: number) => {
const delta = Math.min((now - last) / 1000, 0.05);
last = now;
// One rect read per frame, not one per pointer event: the handlers
// only record client coordinates, so dragging a held press at
// 120Hz never forces synchronous layout per event. Down is applied
// before move and move before up, so a press and its release both
// land even when they arrive between two frames.
if (downSeen || moveSeen) {
const rect = canvas.getBoundingClientRect();
if (downSeen) {
downSeen = false;
const x = downClientX - rect.left;
const y = downClientY - rect.top;
if (inside(x, y)) {
pressX = x;
pressY = y;
pressed = true;
demoUntil = 0;
lastInput = pointerEventAt;
scatterRef.current?.();
}
}
if (moveSeen) {
moveSeen = false;
if (pressed && demoUntil === 0) {
pressX = moveClientX - rect.left;
pressY = moveClientY - rect.top;
lastInput = pointerEventAt;
}
}
}
if (upSeen) {
upSeen = false;
if (pressed && demoUntil === 0) {
pressed = false;
lastInput = pointerEventAt;
}
}
if (idleDemoSeconds > 0 && !pressed && now - lastInput > idleDemoSeconds * 1000) {
if (now > demoUntil) {
// A press somewhere in the tile, held long enough to open it.
pressX = random(width * 0.24, width * 0.76);
pressY = random(height * 0.26, height * 0.74);
pressed = true;
demoUntil = now + 620;
scatterRef.current?.();
}
}
if (pressed && demoUntil > 0 && now > demoUntil) {
pressed = false;
demoUntil = 0;
lastInput = now - idleDemoSeconds * 1000 + 900;
}
let moving = false;
for (const point of points) {
let ax = (point.homeX - point.x) * config.stiffness;
let ay = (point.homeY - point.y) * config.stiffness;
if (pressed) {
const dx = point.x - pressX;
const dy = point.y - pressY;
const r = Math.hypot(dx, dy) || 0.001;
const push = (config.strength * point.give) / (1 + (r / config.falloff) ** 2);
ax += (dx / r) * push;
ay += (dy / r) * push;
}
point.vx = (point.vx + ax * delta) * Math.exp(-damping * delta);
point.vy = (point.vy + ay * delta) * Math.exp(-damping * delta);
point.x += point.vx * delta;
point.y += point.vy * delta;
const shift = Math.hypot(point.x - point.homeX, point.y - point.homeY);
const speed = Math.hypot(point.vx, point.vy);
if (!pressed && shift < REST && speed < REST_SPEED) {
point.x = point.homeX;
point.y = point.homeY;
point.vx = 0;
point.vy = 0;
} else {
moving = true;
}
}
render();
if (!moving && !pressed && idleDemoSeconds <= 0) {
// Closed and still. Park the loop; the next press wakes it.
sleeping = true;
return;
}
frame = requestAnimationFrame(tick);
};
const wake = () => {
if (!sleeping) return;
sleeping = false;
last = performance.now();
frame = requestAnimationFrame(tick);
};
// The handlers deliberately do no layout work and no canvas-space
// math: they store the event and let the frame loop convert it —
// which is also where the inside test now lives.
const onPointerDown = (event: PointerEvent) => {
downClientX = event.clientX;
downClientY = event.clientY;
downSeen = true;
// A move or release still waiting belongs to the gesture before
// this press, and must not act after it.
moveSeen = false;
upSeen = false;
pointerEventAt = performance.now();
wake();
};
const onPointerMove = (event: PointerEvent) => {
// The gate reads last frame's state, at most one event stale — a
// pending press counts, so a drag's first move is not dropped, and
// hover moves stay inert so a parked loop stays parked.
if (!downSeen && (!pressed || demoUntil > 0)) return;
moveClientX = event.clientX;
moveClientY = event.clientY;
moveSeen = true;
pointerEventAt = performance.now();
};
const onPointerUp = () => {
if (!downSeen && (!pressed || demoUntil > 0)) return;
upSeen = true;
pointerEventAt = performance.now();
wake();
};
window.addEventListener("pointerdown", onPointerDown);
window.addEventListener("pointermove", onPointerMove, { passive: true });
window.addEventListener("pointerup", onPointerUp);
window.addEventListener("pointercancel", onPointerUp);
frame = requestAnimationFrame(tick);
const onResize = () => {
resize();
wake();
};
window.addEventListener("resize", onResize);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onPointerUp);
window.removeEventListener("pointercancel", onPointerUp);
window.removeEventListener("resize", onResize);
};
}, [variant, count, color, idleDemoSeconds]);
return (
<canvas
ref={canvasRef}
aria-hidden
style={{ width: "100%", height: "100%", display: "block" }}
/>
);
}About this effect
Physical feedback for a press that should feel like touching something rather than clicking it — a long-press affordance, a card being picked up, a control that admits it has substance. The press is a force applied for as long as it is held rather than a burst fired once, and that single choice makes the gesture legible twice over. Hold time matters, because reaching the balance between the outward push and each point's spring takes about a quarter of a second, so a tap opens the tile a tenth of the way and a held press opens it fully. And nothing can be blown off the surface, because the balance is a fixed distance: a point 14px from the press settles 61px out whether it is held for a second or a minute. Where you press matters too — the push falls off with distance, so the near points travel about three times as far as the far ones. The return is exactly critically damped, so a hundred and fifty points close once and stay closed instead of jittering after the gesture is over.