EmberField
Named pills rising through a spark bed, dimming to a legible floor inside your content column and consumed at the ceiling element.
Install
$ npx shadcn@latest add wisp.pouriah.com/r/ember-field.jsonBackdrop · canvasobstacle-aware
"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";
/**
* EmberField — named pills rising off a floor through a bed of sparks.
*
* Where PacketField stops motion at your content, this one passes *through* it
* at reduced strength: a pill entering the content column eases down to
* `contentFade`, keeps climbing behind the text, and is consumed at the ceiling
* element. Two different answers to the same measurement, and which is right
* depends on the section — a hero with a dense headline wants the crash, a
* looser layout wants the dim.
*/
export interface EmberFieldProps extends React.HTMLAttributes<HTMLDivElement> {
/** Pill labels. Walked in order, so every one is seen. */
labels?: readonly string[];
/** Labels drawn in the `toToken` instead of the `fromToken`. */
warm?: readonly string[];
fromToken?: string;
toToken?: string;
/** The column pills dim inside — measured, defaults to `[data-wisp-avoid]`. */
avoid?: string;
/** Horizontal breathing room around measured content, CSS px. */
padX?: number;
/** Vertical breathing room around measured content, CSS px. */
padY?: number;
/** Draw the measured boxes as wireframes. Debugging aid — and a good demo. */
debugBoxes?: boolean;
/** The element pills are consumed at. Defaults to the top of the column. */
ceiling?: string;
/** Visibility floor inside the content column, `0`–`1`. */
contentFade?: number;
/** Draw the spark bed under the pills. */
sparks?: boolean;
/**
* One pill per this much area, px². The default is tuned for a full-height
* hero; a small panel wants a much lower number or it reads as empty. Named
* `density` to match the other atmosphere canvases, where it means the same
* area-per-element; the spark bed keeps its own `sparkDensity`.
*/
density?: number;
/** One spark per this much area, px². */
sparkDensity?: number;
/**
* Ceiling on concurrent pills. Deliberately generous: lane count already
* scales with width, so that is what should decide density at any size.
*/
maxPills?: number;
/**
* Pill label font, as a CSS `font` shorthand. Give it your product's mono
* face so the labels read as part of the page rather than as a canvas.
*/
font?: string;
/** Pill height, CSS px. Lane pitch and corner radius follow from it. */
pillHeight?: number;
/** Share of sparks drawn in the cool token rather than the warm one, `0`–`1`. */
coolSparks?: number;
}
interface Pill {
/** Which column this pill lives in for its whole life. */
lane: number;
x: number;
y: number;
vy: number;
sway: number;
swaySpeed: number;
phase: number;
life: number;
maxLife: number;
label: string;
warm: boolean;
width: number;
}
interface Spark {
x: number;
y: number;
vy: number;
sway: number;
swaySpeed: number;
phase: number;
radius: number;
life: number;
maxLife: number;
cool: boolean;
flicker: number;
}
const DEFAULT_FONT = "600 11px ui-monospace, SFMono-Regular, Menlo, monospace";
const DEFAULT_PILL_HEIGHT = 24;
/** Horizontal padding inside a pill, both sides combined. */
const PILL_PAD = 26;
/** Slack between lanes. Also the sway budget — pills must stay in their lane. */
const LANE_GAP = 24;
/** Minimum vertical separation between two pills sharing a lane, as a multiple
* of the pill height. */
const PITCH_RATIO = 2.6;
/** At most this many pills per lane, so a column never reads as a queue. */
const PER_LANE = 2;
const MIN_RISE = 13;
const MAX_RISE = 33;
/** Ease-in distance below the content, so there is no visible pop at the edge. */
const FADE_LEAD = 56;
/** A ref that never resolves to an element, so `useObstacles` bails out of its
* effect immediately — used to skip a second observer pipeline when `ceiling`
* is unset and would otherwise watch the exact same targets as `avoid`. */
const NO_CEILING_TARGET: React.RefObject<HTMLElement | null> = { current: null };
export function EmberField({
labels = ["measure", "read tokens", "draw", "settle", "pause", "still frame"],
warm = ["still frame", "settle"],
fromToken = "--wisp",
toToken = "--lantern",
avoid = AVOID_SELECTOR,
padX,
padY,
debugBoxes = false,
ceiling,
contentFade = 0.4,
sparks = true,
density = 90000,
sparkDensity = 46000,
maxPills = 24,
font = DEFAULT_FONT,
pillHeight = DEFAULT_PILL_HEIGHT,
coolSparks = 0.35,
className,
...props
}: EmberFieldProps) {
const ref = React.useRef<HTMLCanvasElement>(null);
const boxes = useObstacles(ref, avoid, { padX, padY });
// Without a distinct `ceiling`, the ceiling is just the top of `boxes` — a
// second `useObstacles` over the same `avoid` selector would stand up a
// whole extra ResizeObserver/MutationObserver pair to recompute data we
// already have. Handing it a ref that never resolves makes it a no-op; the
// draw loop below reads `boxes` directly in that case instead.
const ceilingBoxes = useObstacles(ceiling ? ref : NO_CEILING_TARGET, ceiling ?? avoid, {
padX,
padY,
});
const cool = useTokenColor(fromToken, DEFAULT_WISP);
const hot = useTokenColor(toToken, DEFAULT_LANTERN);
const gain = useWispGain();
const pills = React.useRef<Pill[]>([]);
const embers = React.useRef<Spark[]>([]);
const cursor = React.useRef(0);
// The spark bed's glow is one radial gradient per spark per frame. Bake one
// sprite per colour and stamp it instead, rebuilt only on a theme swap.
const sprites = React.useRef<{ key: string; cool: HTMLCanvasElement; hot: HTMLCanvasElement } | null>(
null,
);
// Spawner closures only change with size, lane geometry or the density/label
// knobs, so they are memoised rather than rebuilt every frame.
const spawner = React.useRef<{ key: string; make: ReturnType<typeof makeSpawner> } | null>(null);
// Lane geometry depends on text metrics, so it is measured once per resize
// rather than per frame — but a `labels`/`font` change also invalidates it,
// so it is keyed and re-measured whenever that key drifts from `lanesKey`.
const lanes = React.useRef<Lanes | null>(null);
const lanesKey = React.useRef<string>("");
const densities = React.useMemo(
() => ({ pill: density, spark: sparkDensity, maxPills }),
[density, sparkDensity, maxPills],
);
const geometry = React.useMemo(() => ({ pillHeight, coolSparks }), [pillHeight, coolSparks]);
const labelKey = labels.join("|");
const laneKey = `${labelKey}::${font}`;
// Rebuild the spawner only when size, lane geometry or a knob moves — never
// per frame. `lanes.current` is measured before this runs in both callbacks.
const getSpawn = (ctx: CanvasRenderingContext2D, width: number, height: number) => {
const key = `${width}x${height}::${laneKey}::${warm.join("|")}::${densities.pill},${densities.spark},${densities.maxPills}::${geometry.pillHeight},${geometry.coolSparks}`;
if (!spawner.current || spawner.current.key !== key) {
spawner.current = {
key,
make: makeSpawner(ctx, lanes.current!, width, height, labels, warm, cursor, densities, geometry),
};
}
return spawner.current.make;
};
useCanvasScene(ref, {
setup: ({ ctx, width, height }) => {
cursor.current = 0;
lanes.current = measureLanes(ctx, width, labels, font);
lanesKey.current = laneKey;
const spawn = getSpawn(ctx, width, height);
// Seeded pills are placed one at a time so each can see the lane
// occupancy the earlier ones created.
const seeded: Pill[] = [];
for (let i = 0; i < spawn.pillCount; i++) seeded.push(spawn.pill(true, seeded));
pills.current = seeded;
embers.current = sparks
? Array.from({ length: spawn.sparkCount }, () => spawn.spark(true))
: [];
},
draw: ({ ctx, width, height, dt }) => {
if (!lanes.current || lanesKey.current !== laneKey) {
lanes.current = measureLanes(ctx, width, labels, font);
lanesKey.current = laneKey;
}
const spawn = getSpawn(ctx, width, height);
const column = columnOf(
boxes.current,
ceiling ? ceilingBoxes.current : boxes.current,
width,
height,
);
const light = gain < 0.9;
ctx.clearRect(0, 0, width, height);
const visibility = (y: number, within: boolean): number => {
if (!within) return 1;
if (y <= column.ceiling) return -1;
if (y >= column.bottom + FADE_LEAD) return 1;
if (y >= column.bottom) {
const t = (y - column.bottom) / FADE_LEAD;
return contentFade + (1 - contentFade) * t;
}
return (contentFade * (y - column.ceiling)) / (column.bottom - column.ceiling || 1);
};
if (sparks) {
ctx.globalCompositeOperation = "lighter";
const key = `${cool.join()}|${hot.join()}`;
if (!sprites.current || sprites.current.key !== key) {
sprites.current = { key, cool: sparkSprite(cool), hot: sparkSprite(hot) };
}
for (let i = 0; i < embers.current.length; i++) {
const s = embers.current[i]!;
s.life += dt;
if (s.life >= s.maxLife || s.y < -20) {
embers.current[i] = spawn.spark(false);
continue;
}
s.y -= s.vy * dt;
s.phase += s.swaySpeed * dt;
const x = s.x + Math.sin(s.phase) * s.sway;
const vis = visibility(s.y, x >= column.left && x <= column.right);
if (vis < 0) {
embers.current[i] = spawn.spark(false);
continue;
}
const t = s.life / s.maxLife;
let envelope = 1;
if (t < 0.16) envelope = t / 0.16;
else if (t > 0.6) envelope = 1 - (t - 0.6) / 0.4;
const flicker = 0.72 + 0.28 * Math.sin(s.phase * 3.1 + s.flicker);
const alpha = Math.max(0, envelope) * flicker * vis * 0.78 * gain;
if (alpha <= 0.012) continue;
const sprite = s.cool ? sprites.current.cool : sprites.current.hot;
const radius = s.radius * 4.4;
// Alpha was baked at 1; scale and fade per spark here.
ctx.globalAlpha = alpha;
ctx.drawImage(sprite, x - radius, s.y - radius, radius * 2, radius * 2);
}
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = "source-over";
}
for (let i = 0; i < pills.current.length; i++) {
const p = pills.current[i]!;
p.life += dt;
if (p.life >= p.maxLife || p.y < -pillHeight) {
pills.current[i] = spawn.pill(false, pills.current, i);
continue;
}
p.y -= p.vy * dt;
p.phase += p.swaySpeed * dt;
const x = p.x + Math.sin(p.phase) * p.sway;
const within = x + p.width / 2 > column.left && x - p.width / 2 < column.right;
const vis = visibility(p.y, within);
if (vis < 0) {
pills.current[i] = spawn.pill(false, pills.current, i);
continue;
}
const t = p.life / p.maxLife;
let envelope = 1;
if (t < 0.14) envelope = t / 0.14;
else if (t > 0.66) envelope = 1 - (t - 0.66) / 0.34;
const alpha = Math.max(0, Math.min(1, envelope)) * vis;
if (alpha <= 0.02) continue;
const color = p.warm ? hot : cool;
const left = x - p.width / 2;
const top = p.y - pillHeight / 2;
ctx.beginPath();
if (ctx.roundRect) ctx.roundRect(left, top, p.width, pillHeight, pillHeight / 2);
else ctx.rect(left, top, p.width, pillHeight);
ctx.fillStyle = hsl(
color,
light ? 92 : Math.max(color[2] - 26, 10),
alpha * (light ? 0.5 : 0.22),
);
ctx.fill();
ctx.lineWidth = 1;
ctx.strokeStyle = hsl(color, color[2], alpha * 0.55);
ctx.stroke();
ctx.font = font;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillStyle = hsl(
color,
light ? Math.max(color[2] - 4, 22) : Math.min(color[2] + 16, 86),
alpha,
);
ctx.fillText(p.label, x, p.y + 0.5);
ctx.textAlign = "start";
ctx.textBaseline = "alphabetic";
}
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(hot, hot[2], 0.85);
ctx.fillStyle = hsl(hot, hot[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(hot, hot[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>
);
}
/** Radius the spark glow is baked at; sparks are far smaller, so it only scales
* down. */
const SPARK_GLOW_RADIUS = 32;
/** Bake a spark's radial glow into an offscreen canvas once, at full alpha, so
* the draw loop can stamp it rather than allocate a gradient per spark. */
function sparkSprite(color: Hsl): HTMLCanvasElement {
const canvas = document.createElement("canvas");
canvas.width = canvas.height = SPARK_GLOW_RADIUS * 2;
const g = canvas.getContext("2d")!;
const grad = g.createRadialGradient(
SPARK_GLOW_RADIUS,
SPARK_GLOW_RADIUS,
0,
SPARK_GLOW_RADIUS,
SPARK_GLOW_RADIUS,
SPARK_GLOW_RADIUS,
);
grad.addColorStop(0, hsl(color, color[2] + 28, 1));
grad.addColorStop(0.35, hsl(color, color[2], 0.62));
grad.addColorStop(1, hsl(color, color[2], 0));
g.fillStyle = grad;
g.fillRect(0, 0, SPARK_GLOW_RADIUS * 2, SPARK_GLOW_RADIUS * 2);
return canvas;
}
/** The union of the measured boxes, plus the ceiling motes are consumed at. */
function columnOf(
boxes: ReadonlyArray<{ l: number; r: number; t: number; b: number }>,
ceilingBoxes: ReadonlyArray<{ t: number }>,
width: number,
height: number,
) {
if (boxes.length === 0) {
return {
left: width * 0.22,
right: width * 0.78,
bottom: height * 0.68,
ceiling: height * 0.2,
};
}
let left = Infinity;
let right = -Infinity;
let bottom = -Infinity;
for (const box of boxes) {
if (box.l < left) left = box.l;
if (box.r > right) right = box.r;
if (box.b > bottom) bottom = box.b;
}
let ceiling = Infinity;
for (const box of ceilingBoxes) if (box.t < ceiling) ceiling = box.t;
if (!Number.isFinite(ceiling)) ceiling = bottom;
return { left, right, bottom, ceiling };
}
/**
* Lane geometry.
*
* Pills used to spawn at a random x with a random rise speed, which meant two
* of them could converge in mid-air and print one label on top of another. No
* amount of spawn-time spacing fixes that: a faster pill will always catch a
* slower one eventually.
*
* So the field is columned. A pill belongs to one lane for its whole life, and
* every pill in a lane rises at that lane's speed — so within a lane the gaps
* are fixed forever and overtaking is impossible. Lanes are wide enough for the
* longest label plus its sway budget, so pills can't drift into a neighbour
* either. Speeds still vary *across* lanes, which is where the organic feel
* came from anyway.
*/
interface Lanes {
count: number;
width: number;
centre: (lane: number) => number;
speed: (lane: number) => number;
widthOf: (label: string) => number;
}
function measureLanes(
ctx: CanvasRenderingContext2D,
canvasWidth: number,
labels: readonly string[],
font: string,
): Lanes {
ctx.font = font;
const widths = new Map<string, number>();
let widest = 0;
for (const label of labels) {
const w = Math.round(ctx.measureText(label).width) + PILL_PAD;
widths.set(label, w);
if (w > widest) widest = w;
}
const count = Math.max(1, Math.floor(canvasWidth / Math.max(1, widest + LANE_GAP)));
const width = canvasWidth / count;
return {
count,
width,
centre: (lane) => width * (lane + 0.5),
// Deterministic per lane, so a respawning pill inherits the same speed as
// the one above it and the gap it was born with is preserved.
speed: (lane) => MIN_RISE + (((lane * 37) % 11) / 10) * (MAX_RISE - MIN_RISE),
widthOf: (label) => widths.get(label) ?? widest,
};
}
/**
* Pick the lane a new pill should enter: one that still has room, and among
* those the one with the most space at the floor.
*
* The occupancy test is load-bearing. Lanes rise at different speeds, so
* "whichever lane is emptiest" alone hands every respawn to the fastest lane —
* it clears first, so it is always the emptiest — and the field collapses into
* one crowded column. Capping occupancy first keeps the traffic spread.
*
* `ignore` is the index being replaced: that pill is already off-screen and
* must not count against its own lane, or the lane it just left would look
* full and never be refilled.
*/
function freestLane(lanes: Lanes, pills: readonly Pill[], ignore = -1): number {
const counts = new Array<number>(lanes.count).fill(0);
const lowest = new Array<number>(lanes.count).fill(-Infinity);
pills.forEach((pill, index) => {
if (!pill || index === ignore || pill.lane >= lanes.count) return;
counts[pill.lane]! += 1;
if (pill.y > lowest[pill.lane]!) lowest[pill.lane] = pill.y;
});
let best = 0;
let bestLowest = Infinity;
let bestHasRoom = false;
for (let lane = 0; lane < lanes.count; lane++) {
const hasRoom = counts[lane]! < PER_LANE;
if (bestHasRoom && !hasRoom) continue;
if (hasRoom && !bestHasRoom) {
best = lane;
bestLowest = lowest[lane]!;
bestHasRoom = true;
continue;
}
if (lowest[lane]! < bestLowest) {
best = lane;
bestLowest = lowest[lane]!;
}
}
return best;
}
function makeSpawner(
ctx: CanvasRenderingContext2D,
lanes: Lanes,
width: number,
height: number,
labels: readonly string[],
warm: readonly string[],
cursor: { current: number },
density: { pill: number; spark: number; maxPills: number },
geometry: { pillHeight: number; coolSparks: number },
) {
const pitch = geometry.pillHeight * PITCH_RATIO;
return {
pillCount: Math.min(
density.maxPills,
lanes.count * PER_LANE,
Math.max(1, Math.round((width * height) / density.pill)),
),
sparkCount: Math.min(34, Math.max(8, Math.round((width * height) / density.spark))),
/** `live` is the current population, needed to pick a lane and clear it. */
pill: (seeded: boolean, live: readonly Pill[], replacing = -1): Pill => {
const label = labels[cursor.current++ % labels.length] ?? "";
const pillWidth = lanes.widthOf(label);
const lane = freestLane(lanes, live, replacing);
let lowest = -Infinity;
let occupants = 0;
for (const pill of live) {
if (!pill || pill.lane !== lane) continue;
occupants++;
if (pill.y > lowest) lowest = pill.y;
}
// Seeded pills are spread over the full height rather than stacked one
// pitch apart at the floor — on a tall stage that would start every pill
// in a single band rising together. Respawns enter below whatever is
// already in the lane, never closer than one pitch.
const floor = height + geometry.pillHeight;
const seedStep = Math.max(pitch, height / PER_LANE);
const y = seeded
? height - (occupants + Math.random()) * seedStep
: Math.max(floor, lowest + pitch);
// Never wander out of the lane and into a neighbour's label.
const swayBudget = Math.max(0, (lanes.width - pillWidth) / 2 - 2);
const maxLife = 7 + Math.random() * 6;
return {
lane,
x: lanes.centre(lane),
y,
vy: lanes.speed(lane),
sway: Math.min(swayBudget, 4 + Math.random() * 12),
swaySpeed: 0.2 + Math.random() * 0.7,
phase: Math.random() * Math.PI * 2,
life: seeded ? Math.random() * maxLife * 0.6 : 0,
maxLife,
label,
warm: warm.includes(label),
width: pillWidth,
};
},
spark: (seeded: boolean): Spark => {
const maxLife = 3 + Math.random() * 5.5;
return {
x: Math.random() * width,
y: seeded ? Math.random() * height : height + Math.random() * height * 0.2,
vy: 12 + Math.random() * 40,
sway: 5 + Math.random() * 24,
swaySpeed: 0.25 + Math.random() * 0.95,
phase: Math.random() * Math.PI * 2,
radius: 0.6 + Math.random() * 2,
life: seeded ? Math.random() * maxLife : 0,
maxLife,
cool: Math.random() < geometry.coolSparks,
flicker: Math.random() * Math.PI * 2,
};
},
};
}
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