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.json
Backdrop · canvasobstacle-aware

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

atmosphere/ember-field.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";

/**
 * 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