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
shadcn CLI
Terminal
npx shadcn@latest add https://mocoui.site/r/scrub.jsonManual
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.cssNeeds 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;
}