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.json
Pointer · canvaspointer
The field parts around the cursor.
move across the stage

PointerFieldesc or double-click to exit full screendouble-tap to exit

atmosphere/pointer-field.tsx
"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