Adjust a number by dragging a ruler, fine-tune with modifiers, type an exact value, or change display units without changing the canonical value.
Drag the ruler or use arrow keys. Shift slows; Alt accelerates.
pnpm dlx shadcn@latest add @latheui/precision-scrub-fieldFirst time? Run npx latheui init once to add the @latheui registry. The CLI then writes the source into your project and installs what it needs. Or add it by hand:
Add the Lathe items this component imports.
lib/interaction-math.tshooks/use-controllable-state.tsCopy the source into your project.
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Select } from "@/components/ui/select"
import { scrubValue } from "@/lib/interaction-math"
import { useControllableState } from "@/hooks/use-controllable-state"
const reducedMotion = () =>
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches
export type ScrubUnit = { label: string; factor: number }
export type PrecisionScrubFieldProps = {
label: string
value?: number
defaultValue?: number
onValueChange?: (value: number) => void
onValueCommit?: (value: number) => void
min?: number
max?: number
step?: number
units?: readonly ScrubUnit[]
disabled?: boolean
className?: string
}
export function PrecisionScrubField({
label,
value: prop,
defaultValue = 0,
onValueChange,
onValueCommit,
min = -Infinity,
max = Infinity,
step = 1,
units,
disabled = false,
className,
}: PrecisionScrubFieldProps) {
const [value, setValue] = useControllableState({
value: prop,
defaultValue,
onChange: onValueChange,
})
const [unitIndex, setUnitIndex] = React.useState(0)
const [draft, setDraft] = React.useState<string>()
const id = React.useId()
const input = React.useRef<HTMLInputElement>(null)
const cancelDraft = React.useRef(false)
const drag = React.useRef<{
x: number
value: number
latest: number
pointerId: number
} | null>(null)
const [scrubbing, setScrubbing] = React.useState(false)
// While scrubbing, the value chases the pointer on a short ease instead of jumping
// straight to it, so a fast flick still rolls through the numbers in between.
const chase = React.useRef<{
target: number
shown: number
step: number
frame: number
time: number
commit: boolean
} | null>(null)
React.useEffect(() => () => cancelAnimationFrame(chase.current?.frame ?? 0), [])
const unit = units?.[unitIndex]
const factor =
unit && Number.isFinite(unit.factor) && unit.factor > 0 ? unit.factor : 1
const current = Number.isFinite(value) ? value : 0
const invalid =
!Number.isFinite(value) || scrubValue(current, step, min, max) === undefined
const blocked = disabled || invalid
const update = (next: number, nextStep = step) => {
const result = scrubValue(next, nextStep, min, max)
if (result !== undefined && !blocked) {
setDraft(undefined)
setValue(result)
}
return result ?? current
}
const commit = (next: number, nextStep = step) => {
const result = update(next, nextStep)
onValueCommit?.(result)
}
/** Moves the scrub target; the shown value catches up over a few frames. */
const scrubTo = (next: number, nextStep: number) => {
const target = scrubValue(next, nextStep, min, max)
if (target === undefined || blocked) return current
if (reducedMotion()) return update(target, nextStep)
const state = chase.current
if (state) {
state.target = target
state.step = nextStep
return target
}
const run: NonNullable<typeof chase.current> = {
target,
shown: current,
step: nextStep,
frame: 0,
time: performance.now(),
commit: false,
}
chase.current = run
const tick = (now: number) => {
const dt = Math.min(64, now - run.time)
run.time = now
// About 90% of the way there every 110ms, whatever the frame rate.
run.shown += (run.target - run.shown) * (1 - Math.exp(-dt / 48))
const settled = Math.abs(run.target - run.shown) < run.step / 2
if (settled) run.shown = run.target
update(run.shown, run.step)
if (!settled) run.frame = requestAnimationFrame(tick)
else {
chase.current = null
if (run.commit) onValueCommit?.(run.target)
}
}
run.frame = requestAnimationFrame(tick)
return target
}
return (
<div
data-slot="precision-scrub-field"
className={cn("w-full min-w-0 space-y-2", className)}
>
<div className="flex items-center justify-between gap-2">
<label htmlFor={id} className="text-sm font-medium">
{label}
</label>
<Button
variant="ghost"
size="sm"
disabled={blocked}
aria-label={`Reset ${label}`}
onClick={() => commit(defaultValue)}
className="text-muted-foreground"
>
Reset
</Button>
</div>
<div className="flex min-w-0 items-center gap-2">
<Button
variant="outline"
size="md"
morphWidth={false}
disabled={blocked}
aria-label={`Scrub ${label}`}
aria-describedby={`${id}-help`}
data-scrubbing={scrubbing || undefined}
// The wide part of the row, so there is room to drag even near the edge of a phone.
className="group/scrub relative min-w-0 flex-1 touch-none cursor-ew-resize overflow-hidden px-0 text-muted-foreground active:scale-100"
onKeyDown={(event) => {
const amount = step * (event.shiftKey ? 0.1 : event.altKey ? 10 : 1)
const changes: Record<string, number> = {
ArrowRight: amount,
ArrowUp: amount,
ArrowLeft: -amount,
ArrowDown: -amount,
PageUp: step * 10,
PageDown: -step * 10,
}
if (event.key === "Enter") input.current?.focus()
else if (event.key === "Home" && Number.isFinite(min)) {
event.preventDefault()
commit(min)
} else if (event.key === "End" && Number.isFinite(max)) {
event.preventDefault()
commit(max)
} else if (event.key in changes) {
event.preventDefault()
commit(
current + changes[event.key],
event.shiftKey ? step / 10 : step
)
}
}}
onPointerDown={(event) => {
if (event.button !== 0 || drag.current) return
event.currentTarget.setPointerCapture(event.pointerId)
setScrubbing(true)
drag.current = {
x: event.clientX,
value: current,
latest: current,
pointerId: event.pointerId,
}
}}
onPointerMove={(event) => {
if (!drag.current || drag.current.pointerId !== event.pointerId)
return
const speed = event.shiftKey ? 0.1 : event.altKey ? 10 : 1
drag.current.latest = scrubTo(
drag.current.value +
(event.clientX - drag.current.x) * step * speed,
event.shiftKey ? step / 10 : step
)
}}
onPointerUp={(event) => {
if (!drag.current || drag.current.pointerId !== event.pointerId)
return
const next = drag.current.latest
drag.current = null
setScrubbing(false)
event.currentTarget.releasePointerCapture(event.pointerId)
// Commit once the shown value has caught up with where the pointer let go.
if (chase.current) chase.current.commit = true
else onValueCommit?.(next)
}}
onPointerCancel={() => {
if (chase.current) {
cancelAnimationFrame(chase.current.frame)
chase.current = null
}
if (drag.current) update(drag.current.value)
drag.current = null
setScrubbing(false)
}}
onLostPointerCapture={() => {
drag.current = null
setScrubbing(false)
}}
>
{/* A ruler that slides with the value, so scrubbing reads as turning a dial. */}
<span
aria-hidden="true"
className="absolute inset-x-0 inset-y-2 opacity-45 transition-opacity duration-200 [mask-image:linear-gradient(90deg,transparent,black_18%,black_82%,transparent)] group-data-scrubbing/scrub:opacity-90 motion-reduce:transition-none"
style={{
backgroundImage:
"repeating-linear-gradient(90deg, currentColor 0 1px, transparent 1px 6px), repeating-linear-gradient(90deg, currentColor 0 1px, transparent 1px 30px)",
backgroundSize: "100% 45%, 100% 100%",
backgroundRepeat: "repeat-x",
backgroundPositionY: "50%, 0",
backgroundPositionX: `${(-current / step) * 2}px, ${(-current / step) * 2}px`,
}}
/>
<span aria-hidden="true" className="absolute inset-y-1.5 left-1/2 w-0.5 -translate-x-1/2 rounded-full bg-primary" />
</Button>
<Input
ref={input}
id={id}
type="number"
value={draft ?? Number((current / factor).toFixed(8))}
min={Number.isFinite(min) ? min / factor : undefined}
max={Number.isFinite(max) ? max / factor : undefined}
step="any"
disabled={blocked}
aria-describedby={`${id}-help`}
className="w-20 shrink-0 font-mono tabular-nums [&_input]:[appearance:textfield] [&_input::-webkit-inner-spin-button]:appearance-none [&_input::-webkit-outer-spin-button]:appearance-none"
onChange={(event) => {
const text = event.currentTarget.value,
next = event.currentTarget.valueAsNumber * factor
if (
text !== "" &&
Number.isFinite(next) &&
next >= min &&
next <= max
)
update(next)
setDraft(text)
}}
onKeyDown={(event) => {
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
event.preventDefault()
const amount =
step * (event.shiftKey ? 0.1 : event.altKey ? 10 : 1)
commit(
current + (event.key === "ArrowUp" ? amount : -amount),
event.shiftKey ? step / 10 : step
)
}
if (event.key === "Enter") event.currentTarget.blur()
if (event.key === "Escape") {
cancelDraft.current = true
setDraft(undefined)
event.currentTarget.blur()
}
}}
onBlur={() => {
if (cancelDraft.current) {
cancelDraft.current = false
setDraft(undefined)
return
}
const result =
draft !== undefined && draft !== ""
? update(Number(draft) * factor)
: current
setDraft(undefined)
onValueCommit?.(result)
}}
/>
{units?.length ? (
<Select
label={`${label} unit`}
value={String(unitIndex)}
disabled={blocked}
onValueChange={(next) => {
setDraft(undefined)
setUnitIndex(Number(next))
}}
options={units.map((item, index) => ({
value: String(index),
label: item.label,
}))}
className="w-20 shrink-0"
/>
) : null}
</div>
<p
id={`${id}-help`}
className="text-xs leading-relaxed text-muted-foreground"
>
{invalid
? "Use a finite value, positive step and valid bounds."
: "Drag the ruler or use arrow keys. Shift slows; Alt accelerates."}
</p>
</div>
)
}Depends on
| Prop | Type | Default | Description |
|---|---|---|---|
| label* (required) | string | — | Visible input label and accessible name for scrub and reset actions. |
| value / defaultValue | number | 0 (defaultValue) | Controlled or initial value in canonical units. |
| onValueChange | (value: number) => void | — | Receives canonical values while dragging, typing, resetting or using keys. |
| onValueCommit | (value: number) => void | — | Receives the value on pointer release, input blur, keyboard adjustment or reset. |
| min / max | number | -Infinity / Infinity | Bounds in canonical units. |
| step | number | 1 | Positive step in canonical units; Shift uses one tenth for fine adjustment. |
| units | readonly ScrubUnit[] | — | Display units with label and positive conversion factor, e.g. px = 1 and rem = 16. The first unit is initially selected. |
| disabled | boolean | false | Disable the input, scrub handle, units and reset. |
| className | string | — | Classes for the outer wrapper. |
Use the typed props to connect application state. The source is installed into your project.
"use client"
import * as React from "react"
import { PrecisionScrubField } from "@/components/ui/precision-scrub-field"
const units = [{ label: "px", factor: 1 }, { label: "rem", factor: 16 }]
export default function PrecisionDemo() {
const [width, setWidth] = React.useState(160)
return <div className="w-full max-w-sm space-y-6">
<div className="flex h-28 items-center justify-center rounded-xl border border-dashed border-border bg-background/60"><div aria-hidden="true" className="h-12 max-w-full rounded-lg bg-primary/15 ring-1 ring-primary/30 transition-[width] duration-75 motion-reduce:transition-none" style={{ width }} /></div>
<PrecisionScrubField label="Width" value={width} defaultValue={160} onValueChange={setWidth} min={32} max={280} units={units} />
</div>
}