Skip to main content

ns-ui

Braze Capillary Fill

A progress/validation-fill indicator rendered as torch brazing: molten filler is drawn sideways along a hairline joint gap by capillary action, brightest at the leading front, settling into a solidified track behind it, and pooling into a fillet the instant it reaches the far end before cooling, fading, and starting a fresh joint.

Use when a fill/progress indicator whose motion should read as a directional, physical process — flow drawn along a seam toward a fixed endpoint, not a bar racing to a percentage — and where a continuous unforced idle loop (no press required) is wanted for a catalog/hero context. Runs its own ambient fill/hold/fade cycle by default; pass `progress` (0-1) to pin the front to real external state instead. Pick meter-latency-capillary instead when the reading needs to be honest about a calibrated p50/p95 wait time and must HOLD rather than fake progress past what's earned — that component never loops ambiently, it is driven entirely by real elapsed time against a distribution. Pick progress-wick instead for a generic linear fill with no directional joint/fillet semantics.

Install

npx shadcn add https://design.helpmarq.com/r/braze-capillary-fill.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/braze-capillary-fill/component.tsx
"use client";

import { useEffect, useRef } from "react";

// ---------------------------------------------------------------------------
// BrazeCapillaryFill — a progress/validation-fill indicator rendered as
// torch brazing: molten filler wicked sideways along a hairline joint gap
// by capillary action, not gravity or pressure.
//
// SOURCE: torch brazing (AWS Brazing Handbook). Filler at the joint mouth
// is drawn INTO a closely-fitted gap by capillary action — flow runs toward
// the hottest, narrowest part of the gap, not downhill — and a fillet only
// forms once filler reaches the far end and pools under surface tension.
// A well-fitted gap wicks fast and completely; a loose one just puddles.
//
// The front sweeps the gap once per loop, unforced, on its own internal
// clock — this is a resting ambient loop, not a press-driven animation; an
// optional `progress` prop can pin the front to real external state instead
// (see props), but the demo loop below runs with zero input regardless.
//
// Monochrome: every value is fg alpha over the bg backdrop, never a
// literal colour. "Molten" reads as the brightest (highest-alpha, freshest)
// point on the track; solidified fill settles to a lower, steady alpha;
// the unfilled gap ahead is un-painted (background) with only the border-
// toned sheet edges either side of it.
// ---------------------------------------------------------------------------

const FRONT_S = 6.3; // capillary front sweeps the full gap length, 0->1
const MENISCUS_S = 0.9; // fillet bulge grows once the front reaches the far end
const FILL_S = 9.4; // total "fill" era — front + meniscus + a quiet settle tail
const HOLD_S = 2.1; // full fillet holds
const FADE_S = 1.2; // fades back to an empty gap (a fresh joint, never a reverse wipe)
const LOOP_S = FILL_S + HOLD_S + FADE_S; // 12.7s

const FREEZE_PROGRESS = 0.62; // reduced-motion freeze: front mid-gap, wetted trail
// behind it and unfilled gap ahead both visible in the same frame — neither the
// empty t0 frame nor the full-fillet hold shows both halves of the mechanic at once.
const FREEZE_T = FREEZE_PROGRESS * FRONT_S;

const INSET_FRAC = 0.06; // seam starts/ends this fraction of width in from each edge
const SOLID_ALPHA = 0.68; // settled/solidified fill
const MOLTEN_ALPHA = 1; // brightest — the front itself and a fresh fillet
const GLOW_FRAC = 0.32; // fraction of the seam span the molten glow trails behind the front
const GLOW_WOBBLE_HZ = 0.08; // standing for local gap-width variance along the seam
const GLOW_WOBBLE_AMOUNT = 0.2;

function frontProgress(t: number): number {
  return Math.min(1, t / FRONT_S);
}

function meniscusProgress(t: number): number {
  return Math.min(1, Math.max(0, (t - FRONT_S) / MENISCUS_S));
}

export interface BrazeCapillaryFillProps {
  /** Accessible label for the reading, e.g. "Import progress". */
  label?: string;
  /** Canvas panel height in px. Default 96. */
  height?: number;
  /**
   * Pin the front to real external progress (0-1) instead of the ambient
   * demo loop. When omitted, the component runs its own unforced fill/
   * hold/fade cycle continuously — this is the default and what the
   * catalog card shows.
   */
  progress?: number;
  className?: string;
}

export function BrazeCapillaryFill({
  label = "Fill progress",
  height = 96,
  progress,
  className = "",
}: BrazeCapillaryFillProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const progressRef = useRef(progress);
  progressRef.current = progress;

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

    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    let fg = "";
    let bg = "";
    let border = "";

    // fallbacks are CSS keywords, never literal colour values
    const readTokens = () => {
      const root = getComputedStyle(document.documentElement);
      fg = root.getPropertyValue("--foreground").trim() || "currentColor";
      bg = root.getPropertyValue("--background").trim() || "Canvas";
      border = root.getPropertyValue("--border").trim() || "currentColor";
    };

    let w = 0;
    let h = 0;
    let sized = false;

    const resize = () => {
      const rect = canvas.getBoundingClientRect();
      if (rect.width < 2 || rect.height < 2) {
        sized = false;
        return;
      }
      const dpr = Math.min(window.devicePixelRatio || 1, 2);
      w = rect.width;
      h = rect.height;
      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 draw = (t: number) => {
      if (!sized) return;
      ctx.clearRect(0, 0, w, h);

      const cy = h / 2;
      const gapHalf = Math.max(2, Math.min(h, w) * 0.03);
      const startX = w * INSET_FRAC;
      const endX = w * (1 - INSET_FRAC);
      const span = endX - startX;

      // sheet edges either side of the joint — separator lines, --border only.
      // Left edge sits flush at startX (the joint mouth, where the front must
      // visibly begin) — only the exit side overhangs, giving the fillet
      // meniscus room to bulge past endX.
      ctx.strokeStyle = border;
      ctx.lineWidth = 1;
      ctx.globalAlpha = 1;
      ctx.beginPath();
      ctx.moveTo(startX, cy - gapHalf);
      ctx.lineTo(endX + h * 0.3, cy - gapHalf);
      ctx.moveTo(startX, cy + gapHalf);
      ctx.lineTo(endX + h * 0.3, cy + gapHalf);
      ctx.stroke();

      // -- phase resolution ---------------------------------------------
      const pinned = progressRef.current;
      let fillP: number;
      let meniscusP: number;
      let cycleAlpha = 1; // global fade multiplier for the fade-out phase
      let molten = MOLTEN_ALPHA;

      if (typeof pinned === "number") {
        fillP = Math.min(1, Math.max(0, pinned));
        meniscusP = fillP >= 1 ? 1 : 0;
      } else {
        const cycleT = t % LOOP_S;
        if (cycleT < FILL_S) {
          fillP = frontProgress(cycleT);
          meniscusP = meniscusProgress(cycleT);
        } else if (cycleT < FILL_S + HOLD_S) {
          fillP = 1;
          meniscusP = 1;
          // the fillet cools across the hold — molten brightness eases down
          // to the settled alpha rather than sitting at peak the whole time
          const holdT = (cycleT - FILL_S) / HOLD_S;
          molten = MOLTEN_ALPHA - (MOLTEN_ALPHA - SOLID_ALPHA) * Math.min(1, holdT * 1.6);
        } else {
          fillP = 1;
          meniscusP = 1;
          const fadeT = (cycleT - FILL_S - HOLD_S) / FADE_S;
          cycleAlpha = Math.max(0, 1 - fadeT);
          molten = SOLID_ALPHA;
        }
      }

      const frontX = startX + fillP * span;

      // -- solidified track (behind the front) ---------------------------
      if (fillP > 0) {
        ctx.globalAlpha = SOLID_ALPHA * cycleAlpha;
        ctx.fillStyle = fg;
        ctx.fillRect(startX, cy - gapHalf, frontX - startX, gapHalf * 2);
      }

      // -- molten glow trailing the front ---------------------------------
      if (fillP > 0 && fillP < 1) {
        const wobble = 1 + GLOW_WOBBLE_AMOUNT * Math.sin(2 * Math.PI * GLOW_WOBBLE_HZ * t);
        const glowLen = Math.max(4, GLOW_FRAC * span * wobble);
        const glowStart = Math.max(startX, frontX - glowLen);
        const grad = ctx.createLinearGradient(glowStart, 0, frontX, 0);
        grad.addColorStop(0, hexWithAlpha(fg, SOLID_ALPHA * cycleAlpha));
        grad.addColorStop(1, hexWithAlpha(fg, molten * cycleAlpha));
        ctx.fillStyle = grad;
        ctx.globalAlpha = 1;
        ctx.fillRect(glowStart, cy - gapHalf, frontX - glowStart, gapHalf * 2);
      }

      // -- fillet meniscus at the exit, once the front has arrived --------
      if (meniscusP > 0) {
        const maxR = gapHalf * 2.6;
        const r = maxR * easeOutBack(meniscusP);
        ctx.globalAlpha = molten * cycleAlpha;
        ctx.fillStyle = fg;
        ctx.beginPath();
        ctx.ellipse(endX, cy, r * 0.55, r, 0, 0, Math.PI * 2);
        ctx.fill();
      }

      ctx.globalAlpha = 1;
    };

    // -- loop ------------------------------------------------------------
    let raf = 0;
    let last = 0;
    let globalT = 0;

    const loop = (now: number) => {
      const dtMs = last ? Math.min(250, now - last) : 1000 / 60;
      last = now;
      globalT += dtMs / 1000;
      draw(globalT);
      if (!document.hidden) raf = requestAnimationFrame(loop);
    };

    const mo = new MutationObserver(() => {
      readTokens();
      draw(reduced ? FREEZE_T : globalT);
    });
    mo.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class"],
    });

    let resizeTimer: ReturnType<typeof setTimeout> | null = null;
    const onResize = () => {
      if (resizeTimer) clearTimeout(resizeTimer);
      resizeTimer = setTimeout(() => {
        resizeTimer = null;
        readTokens();
        resize();
        draw(reduced ? FREEZE_T : globalT);
      }, 150);
    };
    const ro = new ResizeObserver(onResize);
    ro.observe(canvas);

    const io = new IntersectionObserver(
      (entries) => {
        const visible = entries[0]?.isIntersecting;
        if (visible && !reduced && sized) {
          cancelAnimationFrame(raf);
          last = 0;
          raf = requestAnimationFrame(loop);
        } else if (!visible) {
          cancelAnimationFrame(raf);
        }
      },
      { threshold: 0 }
    );
    io.observe(canvas);

    const onVis = () => {
      cancelAnimationFrame(raf);
      if (!document.hidden && !reduced && sized) {
        last = 0;
        raf = requestAnimationFrame(loop);
      }
    };
    document.addEventListener("visibilitychange", onVis);

    // no paint before the first token read
    readTokens();
    resize();

    if (reduced) {
      draw(FREEZE_T);
    } else {
      draw(0);
      raf = requestAnimationFrame(loop);
    }

    return () => {
      cancelAnimationFrame(raf);
      if (resizeTimer) clearTimeout(resizeTimer);
      mo.disconnect();
      ro.disconnect();
      io.disconnect();
      document.removeEventListener("visibilitychange", onVis);
    };
  }, [height]);

  return (
    <div className={className}>
      <div className="flex items-baseline justify-between gap-3">
        <span className="font-mono text-[11px] tracking-wide text-ns-muted">
          {label.toUpperCase()}
        </span>
        <span className="font-mono text-[11px] tracking-wide text-ns-muted">CAPILLARY FILL</span>
      </div>
      <div
        role="img"
        aria-label={`${label}: molten filler wicking along a joint gap, filling it and forming a fillet at the far end`}
        className="mt-2"
      >
        <canvas ref={canvasRef} aria-hidden="true" className="block w-full" style={{ height }} />
      </div>
    </div>
  );
}

/** Cubic ease-out with a slight overshoot then settle — a meniscus builds
 * past its resting bulge before easing back, not a flat asymptotic grow. */
function easeOutBack(x: number): number {
  const c1 = 1.4;
  const c3 = c1 + 1;
  return 1 + c3 * Math.pow(x - 1, 3) + c1 * Math.pow(x - 1, 2);
}

/** #rrggbb -> rgba(...) string at the given alpha. Arithmetic on the token's
 * own channels only — never a new literal hue. */
function hexWithAlpha(hex: string, alpha: number): string {
  // Expand 3-digit shorthand first: this project's light --background is "#fff",
  // and returning it unchanged silently dropped the alpha, turning every fade
  // opaque in light theme while dark theme (6-digit tokens) looked correct.
  let clean = hex.trim().replace("#", "");
  if (clean.length === 3) {
    clean = clean[0] + clean[0] + clean[1] + clean[1] + clean[2] + clean[2];
  }
  if (clean.length !== 6) return hex;
  const r = parseInt(clean.slice(0, 2), 16);
  const g = parseInt(clean.slice(2, 4), 16);
  const b = parseInt(clean.slice(4, 6), 16);
  return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
Build spec

Renders a progress/fill state as torch brazing: a horizontal hairline joint gap (two --border sheet-edge lines either side of a small canvas-drawn channel) that a bright capillary front sweeps left to right on its own internal clock, unforced, looping continuously with zero input — this is the default and what the catalog card shows with no props at all. The loop has three eras totalling 12.7s: a 9.4s fill era (the front sweeps the gap over its first 6.3s on a linear 0-1 progress, then a 0.9s fillet-meniscus grows at the far end with a slight overshoot-and-settle via a custom ease, then a ~2.2s quiet tail before the next era), a 2.1s hold at full fillet during which the molten alpha eases down from its peak to the settled solidified alpha (a 'cooling' cue), and a 1.2s fade of the entire filled track and fillet's opacity back to zero — a fresh empty joint, never a reverse wipe of the front position, since brazing doesn't unmelt. Everything drawn is fg (--foreground) at a modulated alpha over the bg backdrop, never a literal colour: the solidified trailing track sits at 0.68 alpha, the molten leading edge (a linear gradient trailing behind the front, its length wobbling ±20% at a slow 0.08Hz sine standing in for local gap-width variance along the seam) rises to 1.0 alpha, and the unfilled gap ahead of the front is simply unpainted background between two --border reference lines (the sheet edges, drawn as pure separators, full alpha since --border's own low contrast already keeps them structural rather than a fill). Canvas height defaults to 96px; geometry (gap half-thickness, meniscus radius) derives from min(canvas width, canvas height) so it holds up at card scale. Driven by requestAnimationFrame with elapsed-time accumulation (delta capped at 250ms so a backgrounded tab can't produce a giant catch-up jump), paused via IntersectionObserver (threshold 0) when scrolled offscreen and visibilitychange when the tab is hidden, resumed cleanly on return. Tokens (--foreground, --background, --border) are read via getComputedStyle(document.documentElement) before the very first draw call and re-read on a MutationObserver watching documentElement's class attribute, with every resize path (ResizeObserver on the canvas, debounced 150ms) re-reading tokens too so a theme flip mid-resize never paints stale colour. Under prefers-reduced-motion the canvas freezes at FREEZE_PROGRESS = 0.62 (front 62% across the gap, 3.906s into the notional 6.3s front-sweep) — chosen because it is the one frame that shows both halves of the mechanic at once: solidified/molten track behind the front and untouched gap ahead of it, which neither an empty t0 frame nor the full-fillet hold frame shows on its own. An optional `progress` prop (0-1) pins the front position directly to real external state instead of the ambient demo clock, for wiring this up as an actual upload/import/save indicator rather than a decorative loop; when a fillet is warranted (progress >= 1) it renders at full molten alpha with no hold/fade cycle, since a real completed action doesn't loop. The whole canvas is aria-hidden with a sibling role=img element carrying a static accessible label describing the mechanic, since the continuously-changing fill position is not meaningful to announce moment-to-moment (same non-narration-of-continuous-value pattern as meter-latency-capillary). Zero dependencies, single canvas element plus a small DOM label header, no WebGL.

Props

PropTypeDefaultDescription
label?string"Fill progress"Accessible label for the reading, e.g. "Import progress".
height?number96Canvas panel height in px. Default 96.
progress?numberPin the front to real external progress (0-1) instead of the ambient demo loop. When omitted, the component runs its own unforced fill/ hold/fade cycle continuously — this is the default and what the catalog card shows.
className?string