EdgeFlow

Glowing motes that trace the perimeter of your measured content, flowing around each box's outline like current along a wire.

Install

$ npx shadcn@latest add wisp.pouriah.com/r/edge-flow.json
Backdrop · canvasobstacle-aware
Current along the edges.
traces your boxes

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

atmosphere/edge-flow.tsx
"use client";

import * as React from "react";
import {
  AVOID_SELECTOR,
  cx,
  DEFAULT_LANTERN,
  DEFAULT_WISP,
  hsl,
  useCanvasScene,
  useObstacles,
  useTokenColor,
  useWispGain,
  type Hsl,
} from "@pouriahlabs/wisp-ui";

/**
 * EdgeFlow — current running along the edges of your content.
 *
 * Instead of drifting behind the text or crashing into it, the motes here
 * *trace* it: each is pinned to a measured content box and to a position along
 * that box's rectangular outline, and travels around it like charge along a
 * wire, leaving a short fading trail behind a bright head. Two things make it
 * read as deliberate rather than decorative:
 *
 *   1. The path is the content, not the canvas. The perimeter of each tagged
 *      box is parametrised as a single 0..1 loop — top edge, right edge, bottom
 *      edge, left edge — and a mote just advances its `u` and is placed back
 *      onto that outline every frame. Because the point is recomputed from the
 *      *live* box, a headline rewrapping or a card resizing carries its flow
 *      with it, no reseed required.
 *   2. A seam down the middle of each box. The head is coloured by the half it
 *      is currently over — `fromToken` on the left, `toToken` on the right — so
 *      a mote visibly changes hands twice per lap as it rounds the top and the
 *      bottom.
 *
 * With nothing tagged there is nothing to trace, so the effect falls back to
 * running around the canvas's own inset rectangle — it still demos itself.
 */

export interface EdgeFlowProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Token for the left half of each box's seam. */
  fromToken?: string;
  /** Token for the right half. */
  toToken?: string;
  /** Selector for the content whose edges are traced. */
  avoid?: string;
  /** Horizontal breathing room around measured content, CSS px — this is the
   *  gap between the text and the line the flow rides. */
  padX?: number;
  /** Vertical breathing room around measured content, CSS px. */
  padY?: number;
  /** Which way the current runs. `"cw"` is clockwise on screen. */
  direction?: "cw" | "ccw";
  /** Roughly one mote per this much perimeter, CSS px — the same one-per-metric
   *  meaning `density` carries on the other atmosphere canvases, measured along
   *  the outline rather than over an area. */
  density?: number;
  /** Base travel speed, CSS px per second, before per-mote jitter. */
  speed?: number;
  /** Draw the measured boxes as wireframes. Debugging aid — and a good demo. */
  debugBoxes?: boolean;
}

interface Mote {
  /** Which traced rectangle this mote belongs to, indexing the frame's list. */
  rect: number;
  /** Position around that rectangle's perimeter, wrapped to `[0, 1)`. */
  u: number;
  /** Perimeter fractions per second — a px/sec speed divided by the box's own
   *  perimeter, so motes on a small card and a wide hero move at the same
   *  on-screen pace rather than the same laps-per-second. */
  rate: number;
  thickness: number;
  alpha: number;
}

/** A rectangle to trace: a measured content box, or the fallback canvas inset. */
interface Rect {
  l: number;
  r: number;
  t: number;
  b: number;
}

export function EdgeFlow({
  fromToken = "--wisp",
  toToken = "--lantern",
  avoid = AVOID_SELECTOR,
  padX,
  padY,
  direction = "cw",
  density = 120,
  speed = 42,
  debugBoxes = false,
  className,
  ...props
}: EdgeFlowProps) {
  const ref = React.useRef<HTMLCanvasElement>(null);
  const boxes = useObstacles(ref, avoid, { padX, padY });

  const from = useTokenColor(fromToken, DEFAULT_WISP);
  const to = useTokenColor(toToken, DEFAULT_LANTERN);
  const gain = useWispGain();

  const dir = direction === "ccw" ? -1 : 1;

  const motes = React.useRef<Mote[]>([]);
  // How many rectangles the current motes were seeded against. Tagged content
  // can appear, vanish or split *without* the canvas ever resizing (a suspended
  // list resolving, a conditional headline), and none of that re-runs `setup`.
  // When the count moves out from under us we reseed in `draw`, so every box
  // gets its own motes and a removed box's are reassigned — gracefully, not by
  // leaving orphans pinned to a rectangle that is no longer there.
  const seededFor = React.useRef(-1);
  // The head glow is one radial gradient per mote per frame. Bake one sprite per
  // seam colour and stamp it, rebuilt only when a theme swap moves a token.
  const sprites = React.useRef<{ key: string; from: HTMLCanvasElement; to: HTMLCanvasElement } | null>(
    null,
  );

  // The rectangles to trace this frame: the measured boxes, or — with nothing
  // tagged — the canvas's own inset outline, so the scene is never empty.
  const framesOf = (width: number, height: number): Rect[] => {
    if (boxes.current.length > 0) return boxes.current;
    return [{ l: EDGE_INSET, t: EDGE_INSET, r: width - EDGE_INSET, b: height - EDGE_INSET }];
  };

  const seed = (rects: Rect[]) => {
    const next: Mote[] = [];
    for (let r = 0; r < rects.length; r++) {
      const rect = rects[r]!;
      const per = perimeter(rect);
      // Space motes evenly around the outline, then jitter so the laps don't
      // pulse in lock-step. Cap per box so a very long perimeter stays a
      // procession rather than a solid ring.
      const count = Math.min(24, Math.max(2, Math.round(per / density)));
      for (let i = 0; i < count; i++) {
        next.push({
          rect: r,
          u: (i + Math.random() * 0.6) / count,
          rate: (speed * (0.75 + Math.random() * 0.5)) / per,
          thickness: 1.4 + Math.random() * 1.4,
          alpha: 0.55 + Math.random() * 0.45,
        });
      }
    }
    motes.current = next;
    seededFor.current = rects.length;
  };

  useCanvasScene(ref, {
    setup: ({ width, height }) => {
      seed(framesOf(width, height));
    },

    draw: ({ ctx, width, height, dt }) => {
      const rects = framesOf(width, height);
      // A box arrived or left since the last seed — reassign before drawing so
      // no mote points past the end of the list.
      if (rects.length !== seededFor.current) seed(rects);

      ctx.clearRect(0, 0, width, height);
      ctx.globalCompositeOperation = "lighter";
      ctx.lineCap = "round";

      const key = `${from.join()}|${to.join()}`;
      if (!sprites.current || sprites.current.key !== key) {
        sprites.current = { key, from: headSprite(from), to: headSprite(to) };
      }

      for (const m of motes.current) {
        const rect = rects[m.rect];
        if (!rect) continue;
        const per = perimeter(rect);
        // `dt` is exactly 0 on the still frame, so this is a no-op there and the
        // mote paints at its seeded position — one valid composed frame.
        m.u = wrap(m.u + dir * m.rate * dt);

        const midX = (rect.l + rect.r) / 2;
        // The seam: colour by the half of the box the head sits over.
        const seam = (x: number): Hsl => (x < midX ? from : to);

        // The trail is a short arc *behind* the head, sampled along the
        // perimeter so it bends around the corners it crosses rather than
        // chording across them. Walk it tail→head in unwrapped `u` (wrapping
        // only when placing each point) so a trail straddling the 0/1 seam
        // stays continuous, and ramp alpha up toward the head.
        const tailU = m.u - dir * (TRAIL_LEN / per);
        let prev = perimeterPoint(rect, tailU);
        ctx.lineWidth = 1;
        for (let s = 1; s <= TRAIL_SAMPLES; s++) {
          const f = s / TRAIL_SAMPLES;
          const pt = perimeterPoint(rect, tailU + (m.u - tailU) * f);
          const color = seam((prev.x + pt.x) / 2);
          ctx.strokeStyle = hsl(color, color[2], f * m.alpha * 0.16 * gain);
          ctx.beginPath();
          ctx.moveTo(prev.x, prev.y);
          ctx.lineTo(pt.x, pt.y);
          ctx.stroke();
          prev = pt;
        }

        // The head.
        const head = perimeterPoint(rect, m.u);
        const color = seam(head.x);
        const alpha = m.alpha * 0.7 * gain;
        const radius = m.thickness * 3;
        const sprite = color === from ? sprites.current.from : sprites.current.to;
        // Alpha and radius were baked at 1; scale and fade per mote here, then
        // restore so nothing downstream inherits the faded alpha.
        ctx.globalAlpha = alpha;
        ctx.drawImage(sprite, head.x - radius, head.y - radius, radius * 2, radius * 2);
        ctx.globalAlpha = 1;
      }

      ctx.globalCompositeOperation = "source-over";

      if (debugBoxes) {
        ctx.save();
        ctx.lineWidth = 1;
        ctx.font = "600 9px ui-monospace, SFMono-Regular, Menlo, monospace";
        for (const box of boxes.current) {
          ctx.setLineDash([4, 4]);
          ctx.strokeStyle = hsl(to, to[2], 0.85);
          ctx.fillStyle = hsl(to, to[2], 0.05);
          ctx.fillRect(box.l, box.t, box.r - box.l, box.b - box.t);
          ctx.strokeRect(box.l + 0.5, box.t + 0.5, box.r - box.l - 1, box.b - box.t - 1);
          ctx.setLineDash([]);
          ctx.fillStyle = hsl(to, to[2], 0.95);
          ctx.fillText(avoid, box.l + 4, box.t - 4);
        }
        ctx.restore();
      }
    },
  });

  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>
  );
}

/** Inset of the fallback rectangle from the canvas edge, CSS px — enough that
 *  the outline and its glow aren't clipped by the canvas bounds. */
const EDGE_INSET = 6;

/** Arc length of a mote's trail, CSS px. */
const TRAIL_LEN = 24;

/** Sub-segments the trail is stroked as; enough to round a corner it spans. */
const TRAIL_SAMPLES = 7;

/** The perimeter of a rectangle, CSS px — the length the `u` loop covers. */
function perimeter(rect: Rect): number {
  return 2 * (rect.r - rect.l + rect.b - rect.t);
}

/**
 * Place a perimeter fraction back onto the rectangle's outline.
 *
 * `u` is a single 0..1 loop laid out clockwise — top edge, right edge, bottom
 * edge, left edge — and is wrapped here, so callers may pass values outside the
 * range (a trail sampled behind a head near the seam) and still land on the
 * outline.
 */
function perimeterPoint(rect: Rect, u: number): { x: number; y: number } {
  const w = rect.r - rect.l;
  const h = rect.b - rect.t;
  let d = wrap(u) * (2 * (w + h));

  if (d < w) return { x: rect.l + d, y: rect.t }; // top, left→right
  d -= w;
  if (d < h) return { x: rect.r, y: rect.t + d }; // right, top→bottom
  d -= h;
  if (d < w) return { x: rect.r - d, y: rect.b }; // bottom, right→left
  d -= w;
  return { x: rect.l, y: rect.b - d }; // left, bottom→top
}

/** Wrap a perimeter fraction into `[0, 1)`. */
function wrap(u: number): number {
  return ((u % 1) + 1) % 1;
}

/** Radius the head glow is baked at; heads are far smaller, so it only scales
 *  down, where bilinear sampling stays clean. */
const HEAD_GLOW_RADIUS = 32;

/** Bake a mote head — bright core to transparent edge — into an offscreen
 *  canvas once, at full alpha, so the draw loop can stamp it. */
function headSprite(color: Hsl): HTMLCanvasElement {
  const canvas = document.createElement("canvas");
  canvas.width = canvas.height = HEAD_GLOW_RADIUS * 2;
  const g = canvas.getContext("2d")!;
  const grad = g.createRadialGradient(
    HEAD_GLOW_RADIUS,
    HEAD_GLOW_RADIUS,
    0,
    HEAD_GLOW_RADIUS,
    HEAD_GLOW_RADIUS,
    HEAD_GLOW_RADIUS,
  );
  grad.addColorStop(0, hsl(color, color[2] + 26, 1));
  grad.addColorStop(1, hsl(color, color[2], 0));
  g.fillStyle = grad;
  g.fillRect(0, 0, HEAD_GLOW_RADIUS * 2, HEAD_GLOW_RADIUS * 2);
  return canvas;
}

Certified against the contract