Skip to main content

ns-ui

Flyball Throttle

A spend-cap widget drawn as a Watt centrifugal governor: two hinged arms with ball weights fly wider as burn rate outpaces the sustainable rate, the sleeve collar they drive slides down the spindle, and the collar's own linkage swings a throttle lever closed over the purchase actions — reading the derivative of spend, not the level, so 60% spent stays calm on day 28 and flares wide on day 6.

Use when a usage-based spend guard — API spend, ad budget, prepaid balances — where the thing worth reading at rest is the rate of burn relative to a sustainable pace, not how much of the cap is gone; the arms answer 'is this about to blow through the cap' the instant you look, before any percentage math. Pick gauge-capacity-waterline instead for a hard legal/contractual limit where crossing the line is the whole event and the current load level is what matters; pick meter-threshold-trip for a plain trip/re-arm pair with no notion of a rate or a forecast at all. flyball-throttle's `spendRate` and `cap`/`periodDays` are its own scalar (omega) driving the geometry — `spent` only gates the real buttons, it never touches the arms, which is the opposite of both siblings' level-driven fills.

Install

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

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

// ---------------------------------------------------------------------------
// FlyballThrottle — a spend-cap widget built as a Watt centrifugal governor.
// One governing scalar, omega = spendRate / safeRate (safeRate = cap /
// periodDays), drives the whole mechanism: arm elevation angle rises with
// omega squared (capped at 68deg — the linkage self-intersects past that),
// the sleeve collar's drop down the spindle is computed FROM that angle
// through the drawn linkage (armLength * (1 - cos angle)), and the throttle
// lever's rotation maps LINEARLY from the collar's drop, sweeping across a
// small gate track until it lies flat over it at full flare. Axial spin is
// faked (never truly rotated — this is a side-profile diagram) by oscillating
// the two-arm-and-balls group's x-scale on one shared keyframe, at a period
// of SPIN_BASE_MS / omega set through a single CSS custom property — lazy
// spin reads as healthy, a fast blur reads as hot, with no per-ball
// choreography. `value`-style level state (percent of cap already spent)
// never touches the geometry: the arms answer "how fast is it burning right
// now", not "how much is gone" — that distinction is the whole point, and
// is what keeps this legible on day 28 of a calm, on-pace period as well as
// day 6 of a reckless one. Every geometry change (arm/ball position, collar
// drop, lever angle) rides one shared 250ms non-overshooting CSS transition
// on the transitionable SVG geometry properties (d/cx/cy/x/y — all
// CSS-animatable in evergreen browsers) so a stream of per-request omega
// updates settles smoothly instead of twitching frame to frame; no JS
// spring loop. `spent` vs `cap` is a genuinely separate, level-based
// boolean (capReached) that disables the two real action buttons — with
// aria-disabled (not the native attribute, so Tab still reaches them) plus
// an explanatory paragraph — and tints the collar's fill; it never feeds
// the arm/collar/lever geometry itself. DOM+SVG+CSS only, no canvas, every
// stroke/fill a `var(--token)`, --ns-accent reserved for focus/hover.
// ---------------------------------------------------------------------------

export interface FlyballThrottleProps {
  /** current burn rate, currency units per day */
  spendRate: number;
  /** total spend cap for the period */
  cap: number;
  /** length of the cap period, in days */
  periodDays: number;
  /** amount already spent this period — the level that actually gates the buttons */
  spent: number;
  /** currency symbol prefix for readouts (default "$") */
  currency?: string;
  /** what's being governed, shown above the readout, e.g. "API spend" */
  label?: string;
  /** called when "New purchase" is activated while under cap */
  onNewPurchase?: () => void;
  /** called when "Raise limit" is activated while under cap */
  onRaiseLimit?: () => void;
  /** extra classes merged onto the rendered root element */
  className?: string;
}

// geometry, SVG viewBox units — one side-profile governor: lever+gate at the
// top, arm pivot and flyball assembly below it, sleeve collar sliding on the
// spindle below that, motor housing at the base.
const VIEW_W = 280;
const VIEW_H = 260;
const CX = 140;

const LEVER_PIVOT_Y = 48;
const LEVER_LEN = 84;
const LEVER_OPEN_DEG = 30; // resting angle above the gate track — clearly open
const LEVER_CLOSED_DEG = 0; // flat along the gate track — fully closed over it
const GATE_X1 = CX + LEVER_LEN; // where the gate track ends, == lever tip when flat

const ARM_PIVOT_Y = 108;
const ARM_LEN = 46;
const BALL_R = 7;
const MAX_ARM_DEG = 68; // the linkage self-intersects past this — hard ceiling
const MAX_ARM_RAD = (MAX_ARM_DEG * Math.PI) / 180;
const MAX_COLLAR_DROP = ARM_LEN * (1 - Math.cos(MAX_ARM_RAD));
const ANGLE_K = 22; // deg of arm elevation per omega^2 — small-physics approximation
// real flyball arms hinge on a finite pivot and never fold flush against the
// shaft — without a floor, low-omega ball centers sit closer together than
// their own 2*BALL_R diameter and merge into one blob, which is exactly the
// idle/default frame the registry's owner judges first and hardest. 13deg
// keeps the two balls visibly separated (2*ARM_LEN*sin(13deg) ≈ 20.7px
// center-to-center against a 14px diameter) at omega -> 0.
const MIN_HANG_DEG = 13;

const COLLAR_Y0 = 168; // collar's reference position at omega -> 0 (idle)
const COLLAR_W = 34;
const COLLAR_H = 10;

const MOTOR_TOP_Y = 214;
const MOTOR_H = 30;

const HOT_OMEGA = 1.3; // burn meaningfully above sustainable pace, short of capped
const SPIN_BASE_MS = 900; // spin period at omega == 1 (running exactly at sustainable pace)
const MIN_SPIN_MS = 260;
const MAX_SPIN_MS = 4000;
const MIN_OMEGA_FOR_SPIN = 0.08; // divisor floor so idle settles at MAX_SPIN_MS, not Infinity

type Band = "calm" | "hot" | "closed";

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

function fmtMoney(n: number, currency: string) {
  if (!Number.isFinite(n)) return `${currency}0`;
  const v = Math.max(0, n);
  const text = v < 10 && !Number.isInteger(v) ? v.toFixed(1) : Math.round(v).toString();
  return `${currency}${text}`;
}

function fmtDays(n: number) {
  if (!Number.isFinite(n) || n <= 0) return "today";
  if (n < 1) return "under a day";
  const r = Math.round(n);
  return `${r} day${r === 1 ? "" : "s"}`;
}

export function FlyballThrottle({
  spendRate,
  cap,
  periodDays,
  spent,
  currency = "$",
  label = "Spend governor",
  onNewPurchase,
  onRaiseLimit,
  className = "",
}: FlyballThrottleProps) {
  const uid = useId();
  const labelId = `${uid}-label`;
  const descId = `${uid}-desc`;
  const closedNoteId = `${uid}-closed`;

  const safeSpendRate = Math.max(0, spendRate);
  const safeRate = periodDays > 0 ? cap / periodDays : 0;
  const omega = safeRate > 0 ? safeSpendRate / safeRate : safeSpendRate > 0 ? Number.POSITIVE_INFINITY : 0;
  const omegaForGeometry = Number.isFinite(omega) ? omega : 999;

  const angleDeg = clamp(
    MIN_HANG_DEG + ANGLE_K * omegaForGeometry * omegaForGeometry,
    MIN_HANG_DEG,
    MAX_ARM_DEG
  );
  const angleRad = (angleDeg * Math.PI) / 180;

  const ballDX = ARM_LEN * Math.sin(angleRad);
  const ballDY = ARM_LEN * Math.cos(angleRad);
  const ballLeftX = CX - ballDX;
  const ballRightX = CX + ballDX;
  const ballY = ARM_PIVOT_Y + ballDY;

  const collarDrop = ARM_LEN * (1 - Math.cos(angleRad));
  const collarY = COLLAR_Y0 + collarDrop;

  const throttleFrac = MAX_COLLAR_DROP > 0 ? clamp(collarDrop / MAX_COLLAR_DROP, 0, 1) : 0;
  const leverDeg = LEVER_OPEN_DEG - (LEVER_OPEN_DEG - LEVER_CLOSED_DEG) * throttleFrac;
  const leverRad = (leverDeg * Math.PI) / 180;
  const leverEndX = CX + LEVER_LEN * Math.cos(leverRad);
  const leverEndY = LEVER_PIVOT_Y - LEVER_LEN * Math.sin(leverRad);

  const spinMs = clamp(SPIN_BASE_MS / Math.max(omegaForGeometry, MIN_OMEGA_FOR_SPIN), MIN_SPIN_MS, MAX_SPIN_MS);

  // capReached is a real, independent level check — spent vs cap — never the
  // arm/collar/lever geometry above, which is entirely rate-derived (omega).
  const capReached = cap > 0 && spent >= cap;

  const daysToCap = safeSpendRate > 0 ? (cap - spent) / safeSpendRate : Number.POSITIVE_INFINITY;
  const forecastText = capReached
    ? "the cap has been reached for this period"
    : !Number.isFinite(daysToCap) || daysToCap > periodDays
      ? "on pace to stay under cap this period"
      : `cap reached in ${fmtDays(daysToCap)} at this pace`;

  const band: Band = capReached ? "closed" : omega >= HOT_OMEGA ? "hot" : "calm";
  const [announce, setAnnounce] = useState("");
  const prevBandRef = useRef<Band | null>(null);

  useEffect(() => {
    if (prevBandRef.current === null) {
      prevBandRef.current = band; // no crossing announcement on first paint
      return;
    }
    if (prevBandRef.current === band) return;
    prevBandRef.current = band;
    if (band === "closed") {
      setAnnounce(
        `Closed — spend cap of ${fmtMoney(cap, currency)} reached; new purchases and limit increases are disabled until next period.`
      );
    } else if (band === "hot") {
      setAnnounce(
        `Hot — burning ${fmtMoney(safeSpendRate, currency)}/day against a ${fmtMoney(safeRate, currency)}/day sustainable rate.`
      );
    } else {
      setAnnounce("Calm — burn rate back under the sustainable pace.");
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [band]);

  const btnBase =
    "flex-1 rounded-md border px-3 py-2 text-sm font-medium transition-colors duration-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent";
  const btnLive =
    "border-border bg-background text-foreground hover:border-foreground/30 hover:bg-foreground/[0.05] cursor-pointer";
  const btnDead = "border-border bg-background text-ns-muted opacity-50 cursor-not-allowed";

  return (
    <div
      role="group"
      data-flyball-root
      aria-labelledby={labelId}
      aria-describedby={descId}
      className={`w-full max-w-md rounded-md border border-border bg-background p-5 ${className}`}
    >
      <style>{`
.ns-flyball-part{transition-property:d,cx,cy,x,y}
.ns-flyball-part{transition-duration:250ms;transition-timing-function:cubic-bezier(0.16,1,0.3,1)}
@keyframes ns-flyball-spin{0%,100%{transform:scaleX(1)}50%{transform:scaleX(0.74)}}
.ns-flyball-spin-group{
  animation:ns-flyball-spin var(--ns-flyball-spin-ms,1400ms) linear infinite;
  transform-box:view-box;
  transform-origin:${CX}px ${ARM_PIVOT_Y}px;
}
@media (prefers-reduced-motion: reduce){
  /* Not a snap: a state change (a press moving omega) still needs to read as
     movement, just without the ambient never-ending spin blur, which is the
     part reduced-motion is actually meant to suppress. Short, linear, no
     overshoot — small-amplitude SVG attribute interpolation, not parallax
     or anything vestibular. */
  .ns-flyball-part{transition-duration:120ms !important; transition-timing-function:linear !important}
  .ns-flyball-spin-group{animation:none !important}
}
`}</style>

      <div className="flex items-baseline justify-between gap-3">
        <span id={labelId} className="font-mono text-[11px] tracking-wide text-ns-muted">
          {label.toUpperCase()}
        </span>
        <span className="font-mono text-[11px] font-semibold tracking-wide text-foreground">
          {band.toUpperCase()}
        </span>
      </div>

      <p id={descId} className="mt-2 text-sm leading-relaxed text-foreground">
        Spending <span className="font-mono font-semibold">{fmtMoney(safeSpendRate, currency)}</span>/day against a{" "}
        <span className="font-mono">{fmtMoney(safeRate, currency)}</span>/day sustainable rate — {forecastText}.
      </p>

      <div aria-hidden="true" className="mt-3 flex justify-center">
        <svg
          viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
          className="h-[210px] w-full max-w-[260px]"
          aria-hidden="true"
          focusable="false"
        >
          {/* spindle shaft */}
          <line x1={CX} y1={LEVER_PIVOT_Y} x2={CX} y2={MOTOR_TOP_Y} stroke="var(--border)" strokeWidth={2} />

          {/* motor housing, fixed reference at the base */}
          <rect
            x={CX - 30}
            y={MOTOR_TOP_Y}
            width={60}
            height={MOTOR_H}
            rx={4}
            fill="none"
            stroke="var(--border)"
            strokeWidth={2}
          />

          {/* pushrod — collar to lever pivot, the coupling that makes the collar "drive" the lever */}
          <line
            className="ns-flyball-part"
            x1={CX + 6}
            y1={collarY}
            x2={CX + 6}
            y2={LEVER_PIVOT_Y + 4}
            stroke="var(--border)"
            strokeWidth={1.25}
            strokeDasharray="2 5"
          />

          {/* throttle gate track + the two action glyphs the lever sweeps over */}
          <line
            x1={CX}
            y1={LEVER_PIVOT_Y}
            x2={GATE_X1}
            y2={LEVER_PIVOT_Y}
            stroke="var(--border)"
            strokeWidth={2}
            strokeLinecap="round"
            opacity={0.55}
          />
          <rect
            x={CX + 20}
            y={LEVER_PIVOT_Y + 8}
            width={16}
            height={10}
            rx={2}
            fill="none"
            stroke="var(--border)"
            strokeWidth={1.25}
          />
          <rect
            x={CX + 48}
            y={LEVER_PIVOT_Y + 8}
            width={16}
            height={10}
            rx={2}
            fill="none"
            stroke="var(--border)"
            strokeWidth={1.25}
          />

          {/* lever pivot joint + the lever itself, rotation mapped linearly from collar drop */}
          <circle cx={CX} cy={LEVER_PIVOT_Y} r={3} fill="var(--foreground)" />
          <path
            className="ns-flyball-part"
            d={`M ${CX} ${LEVER_PIVOT_Y} L ${leverEndX.toFixed(2)} ${leverEndY.toFixed(2)}`}
            stroke="var(--foreground)"
            strokeWidth={3}
            strokeLinecap="round"
          />

          {/* arm pivot joint */}
          <circle cx={CX} cy={ARM_PIVOT_Y} r={3} fill="var(--foreground)" />

          {/* the 4-element flyball linkage — two arms, two ball weights — spun as one group */}
          <g
            className="ns-flyball-spin-group"
            style={{ "--ns-flyball-spin-ms": `${spinMs}ms` } as React.CSSProperties}
          >
            <path
              className="ns-flyball-part"
              d={`M ${CX} ${ARM_PIVOT_Y} L ${ballLeftX.toFixed(2)} ${ballY.toFixed(2)}`}
              stroke="var(--foreground)"
              strokeWidth={2.5}
              strokeLinecap="round"
            />
            <path
              className="ns-flyball-part"
              d={`M ${CX} ${ARM_PIVOT_Y} L ${ballRightX.toFixed(2)} ${ballY.toFixed(2)}`}
              stroke="var(--foreground)"
              strokeWidth={2.5}
              strokeLinecap="round"
            />
            <circle className="ns-flyball-part" cx={ballLeftX.toFixed(2)} cy={ballY.toFixed(2)} r={BALL_R} fill="var(--foreground)" />
            <circle className="ns-flyball-part" cx={ballRightX.toFixed(2)} cy={ballY.toFixed(2)} r={BALL_R} fill="var(--foreground)" />
          </g>

          {/* sleeve collar — height is purely a function of arm angle, never of spent/cap */}
          <rect
            className="ns-flyball-part"
            x={CX - COLLAR_W / 2}
            y={collarY.toFixed(2)}
            width={COLLAR_W}
            height={COLLAR_H}
            rx={2}
            fill={capReached ? "var(--foreground)" : "none"}
            stroke="var(--foreground)"
            strokeWidth={2}
          />
        </svg>
      </div>

      <div role="status" aria-live="polite" className="sr-only">
        {announce}
      </div>

      <div className="mt-4 flex items-center gap-2">
        <button
          type="button"
          aria-disabled={capReached}
          aria-describedby={capReached ? `${descId} ${closedNoteId}` : descId}
          onClick={() => {
            if (!capReached) onNewPurchase?.();
          }}
          className={`${btnBase} ${capReached ? btnDead : btnLive}`}
        >
          New purchase
        </button>
        <button
          type="button"
          aria-disabled={capReached}
          aria-describedby={capReached ? `${descId} ${closedNoteId}` : descId}
          onClick={() => {
            if (!capReached) onRaiseLimit?.();
          }}
          className={`${btnBase} ${capReached ? btnDead : btnLive}`}
        >
          Raise limit
        </button>
      </div>

      {capReached ? (
        <p id={closedNoteId} data-flyball-closed-note className="mt-2 font-mono text-[11px] text-ns-muted">
          Disabled — the {fmtMoney(cap, currency)} cap for this period has been reached. New purchases and
          limit increases resume next period.
        </p>
      ) : null}
    </div>
  );
}
Build spec

Renders a spend-cap guard as a Watt centrifugal governor instead of a progress bar. Four props carry the read: `spendRate` (currency/day, the current burn rate), `cap` and `periodDays` (together defining `safeRate = cap / periodDays`, the sustainable pace), and `spent` (currency already spent this period — a genuinely separate level that gates the real controls and never touches the governor's geometry). The whole mechanism hangs off one governing scalar, `omega = spendRate / safeRate`: arm elevation angle is `clamp(13 + 22 * omega^2, 13, 68)` degrees (small-physics approximation, squared because centrifugal force scales with the square of angular rate; 68deg is a hard ceiling because the drawn linkage geometrically self-intersects past it; the 13deg floor is a real hinge limit, not decoration — without it the two balls' centers close to under their own diameter at low omega and merge into one blob, which is exactly the idle default frame this registry's owner judges first), each ball's position is placed directly from that angle at the end of a 46-unit arm hinged at a fixed pivot (`ballX = pivotX ± armLen*sin(angle)`, `ballY = pivotY + armLen*cos(angle)` — balls ride HIGHER, i.e. smaller y, as angle rises, which is the classic conical-pendulum flyball read), the sleeve collar's drop down the spindle is computed FROM that same angle through the linkage geometry (`armLen * (1 - cos(angle))`, never independently), and the throttle lever's rotation maps LINEARLY from that collar drop (from a 30deg resting angle above a short gate track down to 0deg, lying flat across it) so the lever visibly closes over two small glyphs standing in for the purchase actions as the collar drops. A dashed pushrod line (`stroke-dasharray`) from the collar up to the lever's pivot is the only thing connecting the two mechanisms — it exists purely to read as 'the collar drives the lever', not as a second data channel. Two arm `<path>`s and two ball `<circle>`s (the entire flyball linkage — four elements, no more) sit inside one wrapping `<g>` that fakes axial spin without ever actually rotating a side-profile diagram: a single shared `@keyframes` oscillates that group's `scaleX` between 1 and 0.74, with `animation-duration` bound to one CSS custom property (`--ns-flyball-spin-ms`) computed as `clamp(900 / max(omega, 0.08), 260, 4000)` — lazy, slow spin at low omega reads as healthy at a glance, a fast blur reads as hot, and there is deliberately no per-ball choreography: both arms and both balls share the exact same group transform. Every geometry-bearing SVG attribute that changes with omega (`d` on the arm and lever paths, `cx`/`cy` on the balls, `y` on the collar rect — all CSS-animatable properties in evergreen browsers) carries one shared 250ms, non-overshooting `cubic-bezier(0.16, 1, 0.3, 1)` CSS transition, so a stream of per-request spend updates settles smoothly frame to frame instead of twitching on every tick; there is no JS spring/rAF loop anywhere in this component. `spent` vs `cap` never feeds that geometry — `capReached = cap > 0 && spent >= cap` is computed and used for exactly two things: tinting the collar's fill solid (a data-state color change, same convention this registry already uses for a latched state, never `--ns-accent`) and gating the two real `<button>`s ('New purchase', 'Raise limit'), which are marked `aria-disabled` (never the native `disabled` attribute, so they stay in the tab sequence and screen-reader-discoverable even at cap) with their `onClick` short-circuited and `aria-describedby` extended to include a visible explanation paragraph (`data-flyball-closed-note`) stating the cap was reached and when purchasing resumes — the disabling is a real, independent control state, not something inferred from the lever's visual position. The whole SVG diagram is `aria-hidden`; the read starts as text. A visible paragraph (`aria-describedby`'d onto a `role=group` wrapper that also carries `aria-labelledby` to the label above it) states the figures in one sentence first: 'Spending $41/day against a $30/day sustainable rate — cap reached in 9 days at this pace' (or 'on pace to stay under cap this period' once the forecast runs past `periodDays`, or 'the cap has been reached for this period' once `capReached`). A bold Geist Mono state chip ('CALM' / 'HOT' / 'CLOSED') sits beside the label at all times — `band` is `closed` whenever `capReached`, else `hot` once `omega >= 1.3`, else `calm` — and a visually-hidden `role=status`/`aria-live=polite` span announces only the edge-triggered crossing between those three bands (not every omega tick, matching this registry's established edge-triggered-announcement pattern), skipping the announcement on first mount so a demo that starts mid-scenario doesn't narrate its own boot. Under `prefers-reduced-motion: reduce` (a CSS media query, no JS branch needed since nothing here runs a rAF loop) the spin keyframe animation — the one open-ended, ambient loop in the component — stops entirely, but the 250ms geometry transitions shorten to 120ms linear rather than vanishing: a press still reads as the arms actually moving to their new resting angle, just without the perpetual spin blur, since a state change is not the kind of motion prefers-reduced-motion is meant to suppress. Colors are `var(--foreground)` / `var(--border)` only inside the SVG (never a hex literal, never `getComputedStyle` since nothing here is a raster surface) and Tailwind token classes (`text-foreground`, `text-ns-muted`, `border-border`, `bg-background`, `focus-visible:outline-ns-accent`) everywhere else — `--ns-accent` appears only on focus-visible outlines, never as a fill or decorative color. DOM+SVG+CSS only, zero dependencies, no canvas.

Props

PropTypeDefaultDescription
spendRatenumbercurrent burn rate, currency units per day
capnumbertotal spend cap for the period
periodDaysnumberlength of the cap period, in days
spentnumberamount already spent this period — the level that actually gates the buttons
currency?string"$"currency symbol prefix for readouts (default "$")
label?string"Spend governor"what's being governed, shown above the readout, e.g. "API spend"
onNewPurchase?() => voidcalled when "New purchase" is activated while under cap
onRaiseLimit?() => voidcalled when "Raise limit" is activated while under cap
className?stringextra classes merged onto the rendered root element