Skip to main content

ns-ui

Float Ribbon Draw

A multi-stage pipeline/job-status bar modeled on the float-glass process: a fixed molten-to-set thermal gradient marks the pipeline's stages while a faint ripple scrolls continuously through the still-molten zone at a constant draw speed, so the bar keeps reading as 'actively running' whether or not a specific stage is highlighted.

Usage

registry/core/float-ribbon-draw/demo.tsx
"use client";

import { useEffect, useState } from "react";
import { FloatRibbonDraw } from "./component";

const STAGES = ["Queued", "Uploading", "Processing", "Done"];
const STAGE_HOLD_MS = 2600; // per-stage dwell before advancing to the next

export default function FloatRibbonDrawDemo() {
  const [activeStage, setActiveStage] = useState(0);

  useEffect(() => {
    const t = window.setInterval(() => {
      setActiveStage((s) => (s + 1) % STAGES.length);
    }, STAGE_HOLD_MS);
    return () => window.clearInterval(t);
  }, []);

  return (
    <div className="flex min-h-screen w-full flex-col items-center justify-center gap-14 bg-background px-8 py-10">
      <div className="w-full max-w-md">
        <h2 className="mb-3 font-mono text-sm text-foreground">Job pipeline status</h2>
        <p className="mb-6 max-w-prose font-mono text-xs text-ns-muted">
          Fixed stage ticks over a ribbon that never stops drawing — the
          thermal gradient marks the pipeline, the ripple marks that
          material is actively moving through it right now.
        </p>
        <FloatRibbonDraw
          stages={STAGES}
          activeStage={activeStage}
          label={`Job pipeline — ${STAGES[activeStage]}`}
        />
      </div>

      <div className="w-full max-w-md">
        <h2 className="mb-3 font-mono text-sm text-foreground">Ambient, no stages</h2>
        <p className="mb-6 max-w-prose font-mono text-xs text-ns-muted">
          The same ribbon with no stage overlay — a bare processing
          indicator for when there is nothing more specific to report.
        </p>
        <FloatRibbonDraw label="Background sync" />
      </div>
    </div>
  );
}

Component

registry/core/float-ribbon-draw/component.tsx
"use client";

import { useEffect, useRef, useState } from "react";

// ---------------------------------------------------------------------------
// FloatRibbonDraw — a multi-stage pipeline/job-status bar modeled on the
// float-glass process (the Pilkington process, the method that produces
// essentially all flat glass made today): molten glass flows continuously
// onto a bath of molten tin, floats and spreads to its natural equilibrium
// thickness, and is drawn forward by top rollers at a constant line speed
// while it cools along the bath's length, entering molten (~1,000C) and
// exiting set (~600C). That process has no start/stop within a run — it is
// a single continuous draw — which is exactly the shape most "job status"
// widgets get wrong by pausing their idle animation while "waiting."
//
// TWO SEPARATE THINGS, deliberately not conflated:
//  - The THERMAL GRADIENT (bright/dense "molten" -> dim "set", left to
//    right) is a FIXED function of position along the bar. It represents
//    the pipeline's stages, and never itself animates — pausing it would
//    misrepresent a process stage as somehow reversible.
//  - The RIPPLE (a faint sine wobble riding the top edge, confined to the
//    still-molten first third and damped out by where the gradient has
//    mostly set) is what SCROLLS continuously at a constant rate. It is the
//    "alive at rest" signal: material is actively moving through the
//    pipeline right now, regardless of which stage is highlighted.
//
// Colour is luminance-only: every fill is a lerp between --ns-muted (t=0,
// "set") and --foreground (t=1, "molten") — never a literal orange/red heat
// colour. That mapping holds in both themes because --foreground is already
// the theme's highest-density ink (near-white in dark, near-black in
// light), so "hot" reads as "densest" in both, not as a hue.
//
// Optional `stages`/`activeStage` overlay fixed pipeline-stage tick marks
// (queued/processing/done, etc.) at even x-fractions along the bar — the
// ribbon's own gradient/ripple keeps running unconditionally underneath
// regardless of which stage is active, per the real process never stopping
// mid-draw.
// ---------------------------------------------------------------------------

type Vec3 = [number, number, number];

// Same parse+lerp idiom used elsewhere in this registry (house convention,
// duplicated per component rather than shared).
function parseColor(raw: string): Vec3 | null {
  const s = raw.trim();
  if (s.startsWith("#")) {
    const hex = s.slice(1);
    if (hex.length === 3) {
      const r = parseInt(hex[0]! + hex[0], 16);
      const g = parseInt(hex[1]! + hex[1], 16);
      const b = parseInt(hex[2]! + hex[2], 16);
      return Number.isNaN(r + g + b) ? null : [r, g, b];
    }
    if (hex.length >= 6) {
      const r = parseInt(hex.slice(0, 2), 16);
      const g = parseInt(hex.slice(2, 4), 16);
      const b = parseInt(hex.slice(4, 6), 16);
      return Number.isNaN(r + g + b) ? null : [r, g, b];
    }
    return null;
  }
  const m = s.match(/rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/);
  return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
}

function lerpColor(a: Vec3, b: Vec3, t: number): Vec3 {
  const c = Math.min(1, Math.max(0, t));
  return [
    Math.round(a[0] + (b[0] - a[0]) * c),
    Math.round(a[1] + (b[1] - a[1]) * c),
    Math.round(a[2] + (b[2] - a[2]) * c),
  ];
}

function rgbStr(v: Vec3, alpha = 1): string {
  return alpha >= 1 ? `rgb(${v[0]},${v[1]},${v[2]})` : `rgba(${v[0]},${v[1]},${v[2]},${alpha})`;
}

const SAMPLES = 64; // 1D luminance/ripple lookup across the ribbon's length
const LAMBDA = 0.35; // exp decay constant, in ribbon-lengths — how fast "hot" gives way to "set"
const T_FLOOR = 0.08; // luminance never fully bottoms out to pure --ns-muted
const RIBBON_FRAC = 0.28; // ribbon band height as a fraction of container height
const SCROLL_FRAC_PER_S = 0.08; // ripple scroll speed, fraction of container width per second
const RIPPLE_WAVELEN_FRAC = 0.18; // ripple wavelength, fraction of container width
const RIPPLE_AMP_FRAC = 0.09; // ripple amplitude, fraction of ribbon height
const MOLTEN_ZONE = 0.34; // ripple envelope is ~0 past this fraction along the ribbon

function luminanceAt(xFrac: number): number {
  return T_FLOOR + (1 - T_FLOOR) * Math.exp(-xFrac / LAMBDA);
}

// ripple envelope: full amplitude near the molten (left) edge, damped to 0
// by MOLTEN_ZONE — a raised-cosine falloff, not a hard cutoff.
function rippleEnvelope(xFrac: number): number {
  if (xFrac >= MOLTEN_ZONE) return 0;
  const t = xFrac / MOLTEN_ZONE;
  return 0.5 * (1 + Math.cos(t * Math.PI));
}

export interface FloatRibbonDrawProps {
  /** total component height in px; the ribbon band derives as 28% of this. Default 88. */
  height?: number;
  /** optional ordered pipeline stage labels, drawn as fixed ticks along the bar */
  stages?: string[];
  /** index into `stages` that is currently active/lit. Omit for a purely ambient ribbon with no stage semantics. */
  activeStage?: number;
  /** accessible label for the status region. Default "Processing pipeline" */
  label?: string;
  /** extra classes merged onto the rendered root element */
  className?: string;
}

export function FloatRibbonDraw({
  height = 88,
  stages,
  activeStage,
  label = "Processing pipeline",
  className = "",
}: FloatRibbonDrawProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const [announce, setAnnounce] = useState("");
  const lastAnnouncedRef = useRef<number | undefined>(undefined);

  useEffect(() => {
    if (!stages || activeStage === undefined) return;
    if (lastAnnouncedRef.current === activeStage) return;
    lastAnnouncedRef.current = activeStage;
    const s = stages[activeStage];
    if (s) setAnnounce(`Stage: ${s}`);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [activeStage, stages?.join("|")]);

  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 muted: Vec3 = [128, 128, 128];
    let fg: Vec3 = [0, 0, 0];
    let border: Vec3 = [128, 128, 128];
    let borderAlpha = 1;

    const readTokens = () => {
      const root = getComputedStyle(document.documentElement);
      muted = parseColor(root.getPropertyValue("--ns-muted")) ?? muted;
      fg = parseColor(root.getPropertyValue("--foreground")) ?? fg;
      border = parseColor(root.getPropertyValue("--border")) ?? border;
      // --border ships at low alpha via its own token in some themes; if the
      // computed value carries no alpha channel, keep full opacity and let
      // the token's own colour do the (deliberately faint) work.
      const m = root.getPropertyValue("--border").match(/rgba?\([^)]*,\s*([\d.]+)\s*\)/);
      borderAlpha = m ? Number(m[1]) : 1;
    };

    let width = 0;
    let sized = false;
    let ribbonH = 0;

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

    // scrollPx: how far the ripple texture has travelled, in css px. Never
    // resets — the draw is a single continuous run.
    let scrollPx = 0;

    const draw = () => {
      if (!sized) return;
      const rect = canvas.getBoundingClientRect();
      const h = rect.height;
      const top = (h - ribbonH) / 2;
      ctx.clearRect(0, 0, rect.width, h);

      // 1D sample lookup across the ribbon's length: luminance (fixed, a
      // function of x only) and ripple y-offset (a function of x and the
      // scroll phase), bilinearly interpolated between the SAMPLES points.
      const lum = new Float32Array(SAMPLES);
      const rip = new Float32Array(SAMPLES);
      const wavelenPx = Math.max(1, width * RIPPLE_WAVELEN_FRAC);
      const ampPx = ribbonH * RIPPLE_AMP_FRAC;
      for (let i = 0; i < SAMPLES; i++) {
        const xFrac = i / (SAMPLES - 1);
        lum[i] = luminanceAt(xFrac);
        const env = rippleEnvelope(xFrac);
        const xPx = xFrac * width;
        rip[i] = env * ampPx * Math.sin(((xPx + scrollPx) / wavelenPx) * Math.PI * 2);
      }

      const sampleAt = (arr: Float32Array, xFrac: number): number => {
        const pos = Math.min(SAMPLES - 1, Math.max(0, xFrac * (SAMPLES - 1)));
        const i0 = Math.floor(pos);
        const i1 = Math.min(SAMPLES - 1, i0 + 1);
        const f = pos - i0;
        const a = arr[i0] ?? 0;
        const b = arr[i1] ?? 0;
        return a + (b - a) * f;
      };

      // gradient fill along the ribbon's length, stopped at 12 points from
      // the pre-sampled luminance curve (the curve is exponential, not
      // linear, so a 2-stop canvas gradient would misrepresent it).
      const grad = ctx.createLinearGradient(0, 0, width, 0);
      const STOPS = 12;
      for (let s = 0; s <= STOPS; s++) {
        const xFrac = s / STOPS;
        const t = sampleAt(lum, xFrac);
        grad.addColorStop(xFrac, rgbStr(lerpColor(muted, fg, t)));
      }

      ctx.beginPath();
      ctx.moveTo(0, top + sampleAt(rip, 0));
      const STEP = 2; // px, top-edge ripple polyline resolution
      for (let x = 0; x <= width; x += STEP) {
        const xFrac = x / width;
        ctx.lineTo(x, top + sampleAt(rip, xFrac));
      }
      ctx.lineTo(width, top + ribbonH);
      ctx.lineTo(0, top + ribbonH);
      ctx.closePath();
      ctx.fillStyle = grad;
      ctx.fill();

      // a thin --border rule under the full ribbon length, keeping the
      // "set" (right, low-luminance) end from disappearing into --surface
      // in light theme.
      ctx.beginPath();
      ctx.moveTo(0, top + ribbonH + 0.5);
      ctx.lineTo(width, top + ribbonH + 0.5);
      ctx.lineWidth = 1;
      ctx.strokeStyle = rgbStr(border, borderAlpha);
      ctx.stroke();

      // fixed pipeline-stage ticks, if provided — luminance only, never
      // accent, drawn on top of the ribbon at even x-fractions.
      if (stages && stages.length > 0) {
        for (let i = 0; i < stages.length; i++) {
          const xFrac = stages.length > 1 ? i / (stages.length - 1) : 0.5;
          const x = xFrac * width;
          const active = activeStage === i;
          ctx.fillStyle = rgbStr(active ? fg : muted, active ? 0.9 : 0.55);
          const tickH = active ? ribbonH * 0.55 : ribbonH * 0.35;
          ctx.fillRect(Math.round(x - 0.5), top + (ribbonH - tickH) / 2, 1, tickH);
        }
      }
    };

    let raf = 0;
    let last = 0;
    let visible = true;

    const loop = (now: number) => {
      if (!visible || document.hidden) {
        raf = 0;
        return;
      }
      const dtMs = last ? Math.min(250, now - last) : 1000 / 60;
      last = now;
      scrollPx += width * SCROLL_FRAC_PER_S * (dtMs / 1000);
      draw();
      raf = requestAnimationFrame(loop);
    };

    const start = () => {
      if (raf || reduced) return;
      last = 0;
      raf = requestAnimationFrame(loop);
    };
    const stop = () => {
      cancelAnimationFrame(raf);
      raf = 0;
    };

    const mo = new MutationObserver(() => {
      readTokens();
      if (reduced) draw();
    });
    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();
      }, 120);
    };
    const ro = new ResizeObserver(onResize);
    ro.observe(canvas);

    const io = new IntersectionObserver(
      (entries) => {
        visible = entries[0]?.isIntersecting ?? true;
        if (visible) start();
        else stop();
      },
      { threshold: 0 }
    );
    io.observe(canvas);

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

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

    if (reduced) {
      // freeze at scroll-phase 0 — the ripple's most evenly-spread crest
      // layout across the molten zone, not an arbitrary mid-scroll moment.
      scrollPx = 0;
      draw();
    } else {
      draw();
      start();
    }

    return () => {
      stop();
      if (resizeTimer) clearTimeout(resizeTimer);
      mo.disconnect();
      ro.disconnect();
      io.disconnect();
      document.removeEventListener("visibilitychange", onVis);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [height, stages?.join("|"), activeStage]);

  return (
    <div role="group" aria-label={label} className={`relative w-full ${className}`} style={{ height }}>
      <canvas ref={canvasRef} aria-hidden="true" className="block h-full w-full" />
      {stages && stages.length > 0 && (
        <div
          aria-hidden="true"
          className="pointer-events-none absolute bottom-1 left-0 flex w-full justify-between px-0.5"
        >
          {stages.map((s, i) => (
            <span
              key={s}
              className="font-mono text-[9px] uppercase tracking-[0.1em]"
              style={{ color: activeStage === i ? "var(--foreground)" : "var(--ns-muted)" }}
            >
              {s}
            </span>
          ))}
        </div>
      )}
      <p role="status" aria-live="polite" className="sr-only">
        {announce}
      </p>
    </div>
  );
}

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/float-ribbon-draw.tsx, so point the import there.

Use when Pick float-ribbon-draw for a horizontal multi-stage job/pipeline status bar where the real-world question is 'is this pipeline still running' as much as 'which stage is it on' — the ribbon's gradient encodes fixed stage territory and its ripple encodes continuous, unconditional forward motion, borrowed from the float-glass process never pausing mid-draw. Pick meter-latency-capillary instead for a single calibrated wait against a known p50/p95 latency distribution (this component has no percentile/stall concept). Pick meter-quota-meniscus instead for a liquid-in-a-vessel capacity reading tied to a bounded value, not an ongoing multi-stage process. Pick stepper-ratchet or wizard-canal-lock instead for a user-driven, discrete multi-step form flow rather than a passive status readout.

Install

npx shadcn add https://design.helpmarq.com/r/float-ribbon-draw.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.

Build spec

Build <FloatRibbonDraw height? stages? activeStage? label? className?> as a full-width horizontal status bar. SOURCE, NOT INVENTED: the float-glass (Pilkington) process, the method that produces essentially all flat glass made today — molten glass flows continuously onto a bath of molten tin, floats and spreads to its natural equilibrium thickness, and is drawn forward by top rollers at a constant line speed while it cools along the bath's length, entering molten (~1,000C) and exiting set (~600C). The real process never starts or stops mid-draw, which is the exact property most 'job status' bars get wrong by pausing their idle animation while waiting on something. TWO DELIBERATELY SEPARATE THINGS: (1) a THERMAL GRADIENT — bright/dense 'molten' on the left fading to dim 'set' on the right — that is a FIXED function of x-position along the bar and never itself animates, because it represents the pipeline's stage territory, not elapsed time; (2) a RIPPLE — a faint sine wobble riding the ribbon's top edge, confined by a raised-cosine envelope to roughly the still-molten first third (full amplitude near x=0, damped to ~0 by x-fraction MOLTEN_ZONE=0.34) — that scrolls continuously at SCROLL_FRAC_PER_S=0.08 (8% of the container's width per second, so the speed is resolution-independent rather than a fixed px/s), which is the actual 'alive at rest' signal: material is moving through the pipeline right now, independent of which stage is highlighted. GEOMETRY/RESOLUTION: ribbon band height = height * RIBBON_FRAC (0.28), vertically centered in the canvas; both the fixed luminance curve and the ripple's y-offset are pre-sampled into two Float32Array(SAMPLES) lookups (SAMPLES=64) across the ribbon's length each frame, bilinearly interpolated when read — luminance at x-fraction xf is T_FLOOR + (1-T_FLOOR) * exp(-xf/LAMBDA) with LAMBDA=0.35 and T_FLOOR=0.08 (so it decays fast then trails, matching the bath's front-loaded real cooling curve, and never fully bottoms out to flat --ns-muted); the canvas fill is a linear gradient built from 12 stops sampled off that curve (a 2-stop gradient would misrepresent the exponential shape as linear). COLOUR IS LUMINANCE ONLY: every fill is parseColor()+lerpColor() between --ns-muted (t=0, 'set') and --foreground (t=1, 'molten') — read via getComputedStyle(document.documentElement), re-read on a documentElement class MutationObserver, no paint before the first read. This mapping holds in both themes without inversion because --foreground is already each theme's highest-density ink (near-white in dark, near-black in light), so 'hot' reads as 'densest ink' in both rather than as a hue swap. A thin 1px --border rule (its own alpha parsed from the token, not assumed) is drawn under the full ribbon length unconditionally — it is what keeps the dim 'set' end of the gradient from disappearing into --surface in light theme; check light theme first. RESTING LOOP: scrollPx accumulates every frame (width * SCROLL_FRAC_PER_S * dt) and never resets — t0, t+2.5s and t+5s each land the ripple's crest positions at a different phase along the same fixed gradient, so all three frames are visibly distinct while the gradient itself stays put. OPTIONAL STAGES: `stages` (string[]) and `activeStage` (index) overlay fixed 1px tick marks at even x-fractions (i/(stages.length-1)) over the ribbon, plus matching mono labels in a DOM row beneath it — the active tick/label render at --foreground, others at --ns-muted, never --ns-accent; the ribbon's gradient/ripple render loop keeps running completely unconditionally regardless of activeStage, since pausing it to 'wait' on a stage misrepresents the real process. A visually-hidden role=status aria-live=polite region announces 'Stage: <label>' exactly once per activeStage change (never a running commentary on the ripple) when stages/activeStage are supplied; with neither supplied the bar is a bare ambient processing indicator and no announcement fires. prefers-reduced-motion freezes at scrollPx=0 — the ripple's most evenly-spread crest layout across the molten zone — and stops the rAF loop entirely; the fixed gradient, border rule, and any stage ticks/labels render exactly as in motion, so the mechanic and any real stage state stay fully legible without motion. Standard canvas host lifecycle: DPR-aware backing store capped at 2, ResizeObserver on the canvas (not window.resize), IntersectionObserver (threshold 0) pausing the loop off-screen, visibilitychange pausing on a hidden tab, and full cleanup (cancelAnimationFrame, disconnect both observers, remove the visibilitychange listener) on unmount. No interactive elements, no gate descriptor: the root is role=group with an accessible `label` (default 'Processing pipeline'); the canvas itself is aria-hidden decoration. Zero dependencies, direct-canvas rendering, only the stage announcement and its own change-detection ref live in React state.

Props

PropTypeDefaultDescription
height?number88total component height in px; the ribbon band derives as 28% of this. Default 88.
stages?string[]—optional ordered pipeline stage labels, drawn as fixed ticks along the bar
activeStage?number—index into `stages` that is currently active/lit. Omit for a purely ambient ribbon with no stage semantics.
label?string"Processing pipeline"accessible label for the status region. Default "Processing pipeline"
className?string—extra classes merged onto the rendered root element