Documentation menu

scrub

Tangle-style inline number scrubber: drag the variable itself, with a compact range input for pointer and keyboard access.

numberinputdraginlineinteractive

Preview

Suppose 2,000 requests a day hit the same prompt — that is $2,520 a month.

Installation

Ask Claude (MCP)

With the MCP server configured, just ask:

Claude
add the moco scrub component

shadcn CLI

Terminal
npx shadcn@latest add https://mocoui.site/r/scrub.json

Manual

Copy each file from the Source section into its target path:

registry/scrub/scrub.tsx  →  components/moco/scrub.tsx
registry/scrub/scrub.css  →  components/moco/scrub.css

Needs moco items: tokens

Usage

usage.tsx
"use client";

import * as React from "react";
import { ScrubNumber } from "./scrub";

const usd = (n: number) =>
  `$${n.toLocaleString("en-US", { maximumFractionDigits: 0 })}`;

export default function ScrubDemo() {
  const [reqs, setReqs] = React.useState(2_000);
  const perMonth = reqs * 30 * 0.042;

  return (
    <p style={{ fontFamily: "var(--moco-serif)", color: "var(--moco-body)" }} aria-live="polite">
      Suppose{" "}
      <ScrubNumber
        handle="n"
        label="requests per day"
        value={reqs}
        min={50}
        max={50_000}
        step={50}
        format={(v) => `${v.toLocaleString("en-US")} requests`}
        onChange={setReqs}
      />{" "}
      a day hit the same prompt — that is{" "}
      <b className="moco-num">{usd(perMonth)}</b> a month.
    </p>
  );
}

Source

components/moco/scrub.tsx
"use client";

import * as React from "react";
import "./scrub.css";

export type ScrubNumberProps = {
  value: number;
  onChange: (n: number) => void;
  min: number;
  max: number;
  step?: number;
  /** Accessible label for the range input. */
  label: string;
  /** Formats the displayed value; defaults to String(value). */
  format?: (n: number) => string;
  /** The letter or word that doubles as the drag handle. */
  handle: string;
};

/**
 * A Tangle-style inline control: the variable itself is a drag handle, and a
 * compact range input sits inline with the text for pointer and keyboard use.
 */
export function ScrubNumber({
  value,
  onChange,
  min,
  max,
  step = 1,
  label,
  format,
  handle,
}: ScrubNumberProps) {
  const id = React.useId();
  const drag = React.useRef<{ x: number; v: number } | null>(null);
  const text = format ? format(value) : String(value);

  const clamp = (n: number) => Math.min(max, Math.max(min, Math.round(n / step) * step));

  const onPointerDown = (e: React.PointerEvent) => {
    (e.target as HTMLElement).setPointerCapture(e.pointerId);
    drag.current = { x: e.clientX, v: value };
  };
  const onPointerMove = (e: React.PointerEvent) => {
    if (!drag.current) return;
    const perPx = (max - min) / 320;
    onChange(clamp(drag.current.v + (e.clientX - drag.current.x) * perPx));
  };
  const onPointerUp = () => {
    drag.current = null;
  };

  return (
    <span className="moco-scrub">
      <span
        className="moco-scrub-handle"
        onPointerDown={onPointerDown}
        onPointerMove={onPointerMove}
        onPointerUp={onPointerUp}
        onPointerCancel={onPointerUp}
        aria-hidden="true"
      >
        {handle}
      </span>
      <label className="moco-sr-only" htmlFor={id}>
        {label}
      </label>
      <input
        id={id}
        className="moco-scrub-mini"
        type="range"
        min={min}
        max={max}
        step={step}
        value={value}
        aria-valuetext={`${label}: ${text}`}
        onChange={(e) => onChange(Number(e.target.value))}
      />
      <span className="moco-num">{text}</span>
    </span>
  );
}
components/moco/scrub.css
/* Mono + tabular figures so the number doesn't jitter while scrubbing. */
.moco-scrub {
  display: inline-flex;
  align-items: center;
  gap: 0.35rem;
  vertical-align: baseline;
  white-space: nowrap;
  font-family: var(--moco-mono);
  font-variant-numeric: tabular-nums;
}

.moco-scrub-handle {
  color: var(--moco-accent-dk);
  cursor: ew-resize;
  border-bottom: 1px dashed var(--moco-accent-dk);
  touch-action: none;
  user-select: none;
}

input[type="range"].moco-scrub-mini {
  -webkit-appearance: none;
  appearance: none;
  width: 76px;
  height: 16px;
  background: transparent;
  vertical-align: middle;
  cursor: ew-resize;
}

@media (min-width: 560px) {
  input[type="range"].moco-scrub-mini {
    width: 88px;
  }
}

input[type="range"].moco-scrub-mini::-webkit-slider-runnable-track {
  height: 1px;
  background: var(--moco-line);
}
input[type="range"].moco-scrub-mini::-moz-range-track {
  height: 1px;
  background: var(--moco-line);
}
input[type="range"].moco-scrub-mini::-webkit-slider-thumb {
  -webkit-appearance: none;
  width: 10px;
  height: 10px;
  margin-top: -4.5px;
  border-radius: 999px;
  background: var(--moco-accent-dk);
}
input[type="range"].moco-scrub-mini::-moz-range-thumb {
  width: 10px;
  height: 10px;
  border: none;
  border-radius: 999px;
  background: var(--moco-accent-dk);
}

.moco-num {
  font-family: var(--moco-mono);
  font-variant-numeric: tabular-nums;
  color: var(--moco-ink);
  font-weight: 600;
  font-size: 0.94em;
}

.moco-sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}