Skip to main content

ns-ui

Airlift Slug Flow

A background file-sync/upload activity rail: a slim vertical conduit where discrete air slugs, injected at the foot every 1.8s, rise at a constant 140px/s and drag a liquid plug immediately ahead of them, depositing into an accumulator basin on arrival. A real airlift-pump slug-flow regime standing in for a generic spinner or looping percentage.

Use when a slim ambient rail sitting beside a file list or sync panel that needs to read as ongoing background activity without a numeric percentage; pick a determinate progress bar instead when the exact completion fraction of one known-size job must be communicated, since this component's basin only tracks arrival count against an arbitrary 14-step macro-cycle and never represents a real percentage.

Install

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

import { useEffect, useRef } from "react";

// ---------------------------------------------------------------------------
// AirliftSlugFlow — a background file-sync/upload activity rail: a slim
// vertical conduit, sized off the container's smaller dimension, standing in
// for a generic spinner or a looping "syncing…" label. The mechanic is a
// real airlift pump (mining dewatering, geothermal lift, aquarium airlift):
// compressed air injected at the foot of a submerged riser forms discrete gas
// slugs, and each slug's buoyant rise drags the plug of liquid immediately
// ahead of it up the pipe — classic two-phase "slug flow", delivery arriving
// in distinct pulses rather than a continuous stream.
//
// A new slug/plug pair is injected at the rail's foot every 1.8s (inside the
// real 0.5-3Hz small-riser slugging band) and rises at a constant 140px/s
// (buoyancy-driven rise is near-constant, not accelerating). The slug is a
// rounded capsule at reduced --foreground opacity (the bubble read); the
// liquid plug it drags is a solid --foreground band riding flush against the
// slug's leading edge, same speed — two coupled parts, never one blob. On
// reaching the rail's head the pair fades over 220ms and deposits into a
// small accumulator basin, whose fill increments 1/14th per arrival and
// wraps every 14 arrivals (~25s self-contained macro-cycle).
//
// Everything is driven off a virtual clock that only advances while the rail
// is on-screen (an IntersectionObserver pauses/resumes it), so a tab-away
// never produces a catch-up burst on return. Geometry is recomputed from a
// ResizeObserver reading the container's own box — no restart on resize, the
// running pairs simply reflow onto the new rail height next frame.
// ---------------------------------------------------------------------------

const INJECT_INTERVAL_MS = 1800; // slug injection period — real 0.5-3Hz small-riser band
const RISE_SPEED_PX_S = 140; // constant buoyancy-driven rise speed
const SLUG_HEIGHT_FRAC = 0.09; // slug capsule height, fraction of rail height
const PLUG_HEIGHT_FRAC = 0.14; // dragged liquid plug height, fraction of rail height
const FADE_MS = 220; // arrival fade-out, slug+plug together
const BASIN_STEPS = 14; // arrivals per full basin cycle (~25s macro-cycle)
const RAIL_WIDTH_FRAC = 0.08; // rail width = 8% of the container's smaller dimension
const RAIL_WIDTH_MIN = 5;
const RAIL_WIDTH_MAX = 22;
const BASIN_SIZE_MULT = 2.4; // basin side length = rail width * this
const BASIN_GAP = 3; // px gap between rail head and basin
const REDUCED_BASIN_FRAC = 5 / BASIN_STEPS; // frozen-frame basin level — mid-cycle, not 0 or full

interface Pair {
  slug: HTMLDivElement;
  plug: HTMLDivElement;
  injectedAtV: number; // virtual ms timestamp at injection
  fading: boolean;
  fadeStartV: number;
}

export interface AirliftSlugFlowProps {
  /** fired once per slug arrival at the basin — hook a real file row's status pulse to it */
  onSlugArrival?: () => void;
  /** extra classes merged onto the root element */
  className?: string;
}

export function AirliftSlugFlow({ onSlugArrival, className = "" }: AirliftSlugFlowProps) {
  const rootRef = useRef<HTMLDivElement>(null);
  const railRef = useRef<HTMLDivElement>(null);
  const columnRef = useRef<HTMLDivElement>(null);
  const layerRef = useRef<HTMLDivElement>(null);
  const basinRef = useRef<HTMLDivElement>(null);
  const basinFillRef = useRef<HTMLDivElement>(null);
  const onArrivalRef = useRef(onSlugArrival);
  onArrivalRef.current = onSlugArrival;

  useEffect(() => {
    const root = rootRef.current;
    const rail = railRef.current;
    const column = columnRef.current;
    const layer = layerRef.current;
    const basin = basinRef.current;
    const basinFill = basinFillRef.current;
    if (!root || !rail || !column || !layer || !basin || !basinFill) return;

    let disposed = false;
    let railWidthPx = 0;
    let railHeightPx = 0;
    let visible = true;
    let raf = 0;
    let last = 0;
    let vClock = 0;
    let injectAcc = 0;
    let arrivalCount = 0;
    let pairs: Pair[] = [];

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

    const layoutBasin = () => {
      const size = Math.min(RAIL_WIDTH_MAX * BASIN_SIZE_MULT, Math.max(RAIL_WIDTH_MIN * BASIN_SIZE_MULT, railWidthPx * BASIN_SIZE_MULT));
      basin.style.width = `${size}px`;
      basin.style.height = `${size}px`;
      basin.style.marginBottom = `${BASIN_GAP}px`;
    };

    const setBasinFrac = (frac: number) => {
      basinFill.style.height = `${Math.max(0, Math.min(1, frac)) * 100}%`;
    };

    const spawnPair = (injectedAtV: number) => {
      const slug = document.createElement("div");
      slug.className = "asf-slug";
      const plug = document.createElement("div");
      plug.className = "asf-plug";
      layer.appendChild(plug);
      layer.appendChild(slug);
      pairs.push({ slug, plug, injectedAtV, fading: false, fadeStartV: 0 });
    };

    const removePair = (p: Pair) => {
      p.slug.remove();
      p.plug.remove();
    };

    const arrive = () => {
      arrivalCount = (arrivalCount % BASIN_STEPS) + 1;
      setBasinFrac(arrivalCount / BASIN_STEPS);
      onArrivalRef.current?.();
    };

    const applyPairStyle = (p: Pair, elapsedV: number) => {
      const slugH = railHeightPx * SLUG_HEIGHT_FRAC;
      const plugH = railHeightPx * PLUG_HEIGHT_FRAC;
      const y = (elapsedV / 1000) * RISE_SPEED_PX_S;
      const slugTop = y + slugH;
      const pairTop = slugTop + plugH;

      if (!p.fading && pairTop >= railHeightPx) {
        p.fading = true;
        p.fadeStartV = elapsedV;
        p.slug.style.transition = `opacity ${FADE_MS}ms linear`;
        p.plug.style.transition = `opacity ${FADE_MS}ms linear`;
        p.slug.style.opacity = "0";
        p.plug.style.opacity = "0";
        arrive();
      }

      p.slug.style.height = `${slugH}px`;
      p.slug.style.bottom = `${Math.min(y, railHeightPx - slugH)}px`;
      p.plug.style.height = `${plugH}px`;
      p.plug.style.bottom = `${Math.min(slugTop, railHeightPx - 0)}px`;
    };

    const layoutFrozenFrame = () => {
      const slugH = railHeightPx * SLUG_HEIGHT_FRAC;
      const plugH = railHeightPx * PLUG_HEIGHT_FRAC;
      const pairH = slugH + plugH;
      const bottom = railHeightPx * 0.5 - pairH / 2;
      layer.innerHTML = "";
      const slug = document.createElement("div");
      slug.className = "asf-slug";
      slug.style.height = `${slugH}px`;
      slug.style.bottom = `${bottom}px`;
      const plug = document.createElement("div");
      plug.className = "asf-plug";
      plug.style.height = `${plugH}px`;
      plug.style.bottom = `${bottom + slugH}px`;
      layer.appendChild(plug);
      layer.appendChild(slug);
      setBasinFrac(REDUCED_BASIN_FRAC);
    };

    const measure = () => {
      const rect = root.getBoundingClientRect();
      const minDim = Math.min(rect.width, rect.height);
      railWidthPx = Math.max(RAIL_WIDTH_MIN, Math.min(RAIL_WIDTH_MAX, minDim * RAIL_WIDTH_FRAC));
      column.style.width = `${railWidthPx}px`;
      rail.style.width = `${railWidthPx}px`;
      layoutBasin();
      const basinRect = basin.getBoundingClientRect();
      railHeightPx = Math.max(0, rect.height - basinRect.height - BASIN_GAP);
      rail.style.height = `${railHeightPx}px`;
      if (mq.matches) layoutFrozenFrame();
    };

    const loop = (now: number) => {
      raf = 0;
      if (disposed) return;
      if (!visible || railHeightPx <= 0) {
        raf = requestAnimationFrame(loop);
        return;
      }
      if (last === 0) last = now;
      const dt = Math.min(100, now - last);
      last = now;
      vClock += dt;

      injectAcc += dt;
      while (injectAcc >= INJECT_INTERVAL_MS) {
        injectAcc -= INJECT_INTERVAL_MS;
        spawnPair(vClock);
      }

      pairs = pairs.filter((p) => {
        const elapsedV = vClock - p.injectedAtV;
        applyPairStyle(p, elapsedV);
        if (p.fading && vClock - p.fadeStartV >= FADE_MS) {
          removePair(p);
          return false;
        }
        return true;
      });

      raf = requestAnimationFrame(loop);
    };

    const start = () => {
      measure();
      if (mq.matches) return; // reduced motion: one static frame, no loop
      last = 0;
      if (!raf) raf = requestAnimationFrame(loop);
    };

    const onReducedChange = () => {
      cancelAnimationFrame(raf);
      raf = 0;
      layer.innerHTML = "";
      pairs = [];
      injectAcc = 0;
      vClock = 0;
      last = 0;
      arrivalCount = 0;
      setBasinFrac(0);
      start();
    };
    mq.addEventListener("change", onReducedChange);

    let resizeTimer = 0;
    const ro = new ResizeObserver(() => {
      window.clearTimeout(resizeTimer);
      resizeTimer = window.setTimeout(() => {
        if (disposed) return;
        measure();
      }, 100);
    });
    ro.observe(root);

    const io = new IntersectionObserver((entries) => {
      visible = entries[0]?.isIntersecting ?? true;
      if (visible && !mq.matches && !raf) {
        last = 0;
        raf = requestAnimationFrame(loop);
      }
    });
    io.observe(root);

    document.fonts.ready.then(() => {
      if (!disposed) measure();
    });

    start();

    return () => {
      disposed = true;
      cancelAnimationFrame(raf);
      raf = 0;
      window.clearTimeout(resizeTimer);
      mq.removeEventListener("change", onReducedChange);
      ro.disconnect();
      io.disconnect();
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <div ref={rootRef} className={`relative flex h-full w-full flex-col items-center justify-end ${className}`}>
      <style>{CSS}</style>
      <div ref={basinRef} className="asf-basin">
        <div ref={basinFillRef} className="asf-basin-fill" />
      </div>
      <div ref={columnRef} className="relative">
        <div ref={railRef} className="asf-static-column relative overflow-visible">
          <div ref={layerRef} className="absolute inset-x-0 bottom-0" />
        </div>
      </div>
    </div>
  );
}

const CSS = `
.asf-static-column {
  background: var(--ns-muted);
  opacity: 0.3;
  border-radius: 999px;
}
.dark .asf-static-column {
  opacity: 0.16;
}
.asf-slug {
  position: absolute;
  left: 0;
  right: 0;
  border-radius: 999px;
  background: var(--foreground);
  opacity: 0.4;
}
.asf-plug {
  position: absolute;
  left: 0;
  right: 0;
  border-radius: 2px;
  background: var(--foreground);
  opacity: 1;
}
.asf-basin {
  position: relative;
  overflow: hidden;
  border: 1px solid var(--border);
  border-radius: 3px;
  background: var(--background);
}
.asf-basin-fill {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 0%;
  background: var(--foreground);
  transition: height 220ms linear;
}
`;
Build spec

A vertical activity rail, card-scale, meant to sit beside a real file list or sync panel. Root is a flex column anchored to the bottom (justify-end) so the rail's foot sits at the container's bottom edge and a small accumulator basin sits above its head. On mount, and on every debounced (100ms) ResizeObserver callback on the root, the container's own bounding box is measured: rail width = 8% of the container's smaller dimension, clamped 5-22px; the basin is a bordered square whose side is railWidth * 2.4 (itself clamped against the same 5-22px band before multiplying), separated from the rail by a 3px gap; rail height = container height minus the basin's rendered height minus that 3px gap, so the rail always fills whatever vertical space remains. The static liquid column is a single pill-shaped div spanning the full rail height, background var(--ns-muted) at opacity 0.3 in light theme and 0.16 under a `.dark` ancestor class (the muted token washes out against light backgrounds at low opacity, so light theme gets a deliberately higher floor; the moving slug/plug pair is var(--foreground) unmodified across themes since it is already the primary legibility carrier). Every 1800ms (real airlift small-riser slugging sits in the 0.5-3Hz band; 1.8s is inside it) a new slug+plug pair is injected at the rail's foot: raw DOM elements appended directly to a layer div and driven every animation frame by imperative style writes (bottom/height), not React state, so multiple concurrent pairs in flight cost nothing extra. The slug is a full-width pill (border-radius 999px) at 40% opacity var(--foreground) — the bubble read; the liquid plug it drags is a solid 100%-opacity var(--foreground) band with a 2px corner radius, riding flush against the slug's leading (upper) edge at the identical rise speed, so the two always touch and never separate or overlap. Both rise at a constant 140px/s (buoyancy-driven rise is near-constant, not accelerating) computed off a virtual clock that only advances while an IntersectionObserver reports the root as on-screen, so backgrounding the tab never produces a catch-up burst of slugs on return; the same virtual clock (not wall time) drives the 1800ms injection accumulator. Slug height is 9% of the current rail height, plug height 14%, both re-derived from railHeightPx every frame so a mid-flight resize reflows the pair onto the new geometry without restarting the cycle. The moment the pair's combined top edge reaches the rail's head, both elements get a 220ms linear opacity transition to 0 (removed from the DOM once that transition's virtual-clock duration elapses) and the arrival is recorded: an arrival counter increments mod 14 and the basin's fill div (absolutely positioned, bottom-anchored, height set as a percentage with its own 220ms linear transition so the deposit reads as landing in sync with the fade) is set to count/14 — a full 14-arrival cycle is roughly 25s and self-contained, no external data required. An optional `onSlugArrival` callback fires once per arrival, exactly at the moment the fade begins, meant to be wired to a real adjacent file row's status text for a single discrete pulse — the callback and the demo's row-cycling handler are the only sanctioned use of the arrival event; nothing in the component itself ever mixes var(--ns-accent) into the slug, plug, or basin fill, since accent is interaction chrome only and this is an ambient, non-interactive rail. Under `prefers-reduced-motion: reduce` (checked via matchMedia with a live change listener) no rAF loop or injection accumulator ever starts: exactly one slug/plug pair is laid out frozen with its combined vertical center at 50% of rail height — the single frame where both parts are visible, coupled, and clearly separated from the static column above and below — and the basin fill is set to a fixed 5/14, a deliberately mid-cycle, non-zero, non-full level rather than either boundary. Cleanup on unmount cancels the animation frame, clears the resize debounce timer, and disconnects both the ResizeObserver and the IntersectionObserver. Zero dependencies, DOM + CSS only, no canvas, every colour a token (--background --foreground --ns-muted --border), autoplay is 'none' because the rail runs on its own internal clock with nothing for a synthetic pointer/scroll/press driver to trigger.

Props

PropTypeDefaultDescription
onSlugArrival?() => voidfired once per slug arrival at the basin — hook a real file row's status pulse to it
className?stringextra classes merged onto the root element