Skip to main content

ns-ui

Melt Pond Drain

A card-scale ambient loader/empty-state panel modelling Arctic melt-pond drainage: a shallow top-view basin whose pool fills over ~9-11s, then drains rapidly through a crack that opens at a new rim location each cycle, leaving a shallow residual pool that refills and repeats.

Use when a loader or empty-state filler that should feel alive on its own timescale with no data behind it — an unbound fill/drain/refill cycle, not a status glyph tied to request states; pick loader-ink-blob instead when the indicator must carry a conversational turn through explicit idle/thinking/listening/speaking/success/error states, or empty-state-sonar when the panel is actually waiting on real data (a probing ring that gets interrupted by arriving content) rather than looping ambiently forever. Also distinct from gauge-capacity-waterline and meter-quota-meniscus, which are data-bound, steady-state level readings driven by an external `value` prop — melt-pond-drain has no props to bind and its climax is a periodic, self-triggered drain event, a cycle rather than a threshold reading.

Install

npx shadcn add https://design.helpmarq.com/r/melt-pond-drain.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

Source
registry/core/melt-pond-drain/component.tsx
"use client";

import { useEffect, useRef } from "react";

// ---------------------------------------------------------------------------
// MeltPondDrain — an ambient loader/empty-state panel modelling Arctic
// melt-pond drainage (Polashenski et al.): a shallow top-view basin whose
// pool slowly fills over a melt season, then drains rapidly once meltwater
// finds a crack through to the ocean below, leaving a shallow residual pool
// that refills and drains again through a DIFFERENT crack later. Unbound and
// self-driven — no `value` prop, no steady-state reading — the climax is a
// periodic, self-triggered fill/drain CYCLE, distinct from the data-bound
// waterline gauges (gauge-capacity-waterline, meter-quota-meniscus) and from
// the fracture-family siblings (craze-rule, compare-crack-seam), where the
// crack is the subject rather than incidental drainage infrastructure.
//
// Fill: ~9-11s (randomized per cycle) from a shallow residual level up to a
// near-full threshold. Drain: a fixed 1.2s event with three visible stages —
// a funnel dimple forms at a newly chosen rim location (0-30%), the water
// level falls rapidly toward it while the dimple holds (30-85%), then the
// dimple recedes as the floor settles at a residual level 80-90% below the
// pre-drain level, never to zero (85-100%). The drain location is a new
// angle around the basin rim each cycle, chosen at least 100deg away from
// the immediately-prior crack so it reads as migrating drainage, not a
// mechanical valve firing in the same spot.
//
// Seeded mid-drain at mount (t0), so the pond is already visibly non-empty,
// non-full, and the fill/drain distinction, plus at least one full
// drain-to-refill cycle boundary, are guaranteed inside the first ~1.2s —
// well inside the 2.5s/5s resting-loop checks — rather than depending on
// where a randomized multi-second fill happened to start.
//
// All ink is read from --foreground/--ns-muted via getComputedStyle on
// documentElement (never a literal), re-read on a MutationObserver watching
// its class attribute. Water alpha is chosen from the theme's own read
// foreground luminance rather than a hardcoded per-theme branch, so a
// third theme with tokens in between still gets a legible value step.
// ---------------------------------------------------------------------------

const FILL_MS_MIN = 9000;
const FILL_MS_MAX = 11000;
const DRAIN_MS = 1200;
const DIMPLE_END = 0.3; // fraction of DRAIN_MS: dimple fully formed
const FALL_END = 0.85; // fraction of DRAIN_MS: level finishes falling
const FULL_LEVEL = 0.93; // fill threshold that triggers the next drain
const DROP_MIN = 0.8; // 80-90% of the pre-drain level is lost each event
const DROP_MAX = 0.9;
const RESEED_MIN_DEG = 100; // next crack is at least this far from the last
const RESEED_SPAN_DEG = 160;
const N_POINTS = 64;
const DIMPLE_HALF_WIDTH = 0.62; // radians, ~35deg
const DIMPLE_DEPTH = 0.24; // fraction of the water radius the dimple bites in

function clamp01(n: number): number {
  return Math.min(1, Math.max(0, n));
}

function smoothstep(a: number, b: number, x: number): number {
  if (a === b) return x < a ? 0 : 1;
  const t = clamp01((x - a) / (b - a));
  return t * t * (3 - 2 * t);
}

function easeInOutCubic(t: number): number {
  return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}

function mulberry32(seed: number) {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

function parseHex(raw: string): [number, number, number] | null {
  const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(raw.trim());
  if (!m) return null;
  let h = m[1];
  if (h.length === 3) h = h.split("").map((c) => c + c).join("");
  const n = parseInt(h, 16);
  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}

function relLuminance([r, g, b]: [number, number, number]): number {
  return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
}

// smallest signed angular distance from a to b, in (-PI, PI]
function angleDelta(a: number, b: number): number {
  let d = (b - a) % (Math.PI * 2);
  if (d > Math.PI) d -= Math.PI * 2;
  if (d < -Math.PI) d += Math.PI * 2;
  return d;
}

interface DrainState {
  phase: "fill" | "drain";
  level: number; // current fill level, 0..1
  fillElapsed: number;
  fillDuration: number;
  drainElapsed: number;
  levelAtDrainStart: number;
  dropFraction: number;
  thetaDrain: number;
  prevTheta: number;
}

function pickNextTheta(prev: number, rand: () => number): number {
  const offsetDeg = RESEED_MIN_DEG + rand() * RESEED_SPAN_DEG;
  const offset = (offsetDeg * Math.PI) / 180;
  let theta = prev + offset;
  theta = ((theta % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2);
  return theta;
}

function coverageFor(level: number): number {
  return 0.16 + 0.76 * Math.sqrt(clamp01(level));
}

/** dimple amount (0..1) and the level for a given point inside a drain
 * event of duration DRAIN_MS, given elapsed ms into it. */
function drainFrame(
  elapsed: number,
  levelAtStart: number,
  dropFraction: number
): { dimple: number; level: number } {
  const u = clamp01(elapsed / DRAIN_MS);
  const residual = levelAtStart * (1 - dropFraction);
  if (u < DIMPLE_END) {
    return { dimple: smoothstep(0, DIMPLE_END, u), level: levelAtStart };
  }
  if (u < FALL_END) {
    const t = easeInOutCubic((u - DIMPLE_END) / (FALL_END - DIMPLE_END));
    return { dimple: 1, level: levelAtStart + (residual - levelAtStart) * t };
  }
  const recede = smoothstep(FALL_END, 1, u);
  return { dimple: 1 - recede, level: residual };
}

export interface MeltPondDrainProps {
  /** extra classes merged onto the rendered root element */
  className?: string;
}

export function MeltPondDrain({ className = "" }: MeltPondDrainProps) {
  const canvasRef = useRef<HTMLCanvasElement | null>(null);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    const rand = mulberry32(0xd4a17e ^ Date.now());
    const reducedMq = window.matchMedia("(prefers-reduced-motion: reduce)");

    let disposed = false;
    let visible = true;
    let raf = 0;
    let last = 0;
    let dpr = 1;
    let w = 0;
    let h = 0;
    let sized = false;

    let fgRgb: [number, number, number] = [23, 23, 23];
    let mutedHex = "#4d4d4d";
    let waterAlphaBase = 0.55;

    const readTokens = () => {
      const cs = getComputedStyle(document.documentElement);
      const fgHex = cs.getPropertyValue("--foreground").trim();
      fgRgb = parseHex(fgHex) ?? fgRgb;
      mutedHex = cs.getPropertyValue("--ns-muted").trim() || mutedHex;
      const lum = relLuminance(fgRgb);
      // dark theme: foreground reads pale/bright -> water sits near its
      // bright end, high alpha ("pale reflective"). Light theme: foreground
      // reads dark -> water stays a moderate, subtle value step, leaning on
      // the specular/edge strokes below for definition rather than a flat
      // dark fill fighting the copy ink.
      waterAlphaBase = 0.42 + lum * 0.42;
    };
    readTokens();

    const fg = (a: number) => `rgba(${fgRgb[0]},${fgRgb[1]},${fgRgb[2]},${a})`;
    const muted = (a: number) => {
      const rgb = parseHex(mutedHex) ?? [128, 128, 128];
      return `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${a})`;
    };

    // Seeded mid-drain: the pond is already draining at mount, guaranteeing
    // a visibly different level by 2.5s and a completed drain-to-refill
    // cycle boundary well inside the 5s check, regardless of RNG.
    const seedTheta = rand() * Math.PI * 2;
    const state: DrainState = {
      phase: "drain",
      level: 0.8 + rand() * 0.12,
      fillElapsed: 0,
      fillDuration: FILL_MS_MIN + rand() * (FILL_MS_MAX - FILL_MS_MIN),
      drainElapsed: rand() * DRAIN_MS * 0.6,
      levelAtDrainStart: 0.8 + rand() * 0.12,
      dropFraction: DROP_MIN + rand() * (DROP_MAX - DROP_MIN),
      thetaDrain: seedTheta,
      prevTheta: seedTheta,
    };
    state.levelAtDrainStart = state.level;

    const advance = (dtMs: number) => {
      if (state.phase === "drain") {
        state.drainElapsed += dtMs;
        const frame = drainFrame(state.drainElapsed, state.levelAtDrainStart, state.dropFraction);
        state.level = frame.level;
        if (state.drainElapsed >= DRAIN_MS) {
          state.level = state.levelAtDrainStart * (1 - state.dropFraction);
          state.phase = "fill";
          state.fillElapsed = 0;
          state.fillDuration = FILL_MS_MIN + rand() * (FILL_MS_MAX - FILL_MS_MIN);
          state.prevTheta = state.thetaDrain;
        }
      } else {
        state.fillElapsed += dtMs;
        const t = easeInOutCubic(clamp01(state.fillElapsed / state.fillDuration));
        const residual = state.levelAtDrainStart * (1 - state.dropFraction);
        state.level = residual + (FULL_LEVEL - residual) * t;
        if (state.fillElapsed >= state.fillDuration) {
          state.phase = "drain";
          state.drainElapsed = 0;
          state.levelAtDrainStart = FULL_LEVEL;
          state.thetaDrain = pickNextTheta(state.prevTheta, rand);
        }
      }
    };

    const currentDimple = (): number => {
      if (state.phase !== "drain") return 0;
      return drainFrame(state.drainElapsed, state.levelAtDrainStart, state.dropFraction).dimple;
    };

    const draw = () => {
      if (!sized) return;
      ctx.clearRect(0, 0, w, h);

      const cx = w / 2;
      const cy = h / 2;
      const smaller = Math.min(w, h);
      const rx = smaller * 0.46;
      const ry = rx * 0.6;

      // basin depression: floor fill + rim hairline, --ns-muted only
      ctx.beginPath();
      ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
      ctx.fillStyle = muted(0.16);
      ctx.fill();
      ctx.lineWidth = 1;
      ctx.strokeStyle = muted(0.38);
      ctx.stroke();

      const dimple = currentDimple();
      const theta = state.thetaDrain;
      const coverage = coverageFor(state.level);

      const pts: [number, number][] = [];
      for (let i = 0; i < N_POINTS; i++) {
        const angle = (i / N_POINTS) * Math.PI * 2;
        const dist = Math.abs(angleDelta(angle, theta));
        const bump = smoothstep(DIMPLE_HALF_WIDTH, 0, dist);
        const indent = dimple * bump * DIMPLE_DEPTH;
        const r = coverage * (1 - indent);
        pts.push([cx + Math.cos(angle) * rx * r, cy + Math.sin(angle) * ry * r]);
      }

      // water surface, smoothed closed curve through the sampled points
      ctx.beginPath();
      const first = pts[0]!;
      const lastPt = pts[N_POINTS - 1]!;
      ctx.moveTo((first[0] + lastPt[0]) / 2, (first[1] + lastPt[1]) / 2);
      for (let i = 0; i < N_POINTS; i++) {
        const p = pts[i]!;
        const next = pts[(i + 1) % N_POINTS]!;
        const midX = (p[0] + next[0]) / 2;
        const midY = (p[1] + next[1]) / 2;
        ctx.quadraticCurveTo(p[0], p[1], midX, midY);
      }
      ctx.closePath();
      ctx.fillStyle = fg(waterAlphaBase);
      ctx.fill();
      ctx.lineWidth = 1;
      ctx.strokeStyle = fg(Math.min(0.95, waterAlphaBase + 0.22));
      ctx.stroke();

      // specular highlight — a soft arc toward the upper-left of the
      // surface, the value-step cue the light-theme spec calls for
      ctx.beginPath();
      ctx.ellipse(
        cx - rx * coverage * 0.32,
        cy - ry * coverage * 0.4,
        rx * coverage * 0.34,
        ry * coverage * 0.18,
        -0.4,
        0,
        Math.PI * 2
      );
      ctx.fillStyle = fg(Math.min(0.95, waterAlphaBase + 0.3));
      ctx.globalAlpha = 0.35;
      ctx.fill();
      ctx.globalAlpha = 1;

      // drain crack — a small zigzag from the water edge out to the rim,
      // visible only while a dimple is forming/holding/receding
      if (dimple > 0.02) {
        const edgeR = coverage * (1 - dimple * DIMPLE_DEPTH);
        const ex = cx + Math.cos(theta) * rx * edgeR;
        const ey = cy + Math.sin(theta) * ry * edgeR;
        const rimX = cx + Math.cos(theta) * rx * 0.97;
        const rimY = cy + Math.sin(theta) * ry * 0.97;
        const midX1 = ex + (rimX - ex) * 0.35 + Math.cos(theta + Math.PI / 2) * 4;
        const midY1 = ey + (rimY - ey) * 0.35 + Math.sin(theta + Math.PI / 2) * 4;
        const midX2 = ex + (rimX - ex) * 0.7 - Math.cos(theta + Math.PI / 2) * 4;
        const midY2 = ey + (rimY - ey) * 0.7 - Math.sin(theta + Math.PI / 2) * 4;
        ctx.beginPath();
        ctx.moveTo(ex, ey);
        ctx.lineTo(midX1, midY1);
        ctx.lineTo(midX2, midY2);
        ctx.lineTo(rimX, rimY);
        ctx.strokeStyle = muted(0.5 * dimple + 0.15);
        ctx.lineWidth = 1.25;
        ctx.stroke();
      }
    };

    const resize = () => {
      const rect = canvas.getBoundingClientRect();
      w = rect.width;
      h = rect.height;
      if (w < 2 || h < 2) {
        sized = false;
        return;
      }
      dpr = Math.min(window.devicePixelRatio || 1, 2);
      canvas.width = Math.max(1, Math.round(w * dpr));
      canvas.height = Math.max(1, Math.round(h * dpr));
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      sized = true;
    };

    const loop = (now: number) => {
      raf = 0;
      if (!visible || !sized) return;
      const dt = last ? Math.min(200, now - last) : 1000 / 60;
      last = now;
      advance(dt);
      draw();
      raf = requestAnimationFrame(loop);
    };

    const drawReducedFrame = () => {
      // deliberately NOT t0's live seed: a fixed, hand-chosen mid-drain
      // instant — dimple fully formed, level partway through its fall,
      // floor partly exposed — the single most structured frame.
      const frozen: DrainState = {
        ...state,
        phase: "drain",
        levelAtDrainStart: 0.88,
        dropFraction: 0.85,
        drainElapsed: DRAIN_MS * 0.55,
        thetaDrain: seedTheta,
      };
      const frame = drainFrame(frozen.drainElapsed, frozen.levelAtDrainStart, frozen.dropFraction);
      state.level = frame.level;
      state.phase = "drain";
      state.drainElapsed = frozen.drainElapsed;
      state.levelAtDrainStart = frozen.levelAtDrainStart;
      state.dropFraction = frozen.dropFraction;
      state.thetaDrain = frozen.thetaDrain;
      draw();
    };

    const start = () => {
      if (reducedMq.matches) {
        drawReducedFrame();
        return;
      }
      last = 0;
      if (!raf) raf = requestAnimationFrame(loop);
    };

    const stop = () => {
      cancelAnimationFrame(raf);
      raf = 0;
    };

    const ro = new ResizeObserver(() => {
      resize();
      if (reducedMq.matches) drawReducedFrame();
    });
    ro.observe(canvas);

    const io = new IntersectionObserver((entries) => {
      visible = entries[0]?.isIntersecting ?? true;
      if (visible && !reducedMq.matches) start();
      else stop();
    });
    io.observe(canvas);

    const mo = new MutationObserver(() => {
      readTokens();
      if (reducedMq.matches) drawReducedFrame();
    });
    mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });

    const onReducedChange = () => {
      if (reducedMq.matches) {
        stop();
        drawReducedFrame();
      } else {
        start();
      }
    };
    reducedMq.addEventListener("change", onReducedChange);

    const onVis = () => {
      if (document.hidden) stop();
      else if (visible && !reducedMq.matches) start();
    };
    document.addEventListener("visibilitychange", onVis);

    resize();
    if (sized) {
      if (reducedMq.matches) drawReducedFrame();
      else start();
    }

    return () => {
      disposed = true;
      stop();
      ro.disconnect();
      io.disconnect();
      mo.disconnect();
      reducedMq.removeEventListener("change", onReducedChange);
      document.removeEventListener("visibilitychange", onVis);
      void disposed;
    };
  }, []);

  return (
    <div role="img" aria-label="Loading" className={`relative h-full w-full ${className}`}>
      <canvas ref={canvasRef} aria-hidden="true" className="block h-full w-full" />
    </div>
  );
}

MeltPondDrain.displayName = "MeltPondDrain";
Build spec

A card-scale (`w-full h-full`) canvas-2D ambient panel with zero props to bind and zero pointer interaction — the mechanic is melt/gravity driven, never touch driven. Internally it runs a small state machine with two phases, `fill` and `drain`, over a single scalar `level` (0-1, the fraction of the basin's near-full threshold FULL_LEVEL=0.93 currently reached). `fill` interpolates level from a post-drain residual up to FULL_LEVEL over a randomized 9-11s duration (eased in/out cubic) before flipping to `drain`. `drain` is a fixed 1200ms event with three stages driven off elapsed-fraction u: 0-30% grows a `dimple` scalar 0->1 via smoothstep while level holds (the funnel forming); 30-85% holds dimple at 1 while level eases from its pre-drain value down to a residual = preDrainLevel * (1 - dropFraction), dropFraction randomized 0.8-0.9 each event (never to zero — a shallow residual pool always remains); 85-100% eases dimple back to 0 while level holds at the residual (the floor settling). On completing a drain, the NEXT drain's rim location `thetaDrain` (an angle around the basin ellipse) is chosen as the previous angle plus a random 100-260deg offset, so it always lands at a materially different spot, never the same rim point twice running. The basin is an ellipse (rx = 0.46x the container's SMALLER dimension, ry = 0.6x that) drawn as a flat --ns-muted fill at ~16% alpha plus a ~38% alpha rim hairline, both real geometry so it reads as a depression regardless of theme. The water surface is 64 points sampled evenly around the ellipse, each point's radius = a shared `coverage` factor (0.16 + 0.76*sqrt(level), so the residual pool is visibly smaller than a near-full one and the basin rim is never fully consumed) minus a local indent near thetaDrain (smoothstep falloff over a ~35deg half-width, scaled by `dimple`, max ~24% of the water radius) — the two effects compose into one physical read: the whole pond visibly shrinks (level) while a funnel dimple bites in locally at the drain point (dimple), exactly the departure/arrival sequence the round-9 cadence rule requires rather than an instant level-snap. Those points are stitched into a smoothed closed quadratic-curve path and filled/stroked in the --foreground token; the fill alpha is not a hardcoded per-theme branch but computed once from --foreground's own read relative luminance (0.42 + luminance*0.42), so a bright (dark-theme) foreground lands near its pale, reflective end and a dark (light-theme) foreground still reads as a distinct, subtler value step rather than flat pale-on-pale, backed by a soft specular highlight ellipse and a slightly-higher-alpha edge stroke for definition in the harder light-theme case. While `dimple` is above ~0.02, a small three-segment zigzag crack is drawn in --ns-muted from the water's edge at thetaDrain out to the basin rim, alpha scaled by dimple, so the crack itself only exists for the ~800ms it is mechanically relevant. At mount the whole state machine is seeded already mid-drain (levelAtDrainStart 0.8-0.92, drainElapsed a random 0-60% into the 1200ms event) rather than at a random point in a 9-11s fill — this guarantees the level is visibly different by 2.5s and that at least one full drain-to-refill cycle boundary has been crossed well inside the 5s resting-loop check, deterministically, regardless of the RNG seed. Colours are read once via getComputedStyle(document.documentElement) for --foreground/--ns-muted and re-read on a MutationObserver watching its class attribute (no paint happens before that first read); --ns-accent and --border never touch the water surface or the crack. Canvas backing store is dpr-clamped to 2 and resized via ResizeObserver on the canvas itself (not window resize, since this is a card-scale element that can resize independently of the viewport); a paired IntersectionObserver pauses the rAF loop when the card scrolls off-screen and resumes it on return, alongside a visibilitychange listener for background tabs. `prefers-reduced-motion` freezes on a deliberately fixed mid-drain instant (dimple fully formed at 1, drain 55% elapsed so level is partway through its fall, floor partly exposed) rather than the live t0 seed or a flat full/empty pond — the single most structured frame, showing basin, water level, AND the drain mechanism at once. `autoplay: none` because the cycle runs on its own internal clock with no pointer/scroll/press input for a synthetic-input driver to act on. The root carries `role="img" aria-label="Loading"` since this occupies a loader/empty-state slot with no discrete states to announce; the canvas itself is `aria-hidden`. Zero dependencies, canvas-2D only, no WebGL, no SVG.

Props

PropTypeDefaultDescription
className?stringextra classes merged onto the rendered root element