Skip to main content

ns-ui

Slider Allocation Wire

Two-way allocation slider built as a slack wire between two labeled anchors: a bead sliding along the wire sets the ratio while the wire's own sag encodes the unallocated remainder, so a taut straight wire means fully committed and a deep droop means budget left on the table.

Usage

registry/core/slider-allocation-wire/demo.tsx
"use client";

import { useState } from "react";
import { CatenarySplit } from "./component";

export default function CatenarySplitDemo() {
  const [ratio, setRatio] = useState(62);
  const [total, setTotal] = useState(58);

  const [trafficRatio, setTrafficRatio] = useState(35);
  const [trafficTotal, setTrafficTotal] = useState(100);

  return (
    <div className="flex min-h-screen flex-col items-center justify-center gap-8 px-6 py-16">
      <p className="font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">
        ns-ui / slider-allocation-wire — the wire is the readout
      </p>

      <div className="w-full max-w-md rounded-md border border-border bg-surface p-6">
        <div className="mb-5">
          <h2 className="text-sm font-medium text-foreground">
            Cluster budget
          </h2>
          <p className="mt-0.5 text-xs text-ns-muted">
            Drag the bead to shift the split. Scroll or drag the total track
            to winch how much of the budget is actually committed.
          </p>
        </div>

        <CatenarySplit
          leftLabel="Compute"
          rightLabel="Storage"
          ratio={ratio}
          onRatioChange={setRatio}
          total={total}
          onTotalChange={setTotal}
          className="cs-primary"
        />
      </div>

      <div className="w-full max-w-md rounded-md border border-border bg-surface p-6">
        <div className="mb-5">
          <h2 className="text-sm font-medium text-foreground">
            Traffic weighting
          </h2>
          <p className="mt-0.5 text-xs text-ns-muted">
            Fully committed by default — the wire snaps taut with a decaying
            twang the moment total allocation lands at 100%.
          </p>
        </div>

        <CatenarySplit
          leftLabel="Primary"
          rightLabel="Canary"
          ratio={trafficRatio}
          onRatioChange={setTrafficRatio}
          total={trafficTotal}
          onTotalChange={setTrafficTotal}
        />
      </div>
    </div>
  );
}

Component

registry/core/slider-allocation-wire/component.tsx
"use client";

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

// ---------------------------------------------------------------------------
// CatenarySplit — a two-way allocation slider built as a slack wire between
// two labeled anchors. The bead's position along the wire sets the RATIO
// (left share vs right share); the wire's SAG encodes a second, independent
// variable — the unallocated remainder — so a taut straight wire reads as
// "fully committed" and a deep droop reads as "budget still on the table".
// Mechanism: an SVG quadratic bezier whose control-point depth is
// proportional to the unallocated fraction, rebuilt every frame; the bead is
// positioned by genuine arc-length parameterization (sampled table, not raw
// parametric t) so it slides evenly along whatever shape the wire currently
// has. A wheel/pinch on the wire, or dragging the paired "Total allocated"
// track, winches the total; easing toward a taut wire plays two decaying
// tension oscillations when it lands fully committed. Direct-DOM rAF hot
// path — React state only holds the committed integer values, per-frame
// geometry lives in refs/locals and is written straight to the DOM.
// ---------------------------------------------------------------------------

const VB_W = 400; // svg viewBox width (units, not px — scaled to the box)
const VB_H = 120;
const ANCHOR_X = 30; // inset of each anchor from the viewBox edges
const WIRE_Y = 46; // baseline y both anchors sit on
const MAX_DEPTH = 44; // control-point sag at fully unallocated (total = 0)
const SAMPLES = 48; // arc-length table resolution

const RATIO_K = 220; // keyboard/glide spring, s^-2 (critically-ish damped)
const RATIO_ZETA = 0.9;
const DEPTH_K = 130; // sag spring when NOT snapping taut
const DEPTH_ZETA = 1.0;
const TAUT_MS = 900; // scripted tension-wobble window when total hits 100
const TAUT_DECAY = 3.4;
const TAUT_CYCLES = 2; // exactly two damped oscillations
const SETTLE_MS = 1200; // forced-settle deadline for any pending motion

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

interface Pt {
  x: number;
  y: number;
}

function quadPoint(p0: Pt, p1: Pt, p2: Pt, t: number): Pt {
  const mt = 1 - t;
  return {
    x: mt * mt * p0.x + 2 * mt * t * p1.x + t * t * p2.x,
    y: mt * mt * p0.y + 2 * mt * t * p1.y + t * t * p2.y,
  };
}

interface ArcTable {
  xs: number[];
  ys: number[];
  lens: number[];
  total: number;
}

// Samples the current quadratic bezier into an arc-length lookup table. Since
// both anchors sit at the same y and the control point's x is always their
// midpoint, x(t) is monotonic — the table doubles as an x -> arc-length map.
function buildTable(p0: Pt, p1: Pt, p2: Pt): ArcTable {
  const xs: number[] = [p0.x];
  const ys: number[] = [p0.y];
  const lens: number[] = [0];
  let prevX = p0.x;
  let prevY = p0.y;
  let acc = 0;
  for (let i = 1; i <= SAMPLES; i++) {
    const { x, y } = quadPoint(p0, p1, p2, i / SAMPLES);
    acc += Math.hypot(x - prevX, y - prevY);
    xs.push(x);
    ys.push(y);
    lens.push(acc);
    prevX = x;
    prevY = y;
  }
  return { xs, ys, lens, total: Math.max(1e-6, acc) };
}

// Point at a given arc-length fraction (0..1) — genuine arc-length walk, not
// a lerp on parametric t.
function pointAtFraction(table: ArcTable, f: number): Pt {
  const target = clamp(f, 0, 1) * table.total;
  const { xs, ys, lens } = table;
  for (let i = 1; i < lens.length; i++) {
    if (lens[i] >= target) {
      const span = lens[i] - lens[i - 1];
      const u = span > 1e-6 ? (target - lens[i - 1]) / span : 0;
      return {
        x: xs[i - 1] + (xs[i] - xs[i - 1]) * u,
        y: ys[i - 1] + (ys[i] - ys[i - 1]) * u,
      };
    }
  }
  const last = lens.length - 1;
  return { x: xs[last], y: ys[last] };
}

// Inverse: arc-length fraction nearest a given x (binary search — x is
// monotonic by construction). Used to map a pointer position on the wire
// back to a ratio.
function fractionForX(table: ArcTable, x: number): number {
  const { xs, lens } = table;
  const hi0 = xs.length - 1;
  if (x <= xs[0]) return 0;
  if (x >= xs[hi0]) return 1;
  let lo = 0;
  let hi = hi0;
  while (hi - lo > 1) {
    const mid = (lo + hi) >> 1;
    if (xs[mid] < x) lo = mid;
    else hi = mid;
  }
  const span = xs[hi] - xs[lo];
  const u = span > 1e-6 ? (x - xs[lo]) / span : 0;
  const len = lens[lo] + (lens[hi] - lens[lo]) * u;
  return clamp(len / table.total, 0, 1);
}

export interface CatenarySplitProps {
  /** label for the left anchor, e.g. "Compute" */
  leftLabel?: string;
  /** label for the right anchor, e.g. "Storage" */
  rightLabel?: string;
  /** controlled split ratio, 0-100 (% of the committed total given to left) */
  ratio?: number;
  /** uncontrolled initial split ratio */
  defaultRatio?: number;
  /** controlled total committed, 0-100 (% of budget not left on the table) */
  total?: number;
  /** uncontrolled initial total committed */
  defaultTotal?: number;
  /** called with the new ratio on every drag */
  onRatioChange?: (v: number) => void;
  /** called with the new total on every drag */
  onTotalChange?: (v: number) => void;
  /** extra classes merged onto the rendered root element */
  className?: string;
}

export function CatenarySplit({
  leftLabel = "Compute",
  rightLabel = "Storage",
  ratio,
  defaultRatio = 60,
  total,
  defaultTotal = 65,
  onRatioChange,
  onTotalChange,
  className = "",
}: CatenarySplitProps) {
  const isRatioControlled = ratio !== undefined;
  const [ratioInternal, setRatioInternal] = useState(() =>
    clamp(defaultRatio, 0, 100)
  );
  const ratioValue = isRatioControlled ? (ratio as number) : ratioInternal;
  const ratioRef = useRef(ratioValue);
  ratioRef.current = ratioValue;

  const isTotalControlled = total !== undefined;
  const [totalInternal, setTotalInternal] = useState(() =>
    clamp(defaultTotal, 0, 100)
  );
  const totalValue = isTotalControlled ? (total as number) : totalInternal;
  const totalRef = useRef(totalValue);
  totalRef.current = totalValue;

  const [ratioActive, setRatioActive] = useState(false); // focus or drag
  const [totalActive, setTotalActive] = useState(false);

  const commitRatioRef = useRef<(v: number) => void>(() => {});
  commitRatioRef.current = (v: number) => {
    const q = clamp(Math.round(v), 0, 100);
    if (q === ratioRef.current) return;
    if (!isRatioControlled) setRatioInternal(q);
    onRatioChange?.(q);
  };
  const commitTotalRef = useRef<(v: number) => void>(() => {});
  commitTotalRef.current = (v: number) => {
    const q = clamp(Math.round(v), 0, 100);
    if (q === totalRef.current) return;
    if (!isTotalControlled) setTotalInternal(q);
    onTotalChange?.(q);
  };

  const wireBoxRef = useRef<HTMLDivElement>(null);
  const pathRef = useRef<SVGPathElement>(null);
  const beadRef = useRef<HTMLDivElement>(null);
  const leftPctRef = useRef<HTMLSpanElement>(null);
  const rightPctRef = useRef<HTMLSpanElement>(null);
  const totalTrackRef = useRef<HTMLDivElement>(null);
  const totalFillRef = useRef<HTMLDivElement>(null);
  const totalHandleRef = useRef<HTMLDivElement>(null);
  const totalCaptionRef = useRef<HTMLSpanElement>(null);

  const engineRef = useRef<{
    beadDragStart: (clientX: number) => void;
    beadDragMove: (clientX: number) => void;
    beadDragEnd: () => void;
    totalDragStart: (clientX: number) => void;
    totalDragMove: (clientX: number) => void;
    totalDragEnd: () => void;
    wheelTotal: (deltaPercent: number) => void;
    syncRatio: (v: number) => void;
    syncTotal: (v: number) => void;
  } | null>(null);

  useEffect(() => {
    const wireBox = wireBoxRef.current;
    const path = pathRef.current;
    const bead = beadRef.current;
    const leftPct = leftPctRef.current;
    const rightPct = rightPctRef.current;
    const totalTrack = totalTrackRef.current;
    const totalFill = totalFillRef.current;
    const totalHandle = totalHandleRef.current;
    const totalCaption = totalCaptionRef.current;
    if (
      !wireBox ||
      !path ||
      !bead ||
      !leftPct ||
      !rightPct ||
      !totalTrack ||
      !totalFill ||
      !totalHandle ||
      !totalCaption
    )
      return;

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

    let raf = 0;
    let last = 0;
    let visible = true;
    let scaleX = 1;
    let scaleY = 1;
    let sized = false;

    // -- hot-path state ------------------------------------------------------
    let aRatio = ratioRef.current; // animated ratio (0-100), what the bead reads
    let rv = 0; // ratio velocity
    let ratioTarget = ratioRef.current;
    let draggingRatio = false;
    let committedRatio = ratioRef.current; // dedupes onRatioChange during a drag

    let aDepth = MAX_DEPTH * (1 - totalRef.current / 100);
    let dv = 0; // depth velocity
    let depthTarget = aDepth;
    let tautAt = -1; // performance.now() when a taut-snap wobble started, -1 = none
    let tautFrom = 0;
    let draggingTotal = false;
    // hot-path mirror of the committed total, for immediate render feedback —
    // kept separate from totalRef (the React-state mirror) so drag/wheel
    // handlers never race the commit function's own dedup check
    let liveTotal = totalRef.current;

    let deadline = 0;

    const anchors = () => ({
      p0: { x: ANCHOR_X, y: WIRE_Y },
      p2: { x: VB_W - ANCHOR_X, y: WIRE_Y },
    });

    const tableFor = (depth: number) => {
      const { p0, p2 } = anchors();
      const p1 = { x: (p0.x + p2.x) / 2, y: WIRE_Y + depth };
      return { table: buildTable(p0, p1, p2), p0, p1, p2 };
    };

    const render = () => {
      const { table, p0, p1, p2 } = tableFor(aDepth);
      path.setAttribute(
        "d",
        `M ${p0.x} ${p0.y} Q ${p1.x} ${p1.y} ${p2.x} ${p2.y}`
      );
      const bp = pointAtFraction(table, aRatio / 100);
      bead.style.transform = `translate3d(${(bp.x * scaleX).toFixed(2)}px, ${(
        bp.y * scaleY
      ).toFixed(2)}px, 0) translate(-50%, -50%)`;

      const leftRounded = Math.round(aRatio);
      leftPct.textContent = `${leftRounded}%`;
      rightPct.textContent = `${100 - leftRounded}%`;

      const totalRounded = clamp(Math.round(liveTotal), 0, 100);
      totalCaption.textContent = `${totalRounded}%`;
      totalFill.style.width = `${totalRounded}%`;
      totalHandle.style.left = `${totalRounded}%`;
    };

    const resize = () => {
      const rect = wireBox.getBoundingClientRect();
      if (rect.width < 40 || rect.height < 20) {
        sized = false;
        return;
      }
      scaleX = rect.width / VB_W;
      scaleY = rect.height / VB_H;
      sized = true;
      render();
    };
    resize();

    const loop = (now: number) => {
      raf = 0;
      const dt = last ? Math.min(0.033, (now - last) / 1000) : 1 / 60;
      last = now;
      let active = false;

      // ratio: pinned to pointer while dragging, otherwise a plain glide
      if (!draggingRatio) {
        const c = 2 * RATIO_ZETA * Math.sqrt(RATIO_K);
        rv += (-RATIO_K * (aRatio - ratioTarget) - c * rv) * dt;
        aRatio += rv * dt;
        if (Math.abs(aRatio - ratioTarget) < 0.05 && Math.abs(rv) < 0.5) {
          aRatio = ratioTarget;
          rv = 0;
        } else active = true;
      }

      // depth: either the scripted taut-snap wobble, or a plain damped glide
      if (tautAt >= 0) {
        const p = clamp((now - tautAt) / TAUT_MS, 0, 1);
        if (p >= 1) {
          aDepth = 0;
          dv = 0;
          tautAt = -1;
        } else {
          aDepth =
            tautFrom *
            Math.exp(-TAUT_DECAY * p) *
            Math.cos(2 * Math.PI * TAUT_CYCLES * p);
          active = true;
        }
      } else {
        // eases toward depthTarget on EVERY total-changing path, including a
        // live drag on the paired track — winching reads as mechanical drag,
        // never a jump cut, so the spring keeps running while draggingTotal
        // is true instead of freezing until pointer-up
        const c = 2 * DEPTH_ZETA * Math.sqrt(DEPTH_K);
        dv += (-DEPTH_K * (aDepth - depthTarget) - c * dv) * dt;
        aDepth += dv * dt;
        if (Math.abs(aDepth - depthTarget) < 0.05 && Math.abs(dv) < 0.5) {
          aDepth = depthTarget;
          dv = 0;
        } else active = true;
      }

      if (active && now > deadline) {
        aRatio = ratioTarget;
        rv = 0;
        if (tautAt < 0) {
          aDepth = depthTarget;
          dv = 0;
        }
        active = false;
      }

      if (sized) render();
      if (active && visible && !document.hidden) {
        raf = requestAnimationFrame(loop);
      }
    };

    const wake = () => {
      if (!raf && sized) {
        last = 0;
        raf = requestAnimationFrame(loop);
      }
    };

    const armDeadline = () => {
      deadline = performance.now() + SETTLE_MS;
    };

    const setRatioTarget = (v: number) => {
      ratioTarget = v;
      if (reduced) {
        aRatio = v;
        rv = 0;
        if (sized) render();
        return;
      }
      armDeadline();
      wake();
    };

    // Every path that changes the total (drag on the paired track, wheel/
    // pinch on the wire, keyboard) routes through here so the sag always
    // eases rather than teleporting — including while actively dragging the
    // total track, so "winching" reads as mechanical, not instant. That is
    // also what lets the taut-snap wobble fire on drag: the transition is
    // detected the moment the eased depth's TARGET reaches zero, while the
    // animated depth itself is still mid-glide and has real energy to decay.
    const setTotalTarget = (v: number) => {
      const wasFull = depthTarget <= 0.01;
      depthTarget = MAX_DEPTH * (1 - v / 100);
      const nowFull = depthTarget <= 0.01;
      if (reduced) {
        aDepth = depthTarget;
        dv = 0;
        tautAt = -1;
        if (sized) render();
        return;
      }
      if (nowFull && !wasFull && tautAt < 0) {
        // landing on a fully-committed total plucks the wire taut — two
        // decaying tension oscillations from wherever the sag currently is
        tautFrom = aDepth;
        tautAt = performance.now();
      }
      armDeadline();
      wake();
    };

    // -- bead drag: arc-length projection of the pointer onto the CURRENT
    // curve shape, so the bead genuinely rides whatever sag is on screen --
    const beadDragStart = (clientX: number) => {
      if (!sized) return;
      draggingRatio = true;
      beadDragMove(clientX);
    };
    const beadDragMove = (clientX: number) => {
      if (!sized || !draggingRatio) return;
      const rect = wireBox.getBoundingClientRect();
      const x = (clientX - rect.left) / scaleX;
      const { table } = tableFor(aDepth);
      const f = fractionForX(table, x);
      aRatio = f * 100;
      rv = 0;
      const q = clamp(Math.round(aRatio), 0, 100);
      ratioTarget = q;
      if (q !== committedRatio) {
        committedRatio = q;
        commitRatioRef.current(q);
      }
      armDeadline();
      if (sized) render();
    };
    const beadDragEnd = () => {
      if (!draggingRatio) return;
      draggingRatio = false;
      ratioTarget = clamp(Math.round(aRatio), 0, 100);
      aRatio = ratioTarget;
      armDeadline();
      wake();
    };

    // -- total drag: linear along the paired track ---------------------------
    const totalDragStart = (clientX: number) => {
      if (!sized) return;
      draggingTotal = true;
      totalDragMove(clientX);
    };
    const totalDragMove = (clientX: number) => {
      if (!draggingTotal) return;
      const rect = totalTrack.getBoundingClientRect();
      const f = rect.width > 0 ? (clientX - rect.left) / rect.width : 0;
      const v = clamp(Math.round(f * 100), 0, 100);
      liveTotal = v; // reflected immediately for the caption/fill/handle
      commitTotalRef.current(v);
      setTotalTarget(v); // eases like every other total-changing path
    };
    const totalDragEnd = () => {
      if (!draggingTotal) return;
      draggingTotal = false;
      setTotalTarget(liveTotal); // idempotent — already the drag target
    };

    const wheelTotal = (deltaPercent: number) => {
      const next = clamp(Math.round(liveTotal + deltaPercent), 0, 100);
      if (next === liveTotal) return;
      liveTotal = next;
      commitTotalRef.current(next);
      setTotalTarget(next);
    };

    // external/keyboard commits: glide to the new value unless that axis is
    // mid-drag, in which case the pointer already owns it this frame
    const syncRatio = (v: number) => {
      if (draggingRatio) return;
      committedRatio = v;
      setRatioTarget(v);
    };
    const syncTotal = (v: number) => {
      if (draggingTotal) return;
      liveTotal = v;
      setTotalTarget(v);
    };

    engineRef.current = {
      beadDragStart,
      beadDragMove,
      beadDragEnd,
      totalDragStart,
      totalDragMove,
      totalDragEnd,
      wheelTotal,
      syncRatio,
      syncTotal,
    };

    const ro = new ResizeObserver(resize);
    ro.observe(wireBox);
    const io = new IntersectionObserver((entries) => {
      visible = entries[0]?.isIntersecting ?? true;
      if (visible) wake();
    });
    io.observe(wireBox);
    const onVis = () => {
      if (!document.hidden) wake();
    };
    document.addEventListener("visibilitychange", onVis);

    // React registers wheel listeners as passive by default, so a synthetic
    // onWheel prop can never call preventDefault without throwing — bind a
    // real non-passive listener instead so scroll/pinch on the wire can
    // actually consume the gesture instead of also scrolling the page.
    const onWheel = (e: WheelEvent) => {
      e.preventDefault();
      // trackpad pinch reports as a wheel event with ctrlKey set
      const scale = e.ctrlKey ? 0.6 : 0.15;
      engineRef.current?.wheelTotal(-e.deltaY * scale);
    };
    wireBox.addEventListener("wheel", onWheel, { passive: false });

    // keep the animated targets aligned with external/committed value changes
    setRatioTarget(ratioRef.current);
    setTotalTarget(totalRef.current);

    return () => {
      cancelAnimationFrame(raf);
      ro.disconnect();
      io.disconnect();
      document.removeEventListener("visibilitychange", onVis);
      wireBox.removeEventListener("wheel", onWheel);
      engineRef.current = null;
    };
    // mount-only: geometry constants are module-level, external value syncs
    // are handled by the effects below via engineRef.syncRatio/syncTotal
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // external/keyboard ratio and total changes glide toward the new value
  // (a live drag on that same axis already owns the frame and is skipped)
  useEffect(() => {
    engineRef.current?.syncRatio(ratioValue);
  }, [ratioValue]);
  useEffect(() => {
    engineRef.current?.syncTotal(totalValue);
  }, [totalValue]);

  // -- keyboard: bead ("Split") ----------------------------------------------
  const onBeadKeyDown = (e: React.KeyboardEvent) => {
    const v = ratioRef.current;
    let next: number | null = null;
    switch (e.key) {
      case "ArrowRight":
      case "ArrowUp":
        next = v + 1;
        break;
      case "ArrowLeft":
      case "ArrowDown":
        next = v - 1;
        break;
      case "PageUp":
        next = v + 10;
        break;
      case "PageDown":
        next = v - 10;
        break;
      case "Home":
        next = 0;
        break;
      case "End":
        next = 100;
        break;
      default:
        return;
    }
    e.preventDefault();
    next = clamp(next, 0, 100);
    if (next !== v) commitRatioRef.current(next);
  };

  // -- keyboard: "Total allocated" -------------------------------------------
  const onTotalKeyDown = (e: React.KeyboardEvent) => {
    const v = totalRef.current;
    let next: number | null = null;
    switch (e.key) {
      case "ArrowRight":
      case "ArrowUp":
        next = v + 1;
        break;
      case "ArrowLeft":
      case "ArrowDown":
        next = v - 1;
        break;
      case "PageUp":
        next = v + 10;
        break;
      case "PageDown":
        next = v - 10;
        break;
      case "Home":
        next = 0;
        break;
      case "End":
        next = 100;
        break;
      default:
        return;
    }
    e.preventDefault();
    next = clamp(next, 0, 100);
    if (next !== v) commitTotalRef.current(next);
  };

  const leftPctNow = Math.round(ratioValue);
  const rightPctNow = 100 - leftPctNow;
  const totalNow = Math.round(totalValue);
  const beadValueText = `${leftPctNow}% ${leftLabel.toLowerCase()}, ${rightPctNow}% ${rightLabel.toLowerCase()}`;
  const totalValueText = `${totalNow}% of budget allocated`;

  return (
    <div className={`w-full select-none ${className}`}>
      <div
        ref={wireBoxRef}
        className="group relative h-28 w-full touch-none"
        onPointerDown={(e) => {
          e.preventDefault();
          e.currentTarget.setPointerCapture(e.pointerId);
          beadRef.current?.focus({ preventScroll: true });
          engineRef.current?.beadDragStart(e.clientX);
          setRatioActive(true);
        }}
        onPointerMove={(e) => engineRef.current?.beadDragMove(e.clientX)}
        onPointerUp={() => {
          engineRef.current?.beadDragEnd();
          setRatioActive(false);
        }}
        onPointerCancel={() => {
          engineRef.current?.beadDragEnd();
          setRatioActive(false);
        }}
      >
        <svg
          aria-hidden
          viewBox={`0 0 ${VB_W} ${VB_H}`}
          preserveAspectRatio="none"
          className="absolute inset-0 h-full w-full cursor-ew-resize"
        >
          <path
            ref={pathRef}
            fill="none"
            stroke="var(--ns-muted)"
            strokeWidth={1.5}
            strokeLinecap="round"
          />
          <circle cx={ANCHOR_X} cy={WIRE_Y} r={3} fill="var(--border)" />
          <circle
            cx={VB_W - ANCHOR_X}
            cy={WIRE_Y}
            r={3}
            fill="var(--border)"
          />
        </svg>

        <div
          ref={beadRef}
          role="slider"
          tabIndex={0}
          aria-label="Split"
          aria-orientation="horizontal"
          aria-valuemin={0}
          aria-valuemax={100}
          aria-valuenow={leftPctNow}
          aria-valuetext={beadValueText}
          onKeyDown={onBeadKeyDown}
          onFocus={() => setRatioActive(true)}
          onBlur={() => setRatioActive(false)}
          className="absolute left-0 top-0 h-7 w-7 cursor-grab rounded-full outline-none will-change-transform active:cursor-grabbing focus-visible:ring-2 focus-visible:ring-ns-accent focus-visible:ring-offset-2 focus-visible:ring-offset-background"
        >
          <span
            aria-hidden
            data-active={ratioActive}
            className="absolute inset-[3px] rounded-full border border-border bg-background transition-colors duration-150 group-hover:border-foreground/40 data-[active=true]:border-ns-accent"
          />
          <span
            aria-hidden
            className="absolute left-1/2 top-1/2 h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-foreground"
          />
        </div>
      </div>

      <div className="flex items-start justify-between px-0.5 pt-1 font-mono">
        <div className="flex flex-col gap-0.5">
          <span className="text-xs text-ns-muted">{leftLabel}</span>
          <span
            ref={leftPctRef}
            className="tabular-nums text-sm text-foreground"
          >
            {leftPctNow}%
          </span>
        </div>
        <div className="flex flex-col items-end gap-0.5">
          <span className="text-xs text-ns-muted">{rightLabel}</span>
          <span
            ref={rightPctRef}
            className="tabular-nums text-sm text-foreground"
          >
            {rightPctNow}%
          </span>
        </div>
      </div>

      <div className="mt-5">
        <div className="flex items-center justify-between pb-1.5">
          <span className="font-mono text-xs text-ns-muted">
            Total allocated
          </span>
          <span
            ref={totalCaptionRef}
            className="font-mono text-xs tabular-nums text-ns-muted"
          >
            {totalNow}%
          </span>
        </div>
        <div
          ref={totalTrackRef}
          role="slider"
          tabIndex={0}
          aria-label="Total allocated"
          aria-orientation="horizontal"
          aria-valuemin={0}
          aria-valuemax={100}
          aria-valuenow={totalNow}
          aria-valuetext={totalValueText}
          onKeyDown={onTotalKeyDown}
          onFocus={() => setTotalActive(true)}
          onBlur={() => setTotalActive(false)}
          onPointerDown={(e) => {
            e.preventDefault();
            e.currentTarget.setPointerCapture(e.pointerId);
            engineRef.current?.totalDragStart(e.clientX);
            setTotalActive(true);
          }}
          onPointerMove={(e) => engineRef.current?.totalDragMove(e.clientX)}
          onPointerUp={() => {
            engineRef.current?.totalDragEnd();
            setTotalActive(false);
          }}
          onPointerCancel={() => {
            engineRef.current?.totalDragEnd();
            setTotalActive(false);
          }}
          className="relative h-4 w-full cursor-ew-resize touch-none outline-none focus-visible:ring-2 focus-visible:ring-ns-accent focus-visible:ring-offset-2 focus-visible:ring-offset-background"
        >
          <span className="absolute left-0 top-1/2 h-1 w-full -translate-y-1/2 rounded-full bg-border" />
          <div
            ref={totalFillRef}
            className="absolute left-0 top-1/2 h-1 -translate-y-1/2 rounded-full bg-foreground/70"
            style={{ width: `${totalNow}%` }}
          />
          <div
            ref={totalHandleRef}
            data-active={totalActive}
            style={{ left: `${totalNow}%` }}
            className="absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full border border-border bg-background transition-colors duration-150 data-[active=true]:border-ns-accent"
          />
        </div>
      </div>
    </div>
  );
}

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

Use when an allocation control where BOTH a ratio and a total-committed amount matter at once — budget splits, traffic weighting, quota assignment — and the wire's own sag is the second variable's readout, not just a ratio rail. Reach for slider-loupe instead for a single precise value read through a magnifying lens (audio scrub, zoom level), and pricing-scale for a literal two-option weighing/comparison rather than a continuous split-plus-commitment.

Install

npx shadcn add https://design.helpmarq.com/r/slider-allocation-wire.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 a two-way allocation slider drawn as a slack wire strung between two labeled anchors (default 'Compute' / 'Storage', both overridable via leftLabel/rightLabel props). Two independent 0-100 values live in the geometry: `ratio` is the bead's position along the wire (share going to the left anchor) and `total` is how much of the budget is actually committed, expressed as the wire's SAG — total=100 pulls it dead straight, total=0 sags to a deep, clearly visible droop. RENDER: an SVG quadratic bezier (`M p0 Q control p2`, viewBox 400x120, preserveAspectRatio=none so it maps 1:1 to the container box) whose control point sits at the anchors' x-midpoint and a y-depth of `MAX_DEPTH * (1 - total/100)`, rebuilt every frame from a freshly-sampled 48-point arc-length lookup table (never a cached shape) so the curve, and everything derived from it, is always current. The bead is a real DOM node (not an SVG shape) layered on top, positioned every frame by walking that SAME arc-length table to the point at `ratio`% of total arc length — genuine arc-length parameterization, not a lerp on the bezier's raw parametric t, so the bead visibly slides evenly along whatever shape the wire currently has, speeding up/slowing down across the sag exactly like a bead on a real slack cord would. Stroke is `var(--ns-muted)`; the bead is a --border-ringed dot that swaps to --ns-accent only while focused or dragging (never at rest); two small --border anchor dots mark the wire's ends; the curve, dots, and wire are all `aria-hidden` — every bit of state lives in the two slider values, never in the drawing. INTERACTION: (1) dragging anywhere on the wire area projects the pointer's x onto the CURRENT curve's arc-length table to set the ratio directly, pinned 1:1, no lag; (2) a second, separate 'Total allocated' track below the anchors' numbers is a paired linear slider (drag it directly, same 0-100 range) that winches the total; (3) wheel or trackpad-pinch (pinch reports as a wheel event with ctrlKey set) anywhere on the wire ALSO winches the total, so the wire itself doubles as a scroll-to-commit control. Every total-changing path — the paired track's drag, wheel/pinch, and keyboard — eases the sag through the same damped spring (critically damped, ~130 s^-2) rather than teleporting, so 'winching' reads as mechanical drag, not a jump cut; when the eased sag's TARGET newly lands at zero (total reaches exactly 100, wire fully taut) the depth is handed off to a scripted decaying cosine — `depth(t) = fromDepth * e^(-3.4p) * cos(4*pi*p)` over 900ms, exactly two visible oscillations — so the wire visibly twangs taut instead of just stopping, then falls back to the normal spring. Loosening back off from 100 never wobbles, only landing on fully-committed does. A forced-settle deadline (1.2s) guarantees any pending glide or wobble always resolves. Per-anchor split numbers (Geist Mono, tabular-nums) sit directly beneath the wire and are read straight off the animated bead position every frame, so they visibly count as the bead (or the whole curve, during a total change) moves; a third mono readout shows the live total percentage above its paired track. ACCESSIBILITY: two independent role=slider controls, nothing else holds state. The bead: aria-label 'Split', aria-valuemin/max 0/100, aria-valuenow the left anchor's percentage, aria-valuetext like '60% compute, 40% storage' built from the actual anchor labels; ArrowLeft/Right (and Up/Down) step 1%, PageUp/PageDown step 10%, Home/End jump to 0/100. The paired track: aria-label 'Total allocated', same aria-valuemin/max/now/key model, aria-valuetext like '85% of budget allocated'. Both are real focusable elements (tabIndex 0) reachable by Tab in document order, both use `outline-none` paired ONLY with `focus-visible:ring-2 focus-visible:ring-ns-accent` (never `focus-visible:outline-*` on the same element) so the focus ring is never invisible. REDUCED MOTION: both springs and the taut-snap wobble are skipped entirely — ratio and total jump straight to their target every time — while dragging still tracks the pointer 1:1 exactly as before, so the component stays fully usable and legible with zero animation. PERF: direct-DOM rAF hot path — React state holds only the two committed integers (controlled/uncontrolled, each with its own onChange), all per-frame geometry (bead transform, path `d`, number text, track fill/handle position) is written straight to refs, never through React re-render; the loop sleeps at a velocity/position epsilon, is paused by an IntersectionObserver when offscreen and on document.hidden, and a ResizeObserver keeps the SVG-to-container scale factors current. No dependencies, no canvas — pure SVG + DOM + CSS, tokens only (--background --foreground --ns-muted --border --ns-accent), correct in both themes.

Props

PropTypeDefaultDescription
leftLabel?string"Compute"label for the left anchor, e.g. "Compute"
rightLabel?string"Storage"label for the right anchor, e.g. "Storage"
ratio?number—controlled split ratio, 0-100 (% of the committed total given to left)
defaultRatio?number60uncontrolled initial split ratio
total?number—controlled total committed, 0-100 (% of budget not left on the table)
defaultTotal?number65uncontrolled initial total committed
onRatioChange?(v: number) => void—called with the new ratio on every drag
onTotalChange?(v: number) => void—called with the new total on every drag
className?string—extra classes merged onto the rendered root element