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.jsonBackdrop · canvasobstacle-aware
Current along the edges.
traces your boxes
"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
- ✓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