Skip to main content

ns-ui

Running Belay

A deploy pipeline drawn as a climbing pitch: stages are protection anchors bolted up a vertical line, the current deploy is the climber-end of a rope clipped through every passed anchor, and rollback is a fall arrest that catches at the last healthy anchor with visible rope stretch.

Use when the status card a team stares at during a rollout, where a rollback needs a visible destination before you commit to it. Health is rope slack rather than a badge, and arrest is a real button that computes and shows exactly which checkpoint it returns to. Distinct from passing-loop (a continuous traffic controller with no notion of a destination) and wizard-canal-lock (a forward-only validation gate with no rollback concept at all) — running-belay's whole contribution is that a rollback has a legible landing point before you need it.

Install

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

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

// ---------------------------------------------------------------------------
// RunningBelay — a deploy pipeline drawn as a climbing pitch. Stages are
// protection anchors bolted along a vertical SVG line; the current deploy is
// the climber-end of a rope clipped through every passed anchor. The segment
// between the last clipped anchor and the leader bows with a quadratic
// bezier control offset of `headroom * 24px` (24px at 96px stage spacing
// reads as slack; the same offset never exceeds 28px, past which the curve
// would cross its own anchor line and stop reading as clipped through) — so
// headroom approaching 0 pulls the rope visibly taut before an automated
// abort fires. Advancement is a linear climb on ease-out-expo; passing an
// anchor clips through with a one-frame carabiner-ring flash, no per-stage
// celebration. ARREST (fall arrest / rollback) is computed only as the
// greatest passed anchor and is a single translate of the leader there, with
// one overshoot-and-settle spring on the leader — never a stage-by-stage
// reverse walk, because a rollback is not a tidy reverse deploy and
// animating it as one teaches a false mental model. The catch itself also
// draws a second, dashed rope segment pinned taut (the same 28px hard-capped
// bow as the live rope) between the target anchor and where the leader fell
// from, fading out over the spring's own duration as it settles — the rope
// visibly taking the load, not just a dot relocating. Pure DOM + SVG + CSS,
// every ink a token, no canvas.
// ---------------------------------------------------------------------------

export type BelayStageStatus = "pending" | "active" | "passed" | "failed";

export interface BelayStage {
  /** stable id, also what onArrest reports back */
  id: string;
  /** short stage name, e.g. "build", "canary", "10%" */
  label: string;
  status: BelayStageStatus;
  /** shown next to the stage and read out in the arrest target description */
  timestamp?: string;
}

export interface RunningBelayProps {
  /** pipeline stages, bottom of the pitch to the top — e.g. build..canary..100% */
  stages: BelayStage[];
  /** 0..1 aggregated canary-health margin; drives how much the live rope bows */
  headroom: number;
  /** fires with the stage id the arrest fell back to */
  onArrest?: (targetStageId: string) => void;
  /** accessible name for the pipeline group */
  ariaLabel?: string;
  className?: string;
}

const SPACING = 96; // px between anchors
const RAIL_W = 40; // px, the SVG rail column
const LINE_X = RAIL_W / 2;
const ANCHOR_R = 5;
const BOW_MAX = 24; // px, at headroom = 1
const BOW_CAP = 28; // px, hard ceiling — past this the curve crosses the line
const CLIMB_MS = 600;
const CLIMB_EASE = "cubic-bezier(0.16,1,0.3,1)"; // ease-out-expo
const SPRING_MS = 700;
const SPRING_EASE = "cubic-bezier(0.34,1.56,0.64,1)"; // overshoot + settle
const CLIP_FLASH_MS = 150;
const ARREST_FLASH_MS = 500;

function clamp(v: number, lo: number, hi: number) {
  return Math.min(hi, Math.max(lo, v));
}

function useReducedMotion() {
  const [reduced, setReduced] = useState(false);
  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    setReduced(mq.matches);
    const on = () => setReduced(mq.matches);
    mq.addEventListener("change", on);
    return () => mq.removeEventListener("change", on);
  }, []);
  return reduced;
}

/** the frontmost stage the deploy is currently at — the active one, or the
 *  furthest passed stage once nothing is active */
function naturalLeaderIndex(stages: BelayStage[]): number {
  const active = stages.findIndex((s) => s.status === "active");
  if (active !== -1) return active;
  let idx = 0;
  stages.forEach((s, i) => {
    if (s.status === "passed") idx = i;
  });
  return idx;
}

/** the arrest target: the greatest passed anchor, full stop. -1 if none. */
function greatestPassedIndex(stages: BelayStage[]): number {
  let idx = -1;
  stages.forEach((s, i) => {
    if (s.status === "passed" && i > idx) idx = i;
  });
  return idx;
}

export function RunningBelay({
  stages,
  headroom,
  onArrest,
  ariaLabel = "Deploy pipeline",
  className = "",
}: RunningBelayProps) {
  const uid = useId();
  const reduced = useReducedMotion();
  const n = stages.length;
  const totalH = Math.max(1, n) * SPACING;

  const natural = naturalLeaderIndex(stages);
  const lastPassed = greatestPassedIndex(stages);

  // arrestedIdx is a local, optimistic override — released the moment the
  // caller's own stages prop confirms the deploy is actually back there.
  const [arrestedIdx, setArrestedIdx] = useState<number | null>(null);
  const [springOn, setSpringOn] = useState(false);
  const springTimeout = useRef<number | undefined>(undefined);
  // the leader's position the instant BEFORE a fall — kept only for the
  // duration of the spring, so the catch has a rope to show the load on.
  const [fallFromIdx, setFallFromIdx] = useState<number | null>(null);
  const [fallFading, setFallFading] = useState(false);
  const fallFadeFrame = useRef<number | undefined>(undefined);
  const [arrestFlashIdx, setArrestFlashIdx] = useState<number | null>(null);
  const arrestFlashTimeout = useRef<number | undefined>(undefined);
  const [assertiveMsg, setAssertiveMsg] = useState("");

  useEffect(() => {
    if (arrestedIdx !== null && natural === arrestedIdx) setArrestedIdx(null);
  }, [natural, arrestedIdx]);

  const leaderIndex = arrestedIdx ?? natural;

  // polite announcement whenever a stage's own status changes
  const prevStages = useRef(stages);
  const [politeMsg, setPoliteMsg] = useState("");
  useEffect(() => {
    const prev = prevStages.current;
    prevStages.current = stages;
    if (prev === stages) return;
    for (let i = 0; i < stages.length; i++) {
      const p = prev[i];
      const s = stages[i];
      if (p && s && p.status !== s.status) {
        setPoliteMsg(`${s.label} is now ${s.status}${s.timestamp ? `, ${s.timestamp}` : ""}.`);
      }
    }
  }, [stages]);

  // one-frame carabiner-ring flash the instant an anchor newly becomes passed
  const passedSeen = useRef<Set<number>>(new Set());
  const [clipFlashIdx, setClipFlashIdx] = useState<number | null>(null);
  const clipFlashTimeout = useRef<number | undefined>(undefined);
  useEffect(() => {
    const prevSeen = passedSeen.current;
    const nextSeen = new Set<number>();
    let fresh: number | null = null;
    stages.forEach((s, i) => {
      if (s.status === "passed") {
        nextSeen.add(i);
        if (!prevSeen.has(i)) fresh = i;
      }
    });
    passedSeen.current = nextSeen;
    if (fresh !== null && !reduced) {
      setClipFlashIdx(fresh);
      window.clearTimeout(clipFlashTimeout.current);
      clipFlashTimeout.current = window.setTimeout(() => setClipFlashIdx(null), CLIP_FLASH_MS);
    }
  }, [stages, reduced]);

  useEffect(
    () => () => {
      window.clearTimeout(springTimeout.current);
      window.clearTimeout(arrestFlashTimeout.current);
      window.clearTimeout(clipFlashTimeout.current);
      window.cancelAnimationFrame(fallFadeFrame.current ?? -1);
    },
    []
  );

  const targetStage = lastPassed >= 0 ? stages[lastPassed] : null;

  function handleArrest() {
    if (lastPassed < 0 || !targetStage) return;
    const fellFrom = reduced ? null : leaderIndex;
    setFallFromIdx(fellFrom);
    setFallFading(false);
    window.cancelAnimationFrame(fallFadeFrame.current ?? -1);
    if (fellFrom !== null) {
      // mount the taut catch-rope at full opacity, then flip to the CSS
      // transition target on the next frame so it fades out OVER the
      // spring's duration rather than vanishing with it.
      fallFadeFrame.current = window.requestAnimationFrame(() => setFallFading(true));
    }
    setArrestedIdx(lastPassed);
    setSpringOn(true);
    window.clearTimeout(springTimeout.current);
    springTimeout.current = window.setTimeout(() => {
      setSpringOn(false);
      setFallFromIdx(null);
      setFallFading(false);
    }, SPRING_MS);
    setArrestFlashIdx(lastPassed);
    window.clearTimeout(arrestFlashTimeout.current);
    arrestFlashTimeout.current = window.setTimeout(() => setArrestFlashIdx(null), ARREST_FLASH_MS);
    setAssertiveMsg(
      `Arrested. Rolled back to ${targetStage.label} cohort, deployed ${targetStage.timestamp ?? "unknown time"}.`
    );
    onArrest?.(targetStage.id);
  }

  const describeId = `${uid}-target`;
  const targetDesc =
    lastPassed < 0 || !targetStage
      ? "No healthy checkpoint recorded yet — arrest has nothing to fall back to."
      : `Rolls back to ${targetStage.label} cohort, deployed ${targetStage.timestamp ?? "unknown time"}.`;

  const yFor = (i: number) => i * SPACING + SPACING / 2;

  const solidSegments: Array<{ y0: number; y1: number }> = [];
  for (let i = 0; i < n - 1; i++) {
    if (stages[i]?.status === "passed" && stages[i + 1]?.status === "passed") {
      solidSegments.push({ y0: yFor(i), y1: yFor(i + 1) });
    }
  }

  const showLive = lastPassed >= 0 && leaderIndex > lastPassed;
  const bowOffset = reduced ? 0 : clamp(clamp(headroom, 0, 1) * BOW_MAX, 0, BOW_CAP);
  const liveY0 = lastPassed >= 0 ? yFor(lastPassed) : 0;
  const liveY1 = yFor(leaderIndex);
  const liveMidY = (liveY0 + liveY1) / 2;
  const liveD = `M ${LINE_X} ${liveY0} Q ${LINE_X + bowOffset} ${liveMidY} ${LINE_X} ${liveY1}`;

  // the catch-rope: only exists for the SPRING_MS of an arrest, pinned
  // between the target anchor and where the leader fell from, bowed to the
  // same hard-capped 28px — the rope taking the full load, then fading as
  // it settles. Never drawn under reduced motion (fallFromIdx stays null).
  const fallD =
    fallFromIdx !== null && fallFromIdx > lastPassed
      ? (() => {
          const y0 = yFor(lastPassed);
          const y1 = yFor(fallFromIdx);
          const midY = (y0 + y1) / 2;
          return `M ${LINE_X} ${y0} Q ${LINE_X + BOW_CAP} ${midY} ${LINE_X} ${y1}`;
        })()
      : null;

  const leaderTransition = reduced
    ? "none"
    : `transform ${springOn ? SPRING_MS : CLIMB_MS}ms ${springOn ? SPRING_EASE : CLIMB_EASE}`;

  return (
    <div
      className={className}
      role="group"
      aria-label={ariaLabel}
      data-belay-state={arrestedIdx !== null ? "arrested" : "armed"}
    >
      <style>{`
@media (prefers-reduced-motion: reduce){
  .ns-rb-leader{transition:none !important}
  .ns-rb-clip{transition:none !important}
}
`}</style>

      <div className="relative w-full">
        <svg
          aria-hidden="true"
          className="pointer-events-none absolute left-0 top-0"
          width={RAIL_W}
          height={totalH}
          viewBox={`0 0 ${RAIL_W} ${totalH}`}
        >
          {/* the permanent conduit — always fully visible */}
          <line x1={LINE_X} y1={yFor(0)} x2={LINE_X} y2={yFor(Math.max(0, n - 1))} stroke="var(--border)" strokeWidth={1} />

          {solidSegments.map((seg, i) => (
            <line
              key={i}
              x1={LINE_X}
              y1={seg.y0}
              x2={LINE_X}
              y2={seg.y1}
              stroke="var(--foreground)"
              strokeWidth={1.5}
            />
          ))}

          {showLive && (
            <path d={liveD} fill="none" stroke="var(--foreground)" strokeOpacity={0.65} strokeWidth={1.5} />
          )}

          {fallD && (
            <path
              d={fallD}
              fill="none"
              stroke="var(--foreground)"
              strokeWidth={1.5}
              strokeDasharray="2 3"
              style={{
                opacity: fallFading ? 0 : 0.9,
                transition: reduced ? "none" : `opacity ${SPRING_MS}ms ease-out`,
              }}
            />
          )}

          {stages.map((s, i) => {
            const y = yFor(i);
            const passed = s.status === "passed";
            const failed = s.status === "failed";
            const isActiveAnchor = i === natural && s.status === "active";
            const clipping = clipFlashIdx === i;
            const flashing = arrestFlashIdx === i;
            return (
              <g key={s.id} transform={`translate(${LINE_X}, ${y})`}>
                {failed ? (
                  <>
                    <line x1={-ANCHOR_R} y1={-ANCHOR_R} x2={ANCHOR_R} y2={ANCHOR_R} stroke="var(--foreground)" strokeWidth={1.5} />
                    <line x1={-ANCHOR_R} y1={ANCHOR_R} x2={ANCHOR_R} y2={-ANCHOR_R} stroke="var(--foreground)" strokeWidth={1.5} />
                  </>
                ) : isActiveAnchor ? (
                  <circle r={ANCHOR_R} fill="none" stroke="var(--foreground)" strokeWidth={1.5} />
                ) : (
                  <circle r={ANCHOR_R} fill={passed ? "var(--foreground)" : "var(--border)"} />
                )}
                {passed && (
                  <ellipse
                    className="ns-rb-clip"
                    cx={0}
                    cy={0}
                    rx={ANCHOR_R + 4}
                    ry={ANCHOR_R + 2}
                    fill="none"
                    stroke="var(--foreground)"
                    strokeWidth={1}
                    opacity={clipping ? 1 : 0.5}
                    style={{ transition: reduced ? "none" : "opacity 140ms ease-out" }}
                  />
                )}
                {flashing && (
                  <circle r={ANCHOR_R + 6} fill="none" stroke="var(--foreground)" strokeWidth={1} opacity={0.85} />
                )}
              </g>
            );
          })}

          <g
            className="ns-rb-leader"
            style={{
              transform: `translate(${LINE_X}px, ${yFor(leaderIndex)}px)`,
              transition: leaderTransition,
            }}
          >
            <circle r={6} fill="var(--background)" stroke="var(--foreground)" strokeWidth={2} />
            <circle r={2} fill="var(--foreground)" />
          </g>
        </svg>

        <ol className="relative m-0 list-none p-0" style={{ paddingLeft: RAIL_W + 16 }}>
          {stages.map((s, i) => {
            const isCurrent = i === leaderIndex;
            return (
              <li
                key={s.id}
                aria-current={isCurrent ? "step" : undefined}
                className="flex flex-col justify-center gap-0.5"
                style={{ height: SPACING }}
              >
                <span className="font-sans text-sm text-foreground">{s.label}</span>
                <span className="font-mono text-[11px] text-ns-muted">
                  {s.status}
                  {s.timestamp ? ` · ${s.timestamp}` : ""}
                </span>
              </li>
            );
          })}
        </ol>
      </div>

      <div className="mt-4 flex items-center justify-between gap-3 border-t border-border pt-3">
        <p className="font-mono text-[11px] text-ns-muted">
          headroom {Math.round(clamp(headroom, 0, 1) * 100)}%
        </p>
        <button
          type="button"
          data-belay-arrest
          disabled={lastPassed < 0}
          aria-describedby={describeId}
          onClick={handleArrest}
          className="rounded-[6px] border border-border px-3 py-1.5 font-mono text-xs text-foreground transition-colors hover:border-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent disabled:pointer-events-none disabled:opacity-40"
        >
          Arrest rollout
        </button>
      </div>
      <span id={describeId} className="sr-only">
        {targetDesc}
      </span>

      <p aria-live="polite" className="sr-only">
        {politeMsg}
      </p>
      <p aria-live="assertive" className="sr-only">
        {assertiveMsg}
      </p>
    </div>
  );
}
Build spec

Build <RunningBelay stages headroom onArrest? ariaLabel? className?> as a vertical climbing pitch. DATA: `stages: {id,label,status,timestamp?}[]` in pipeline order (e.g. build, canary, 10%, 50%, 100%), status one of pending|active|passed|failed; `headroom: number` 0..1, the caller-aggregated canary-health margin. GEOMETRY: one 96px row per stage, a fixed 40px SVG rail on the left carries a vertical line at its centre — a faint --border hairline runs its full length as the permanent conduit; --foreground solid segments overlay it between every pair of ADJACENT passed anchors (the already-clipped chain). Anchors are small circles on that line: filled --foreground when passed, an open --foreground ring when active, plain --border when still pending, and an X (two crossing --foreground strokes, never a color) when failed — status is shape, never hue, matching the rest of the registry's no-color-coding rule. A passed anchor also carries a faint carabiner ring (an ellipse, opacity 0.5) that flashes to full opacity for one 140ms beat the instant that anchor newly becomes passed, then settles back — a one-frame clip, not a celebration. THE LIVE ROPE: from the greatest passed anchor to the current leader position (the active stage, or the furthest passed stage once nothing is active) is drawn as a single quadratic bezier, `M lineX,y0 Q lineX+offset,midY lineX,y1`, where offset = clamp(headroom,0,1) * 24, hard-capped at 28px — past that cap the curve would cross back over its own anchor line and stop reading as clipped through. headroom near 1 reads as visible slack; headroom near 0 pulls the rope visibly taut, legible before any automated abort actually fires. No live segment is drawn once the leader sits exactly on the greatest passed anchor (nothing left to bow). THE LEADER: a small ringed dot translated to the current stage's y with a single CSS transform transition — ease-out-expo (cubic-bezier(0.16,1,0.3,1), 600ms) for ordinary forward advancement between renders, cubic-bezier(0.34,1.56,0.64,1) 700ms (one overshoot-and-settle spring, never a stage-by-stage reverse walk) specifically for an arrest. ARREST: a real <button data-belay-arrest> labeled 'Arrest rollout', disabled only when there is no passed anchor yet to fall back to, carrying aria-describedby pointing at a permanently-mounted sr-only span reading the exact target, e.g. 'Rolls back to 10% cohort, deployed 14:02.' On click the target is computed ONLY as the greatest passed anchor (recorded-healthy) at that instant — this is a fall-arrest catch, not a negotiated multi-step rollback — the leader makes one spring-eased translate straight there (cubic-bezier(0.34,1.56,0.64,1), 700ms), the target anchor gets a one-shot ring flash (500ms), and onArrest(stageId) fires. For that same 700ms a second, dashed rope segment is drawn between the target anchor and wherever the leader fell from, bowed to the identical 28px hard cap as the live rope and fading to transparent over the spring's own duration — the catch shows the rope taking the load, not just a dot relocating with nothing attached to it. The arrest position is a local optimistic override that is released automatically the moment the caller's own `stages` prop independently confirms the deploy is back at that same stage (a real rollback landing), so the component never fights a parent that is the actual source of truth. A repeat press while already arrested at the same target is a harmless no-op replay, not a toggle — arrest has no 'undo' click, matching a real fall-arrest catch. RENDER + A11Y: stages render as a real <ol> (not the SVG) with each stage's name and a 'status · timestamp' line as plain text, aria-current='step' on the stage the leader is currently at — the active/furthest-passed stage at rest, or the arrest's landing anchor once arrested, so the list never goes silent about where the deploy actually is; the rope/anchor SVG is aria-hidden. A permanently-mounted <p aria-live='polite'> (sr-only) announces every stage status change in plain language ('canary is now passed, 13:52.'); a separate <p aria-live='assertive'> (sr-only) announces only the arrest ('Arrested. Rolled back to 10% cohort, deployed 14:02.'). REDUCED MOTION: the leader's transition is removed entirely (teleports to its resting position), the live rope's bow offset is forced to 0 regardless of headroom (always redraws straight), and arrest is instant — the target still gets its one-shot ring flash, which is a single state change, not a repeating animation, so it stays inside prefers-reduced-motion. Every color is var(--background)/var(--foreground)/var(--ns-muted)/var(--border)/var(--ns-accent) — no hex, no rgb()/hsl(), --ns-accent used only for the arrest button's own focus-visible ring, never as a status or health indicator. Pure DOM + SVG + CSS transform transitions, no canvas, no rAF loop. Props: stages, headroom, onArrest(stageId), ariaLabel (default 'Deploy pipeline'), className.

Props

PropTypeDefaultDescription
stagesBelayStage[]pipeline stages, bottom of the pitch to the top — e.g. build..canary..100%
headroomnumber0..1 aggregated canary-health margin; drives how much the live rope bows
onArrest?(targetStageId: string) => voidfires with the stage id the arrest fell back to
ariaLabel?string"Deploy pipeline"accessible name for the pipeline group
className?string