a number you drag, type or step, its unit beside it, for input type=number
npx shadcn add @ag/numberAlready installed? Preview an update
npx shadcn add @ag/number --dry-runYour installed source stays unchanged until you update it. How updates work
Latest changes · 2026-10-14
- Its ‹ › are
stepper'sStepChevron(the same press, hold and repeat); their slots arestep-previousandstep-next, where they werenumber-previousandnumber-next.numbernow depends onstepper.
also called number field, spin button, scrubber
shelf controls
uses field-context, material, setting-context, stepper
used by components
the demo above, verbatim
"use client"
import * as React from "react"
import { Sample } from "@/app/samples"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
import { NumberField, SECONDS } from "@/registry/base-nova/ui/number"
import { Setting } from "@/registry/base-nova/ui/setting"
function Delay() {
// Whole ms, read and typed as seconds: try "250ms", "1m 30s", ".5 * 3".
const [delay, setDelay] = React.useState(250)
return (
<div className="flex flex-wrap items-center gap-3">
<NumberField
label="delay"
handle="delay"
unit={SECONDS}
min={0}
max={5000}
step={10}
value={delay}
onValueChange={setDelay}
/>
<span className="font-mono text-foreground/60 text-xs">{delay} ms</span>
</div>
)
}
function FrameRate() {
const [fps, setFps] = React.useState(24)
return (
<NumberField
label="frames a second"
unit="fps"
min={1}
max={120}
whole
marks={[
{ value: 12, label: "animation on twos" },
{ value: 24, label: "film" },
{ value: 25, label: "pal" },
{ value: 30, label: "ntsc" },
{ value: 60, label: "smooth" },
]}
value={fps}
onValueChange={setFps}
/>
)
}
function Columns() {
// No marks: each chevron is a step, held it runs.
const [columns, setColumns] = React.useState(3)
return (
<NumberField
label="columns"
handle="columns"
min={1}
max={12}
whole
value={columns}
onValueChange={setColumns}
/>
)
}
function Threshold() {
const [cut, setCut] = React.useState(0.8)
return (
<NumberField
label="threshold"
handle="≥"
min={0}
max={1}
step={0.01}
marks={[{ value: 0.5 }]}
value={cut}
onValueChange={setCut}
/>
)
}
function Tokens() {
// No max: an open range draws no hairline, and the drag moves a step every few
// pixels. "1.2k", "32 000" and "1,120" all read.
const [tokens, setTokens] = React.useState(1120)
return (
<NumberField
label="max tokens"
handle="max"
unit="tokens"
min={1}
step={1}
whole
value={tokens}
onValueChange={setTokens}
/>
)
}
function OnGlass() {
const [delay, setDelay] = React.useState(400)
const [fps, setFps] = React.useState(30)
return (
<div className="flex flex-wrap items-center gap-3 rounded-3xl bg-[url(/glass-mountains.jpg)] bg-cover bg-center p-8 text-white">
<NumberField
material="glass"
scheme="dark"
size="lg"
label="delay"
handle="delay"
unit={SECONDS}
min={0}
max={2000}
step={10}
value={delay}
onValueChange={setDelay}
/>
<NumberField
material="glass"
scheme="dark"
size="lg"
label="frames a second"
unit="fps"
min={1}
max={60}
whole
marks={[{ value: 24 }, { value: 30 }]}
value={fps}
onValueChange={setFps}
/>
</div>
)
}
// #region inherited
/** Numbers that fall back to the workspace's: a ghost of the value that applies
* until one is set here; × (or ⌥-click), or an emptied field committed, clears it
* back. */
function Inherited() {
const [threads, setThreads] = React.useState<number | undefined>(undefined)
const [retries, setRetries] = React.useState<number | undefined>(5)
return (
<div className="flex flex-col items-start gap-3">
<Setting
label="threads"
value={threads}
fallback={{ value: 8, from: "the workspace" }}
onValueChange={setThreads}
>
{(s) => (
<NumberField
label="threads"
handle="threads"
min={1}
max={64}
whole
chevrons={false}
bar={false}
value={s.value}
onValueChange={s.pin}
/>
)}
</Setting>
<Setting
label="retries"
value={retries}
fallback={{ value: 3, from: "the workspace" }}
onValueChange={setRetries}
>
{(s) => (
<NumberField
label="retries"
handle="retries"
min={0}
max={10}
whole
chevrons={false}
bar={false}
value={s.value}
onValueChange={s.pin}
/>
)}
</Setting>
</div>
)
}
// #endregion
export default function NumberDemo() {
return (
<div className="space-y-8">
<Sample
name="time"
glance
title="a time, in seconds"
description="the value is whole ms; drag delay or the value, alt for fine, shift for coarse"
>
<Delay />
</Sample>
<Sample
name="marks"
title="a frame rate with marks"
description="the chevrons and page up and down go mark to mark; the drag holds on a mark as it passes"
>
<FrameRate />
</Sample>
<Sample
name="chevrons"
title="chevrons, by the step"
description="hold one and it repeats; shift ten steps, alt a tenth"
>
<Columns />
</Sample>
<Sample name="threshold" title="a threshold, 0 to 1">
<Threshold />
</Sample>
<Sample name="tokens" title="a large count, open above">
<Tokens />
</Sample>
<Sample name="glass" title="on glass">
<OnGlass />
</Sample>
<Sample
name="inherited"
title="following a default"
description="threads follows the workspace (dashed, its digits a step back); retries is set here. empty it and press Enter to clear it."
with="inherited"
>
<div className="grid gap-4 sm:grid-cols-2">
<div className="p-6">
<Inherited />
</div>
<MaterialProvider material="glass" scheme="dark">
<div className="rounded-xl bg-[url(/glass-mountains.jpg)] bg-cover bg-center p-6">
<Inherited />
</div>
</MaterialProvider>
</div>
</Sample>
<Sample
name="read-only"
title="read-only"
description="shown and read out, never changed: no typing, no drag, no step; it still takes focus. never dimmed, which is disabled."
>
<NumberField
label="port"
handle="port"
value={8080}
onValueChange={() => {}}
format={String}
bar={false}
chevrons={false}
readOnly
/>
</Sample>
</div>
)
}the source, every file an install writes
"use client"
// A number a person sets: what `<input type="number">` should have been. The value
// is a word in a line ("0.25 s", "24 fps · film", "1120 tokens"), never a box with
// spin arrows over its digits, and every way of changing it is one gesture away:
//
// drag sideways on the value (or the `handle` before it) scrubs, the way a
// design tool's fields do: the pointer is locked so a screen edge never
// stops the hand, alt drags a tenth as fast, shift ten times, a mark
// holds the value as the hand passes it, Esc puts it back
// click a press that does not travel edits the value as text
// type what a person would write: ".25", "250ms", "1m 30s", "0,25",
// "1,120", "1.2k", "80%", "0.25 * 2". Enter commits, Esc reverts (and,
// with nothing to revert, leaves), leaving commits. Not a number: the
// text stays, marked, until it is one
// keys up and down step, shift ten steps, alt a tenth; page up and down go
// to the next mark (ten steps without marks)
// wheel steps too, but only while the field is focused: a page scrolled
// past a field never changes it
// chevrons ‹ › at the ends of the range bar (`chevrons`, on by default), mark
// to mark or step by step, repeating while held
//
// The value is in its own unit and is shown in the person's (`unit`, see
// lib/number-core): a time is whole ms and reads and types as seconds. It is always
// written with a dot, whatever the browser's locale. A known range draws a
// hairline under the value, filled to where it sits, its marks as ticks; looked
// at or used it grows in its reserved strip (a track, a thumb, taller ticks, the
// mark under the hand named below) and is a slider of its own: press, drag, Esc
// to put back.
//
// Laid on a grid: the padding is a left and a right inset, and every row runs
// between them. The value (with its unit) always ends on the right inset, so
// fields stacked in a column line their numbers up; the handle and the mark's
// name start on the left inset (the name keeps the widest name's room, so
// moving between marks moves nothing). Under them the strip: the track inset to
// inset, or ‹ and › with their ink on the insets and the track between them at
// equal gaps. Fill, ticks and thumb share the track's one coordinate.
// Monochrome by `currentColor`: it sits on any surface. Two materials
// (`material`): plain, a well of the text's colour; glass, the material's glass, a
// pill (soft corners over a strip), every gesture and key the same. `readOnly`:
// shown and read out, never changed. Inside a `Setting` whose value is inherited,
// a ghost (the well dashed, the digits a step back; glass: the material's ghost),
// and an emptied field, committed, clears what is set here.
import * as React from "react"
import { cn } from "@/lib/utils"
import {
clamp,
clean,
type Drag,
dragBy,
formatValue,
MARK_PX,
markBy,
type NumberRange,
type NumberUnit,
parseValue,
quantize,
shareOf,
startDrag,
stepBy,
unitOf,
} from "@/registry/base-nova/lib/number-core"
import { useField } from "@/registry/base-nova/ui/field-context"
import {
type Material,
type Scheme,
useMaterial,
useSurfaceScheme,
} from "@/registry/base-nova/ui/material"
import { glassVariants, schemeOf } from "@/registry/base-nova/ui/material-glass"
import {
GHOST_EDGE,
GLASS_GHOST,
useSetting,
} from "@/registry/base-nova/ui/setting-context"
import {
stepModifier as modifier,
StepChevron,
} from "@/registry/base-nova/ui/stepper"
export { type NumberUnit, SECONDS } from "@/registry/base-nova/lib/number-core"
export interface NumberMark {
value: number
/** Said on the left of the row while the value sits on the mark ("film" at
* 24 fps). */
label?: string
}
export interface NumberFieldProps {
value: number
/** Every change, a drag's included. */
onValueChange: (value: number) => void
/** When a change is settled: a drag let go, Enter, leaving the field, a key. */
onValueCommitted?: (value: number) => void
/** The field's name for a screen reader. */
label: string
/** Drawn before the value: a word or an icon to grab and drag (Figma's W). */
handle?: React.ReactNode
/** Shown after the value: a word (`"fps"`), or a unit that converts
* (`SECONDS`: the value is ms, a person reads seconds). */
unit?: NumberUnit | string
min?: number
max?: number
/** The keys' step and the drag's grain, in the value's unit. Also decides the
* decimals shown (a 10 ms step shown in seconds is 0.01: two). */
step?: number
/** Nothing finer than 1 (a count). `SECONDS` is whole already. */
whole?: boolean
/** Values worth landing on: the drag holds on them, page up and down go to the
* next, and the range hairline ticks them. */
marks?: readonly NumberMark[]
/** The value as text for a person, in place of the dot decimal. Typing still
* reads what `unit` reads. */
format?: (value: number) => string
/** ‹ › at the two ends of the range bar, the track between them: each press
* moves to the previous or next mark (a step without marks; shift ten steps,
* alt a tenth), held it repeats. The bar's strip is always there with them,
* even on an open range (nothing between the ends then), so nothing moves. On by
* default; `false` leaves the value and its hairline alone. */
chevrons?: boolean
/** The range hairline; on by default when `min` and `max` are both given. */
bar?: boolean
/** The button's heights (28, 32, 40 px with its one row), so a field and its
* button sit flush; a strip under the value adds its 12 px. Default "md". */
size?: NumberSize
material?: Material
/** The colour scheme it stands in (material): default the nearest provider's. */
scheme?: Scheme
disabled?: boolean
/** Shown, never changed here: no typing, no drag, no step, the field still
* focusable and read out. Never dimmed (that is `disabled`). A field's read-only
* and a locked setting's hold it the same way. */
readOnly?: boolean
/** What else says something about it (a hint's id); a field's and a setting's
* words are added to it. */
"aria-describedby"?: string
/** Submitted with a form, as the value in its own unit. */
name?: string
className?: string
}
/** How far a press may travel and still be a click. */
const SLOP_PX = 3
/** Wheel travel per step: a mouse notch is about 100, a trackpad sends small
* deltas that add up. */
const WHEEL_PX = 50
/** A single movement larger than this is a glitch, not a hand. */
const LEAP_PX = 300
/** How near a mark the hand on the bar must be for the mark to be named. */
const AIM_PX = 10
export type NumberSize = "sm" | "md" | "lg"
/** The insets and the padding above and below; the value row is 20 px. Glass is a
* pill, so it stands further in from its rounded ends. */
const SIZE: Record<NumberSize, { plain: string; glass: string }> = {
sm: { plain: "px-2 py-1 text-xs", glass: "px-3 py-1 text-xs" },
md: { plain: "px-2.5 py-1.5 text-sm", glass: "px-3.5 py-1.5 text-sm" },
lg: { plain: "px-3 py-2.5 text-sm", glass: "px-4 py-2.5 text-sm" },
}
/** The plain face: a well of the text's own colour, lit under the hand and on focus. */
const PLAIN_FACE =
"rounded-md bg-current/6 transition-colors hover:bg-current/9 focus-within:bg-current/10"
/** The glass pill's radius, half its height (the 20 px value row and `SIZE`'s
* padding above and below). */
const GLASS_PILL: Record<NumberSize, string> = {
sm: "[--glass-radius:14px]",
md: "[--glass-radius:16px]",
lg: "[--glass-radius:20px]",
}
/** The glass face: the material's glass, its rim catching more light on focus as a
* glass text field's does. A field with a strip under its value is two rows: a pill
* would round its corners into them, so it takes soft corners instead. */
const glassFace = (size: NumberSize, strip: boolean) =>
cn(
glassVariants({ shape: "surface" }),
strip ? "[--glass-radius:18px]" : GLASS_PILL[size],
"[--glass-change-duration:200ms] focus-within:[--glass-rim:1.6]!",
)
const SCRUB = "cursor-ew-resize touch-pan-y select-none"
/** Inherited (inside a `Setting`, nothing set here): the well unfilled and dashed,
* the digits a step back. Glass wears the material's ghost. */
const PLAIN_GHOST = cn(
"bg-transparent hover:bg-current/4 focus-within:bg-current/6",
GHOST_EDGE,
)
/** A chevron's 20 px target around its 6 px glyph, pulled outward by the 7 px
* the glyph sits in from the target's edge: the ink lands on the inset. */
const CHEVRON_OUT_L = "-ml-[7px]"
const CHEVRON_OUT_R = "-mr-[7px]"
export function NumberField({
value,
onValueChange,
onValueCommitted,
label,
handle,
unit: unitProp,
min,
max,
step = 1,
whole,
marks = [],
format,
chevrons = true,
bar,
size = "md",
material,
scheme,
disabled,
readOnly,
"aria-describedby": describedBy,
name,
className,
}: NumberFieldProps) {
const m = useMaterial(material)
const unit = unitOf(unitProp)
const range: NumberRange = {
min,
max,
step,
whole: whole ?? unit.whole ?? false,
}
if (min !== undefined && max !== undefined && min > max)
throw new Error(`number ${label}: min ${min} is over max ${max}`)
const markValues = marks.map((m) => m.value)
const root = React.useRef<HTMLSpanElement>(null)
// Over a picture it is read by the part of it under the field.
const [s, here] = useSurfaceScheme(scheme, root)
const input = React.useRef<HTMLInputElement>(null)
const [editing, setEditing] = React.useState(false)
const [draft, setDraft] = React.useState("")
const [scrubbing, setScrubbing] = React.useState(false)
// Inside a field: its wiring on the input, and held still while it is read-only (a
// form sending: no change, the focus kept, never disabled). Inside a setting: a
// ghost while its value is inherited, held still while it is locked, and an
// emptied field clears it back to what it inherits.
const field = useField()
const setting = useSetting()
const held =
(readOnly ?? false) ||
(field?.readOnly ?? false) ||
(setting?.readOnly ?? false)
const ghost = setting?.inherited ?? false
const described =
[describedBy ?? field?.wiring["aria-describedby"], setting?.says]
.filter(Boolean)
.join(" ") || undefined
// The handlers outlive a render (a drag spans many): they read the latest.
const live = React.useRef({
value,
onValueChange,
onValueCommitted,
range,
held,
})
live.current = { value, onValueChange, onValueCommitted, range, held }
const edit = (v: number) => formatValue(v, range, unit, false)
const shown = (v: number) => format?.(v) ?? formatValue(v, range, unit)
const parsed = editing ? parseValue(draft, unit) : value
const invalid = editing && draft.trim() !== "" && parsed === null
const mark = marks.find((m) => m.value === value)
const set = (v: number, commit: boolean) => {
if (live.current.held) return
if (v !== live.current.value) live.current.onValueChange(v)
live.current.value = v
if (commit) live.current.onValueCommitted?.(v)
}
/** A typed value, into the range and onto the whole grain if whole, but not
* onto the step: 0.253 typed is meant. */
const settle = (v: number) =>
range.whole ? quantize(v, range, 1) : clean(clamp(v, range))
const commit = (): boolean => {
// Emptied inside a setting that holds a value here: back to what it inherits,
// shown at once.
if (draft.trim() === "" && setting?.clear && !live.current.held) {
const inherited = setting.fallback?.value
setting.clear()
if (typeof inherited === "number") setDraft(edit(inherited))
return true
}
const v = parseValue(draft, unit)
if (v === null) return false
const next = settle(v)
// Leaving a field that was only looked at settles nothing.
if (next !== live.current.value) set(next, true)
setDraft(edit(next))
return true
}
const selectAll = () => requestAnimationFrame(() => input.current?.select())
// The base a key or the wheel steps from: the text being typed, when it reads.
const base = () => {
if (!editing) return live.current.value
const typed = parseValue(draft, unit)
return typed === null ? live.current.value : settle(typed)
}
const moveTo = (v: number) => {
// Held still: no step shown either, not even in the text being looked at.
if (live.current.held) return
set(v, true)
if (editing) {
setDraft(edit(v))
selectAll()
}
}
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
e.preventDefault()
moveTo(stepBy(base(), e.key === "ArrowUp" ? 1 : -1, range, modifier(e)))
} else if (e.key === "PageUp" || e.key === "PageDown") {
e.preventDefault()
const dir = e.key === "PageUp" ? 1 : -1
moveTo(
markValues.length > 0
? markBy(base(), dir, markValues, range)
: stepBy(base(), dir, range, 10),
)
} else if (e.key === "Enter") {
e.preventDefault()
if (commit()) selectAll()
} else if (e.key === "Escape") {
// Esc twice: the first puts the typing back, the second leaves.
if (draft !== edit(value)) {
e.preventDefault()
setDraft(edit(value))
selectAll()
} else input.current?.blur()
}
}
// The chevrons. One press is one move: to the next mark when there are marks,
// else a step; shift or alt (ten steps, a tenth) step even with marks, which is
// how a value leaves a mark by a little. Held, the move repeats after HOLD_MS,
// every REPEAT_MS (slower between marks, which are far apart). A press never
// takes focus from the value, so typing carries on where it was.
const chevronTo = (from: number, dir: 1 | -1, mult: number) =>
markValues.length > 0 && mult === 1
? markBy(from, dir, markValues, range)
: stepBy(from, dir, range, mult)
const chevronMove = (dir: 1 | -1, mult: number, commit: boolean) => {
if (live.current.held) return
const next = chevronTo(live.current.value, dir, mult)
set(next, commit)
if (editing) setDraft(edit(next))
}
/** Nothing further that way: the chevron dims (a state, so it stays). */
const atEnd = (dir: 1 | -1) => chevronTo(value, dir, 1) === value
// The value a held press started from, to settle on release only if it moved.
const pressedFrom = React.useRef(value)
const chevron = (dir: 1 | -1) => (
// The registry's step chevron (stepper): a step a press, repeating while held
// (slower between marks, which are far apart), never taking focus.
<StepChevron
dir={dir}
label={
markValues.length > 0
? dir > 0
? "next mark"
: "previous mark"
: dir > 0
? "increase"
: "decrease"
}
atEnd={atEnd(dir) || held}
disabled={disabled}
every={markValues.length > 0 ? 140 : 70}
onPress={() => {
pressedFrom.current = live.current.value
}}
onStep={(mult, commit) => chevronMove(dir, mult, commit)}
onRelease={() => {
if (live.current.value !== pressedFrom.current)
live.current.onValueCommitted?.(live.current.value)
}}
// The 20 px target reaches out into the padding; the glyph's ink, which
// fills its box exactly, stands on the inset like the text above it.
className={cn("-my-1", dir < 0 ? CHEVRON_OUT_L : CHEVRON_OUT_R)}
/>
)
// The wheel, only while focused. A listener of our own: React's is passive, and
// a passive listener cannot keep the page from scrolling. Re-bound each render,
// so it steps from what is typed now; the travel a trackpad has sent toward the
// next step is kept across renders.
const travel = React.useRef(0)
React.useEffect(() => {
const el = input.current
if (!el) return
const onWheel = (e: WheelEvent) => {
if (document.activeElement !== el || disabled) return
e.preventDefault()
travel.current += (e.deltaY || e.deltaX) * (e.deltaMode === 1 ? 16 : 1)
const steps = Math.trunc(travel.current / WHEEL_PX)
if (steps === 0) return
travel.current -= steps * WHEEL_PX
moveTo(stepBy(base(), -steps, live.current.range, modifier(e)))
}
el.addEventListener("wheel", onWheel, { passive: false })
return () => el.removeEventListener("wheel", onWheel)
})
// The drag. A press on the handle, or on the value while it is not being typed
// in, waits to see whether it travels: past SLOP_PX it scrubs, otherwise it was a
// click and the value opens for typing.
const onScrubDown = (e: React.PointerEvent) => {
// Read-only, a press only focuses the value: nothing to drag.
if (disabled || held || e.button !== 0) return
if (editing && e.currentTarget === input.current) return
e.preventDefault()
const el = root.current
if (!el) return
const from = live.current.value
let drag: Drag = startDrag(from)
let moved = false
let lastX = e.clientX
let locked = false
const end = (outcome: "commit" | "revert" | "click") => {
document.removeEventListener("pointermove", onMove)
document.removeEventListener("pointerup", onUp)
document.removeEventListener("pointercancel", onCancel)
document.removeEventListener("keydown", onKey, true)
document.removeEventListener("pointerlockchange", onLock)
if (document.pointerLockElement === el) document.exitPointerLock()
setScrubbing(false)
if (outcome === "revert") set(from, true)
else if (outcome === "commit") set(drag.value, true)
else {
input.current?.focus()
selectAll()
}
if (editing) setDraft(edit(live.current.value))
}
const onMove = (ev: PointerEvent) => {
const dx = locked ? ev.movementX : ev.clientX - lastX
lastX = ev.clientX
// Chrome reports one wild movementX as the lock lands; no hand moves this far
// in one event.
if (Math.abs(dx) > LEAP_PX) return
if (!moved) {
if (Math.abs(ev.clientX - e.clientX) < SLOP_PX) return
moved = true
setScrubbing(true)
// Locked, the cursor is hidden and the hand can travel past the screen's
// edge. Not every pointer can be locked (touch, a page in a frame): it
// then scrubs on the pointer's own position.
if (ev.pointerType === "mouse") {
const asked = el.requestPointerLock?.() as Promise<void> | undefined
asked?.catch?.(() => {})
}
}
drag = dragBy(drag, dx, live.current.range, markValues, modifier(ev))
if (drag.value !== live.current.value) set(drag.value, false)
}
const onUp = () => end(moved ? "commit" : "click")
const onCancel = () => end("revert")
const onKey = (ev: KeyboardEvent) => {
if (ev.key !== "Escape") return
ev.preventDefault()
ev.stopPropagation()
end("revert")
}
// The browser lets go of a locked pointer on Esc itself, before any key
// reaches the page: losing the lock mid-drag is the Esc.
const onLock = () => {
if (document.pointerLockElement === el) locked = true
else if (locked) end("revert")
}
document.addEventListener("pointermove", onMove)
document.addEventListener("pointerup", onUp)
document.addEventListener("pointercancel", onCancel)
document.addEventListener("keydown", onKey, true)
document.addEventListener("pointerlockchange", onLock)
}
// The range bar is a slider too: a press puts the value where it lands and the
// hand carries it, absolute like a slider's thumb (the value's own drag is the
// relative one, for fine work). A mark in reach takes the value, as it does in
// the value's drag. Esc puts back what was there.
const track = React.useRef<HTMLSpanElement>(null)
const onBarDown = (e: React.PointerEvent<HTMLSpanElement>) => {
if (
disabled ||
held ||
e.button !== 0 ||
min === undefined ||
max === undefined
)
return
const el = track.current
if (!el) return
e.preventDefault()
const zone = e.currentTarget
zone.setPointerCapture(e.pointerId)
const from = live.current.value
const box = el.getBoundingClientRect()
const at = (x: number) => {
const share = Math.min(1, Math.max(0, (x - box.left) / box.width))
const raw = min + share * (max - min)
const reach = (MARK_PX / box.width) * (max - min)
const near = markValues.reduce<number | undefined>(
(best, m) =>
Math.abs(m - raw) <= reach &&
(best === undefined || Math.abs(m - raw) < Math.abs(best - raw))
? m
: best,
undefined,
)
return near ?? quantize(raw, live.current.range, live.current.range.step)
}
setScrubbing(true)
set(at(e.clientX), false)
const onMove = (ev: PointerEvent) => set(at(ev.clientX), false)
const end = (revert: boolean) => {
zone.removeEventListener("pointermove", onMove)
zone.removeEventListener("pointerup", onUp)
zone.removeEventListener("pointercancel", onCancel)
document.removeEventListener("keydown", onKey, true)
setScrubbing(false)
const v = revert ? from : live.current.value
set(v, false)
if (v !== from) live.current.onValueCommitted?.(v)
if (editing) setDraft(edit(v))
}
const onUp = () => end(false)
const onCancel = () => end(true)
const onKey = (ev: KeyboardEvent) => {
if (ev.key !== "Escape") return
ev.preventDefault()
ev.stopPropagation()
end(true)
}
zone.addEventListener("pointermove", onMove)
zone.addEventListener("pointerup", onUp)
zone.addEventListener("pointercancel", onCancel)
document.addEventListener("keydown", onKey, true)
}
// The value's width is reserved for the widest it can be written, so a drag
// never shifts what stands after it.
const ends = [min, max].filter((v): v is number => v !== undefined)
const text = editing ? draft : shown(value)
const chars = Math.max(
text.length,
...ends.map((v) => shown(v).length),
...(editing ? [] : [shown(value).length]),
1,
)
const share = shareOf(value, range)
const drawBar = bar !== false && share !== null
const typedUnit = editing && /[a-zA-Zµ%]/.test(draft)
const named = marks.filter((m) => m.label)
// Lit: the field is being looked at or used. The bar grows into its reserved
// strip, shows its thumb, its ticks and their names, so it reads as something
// to drag; at rest it is a hairline again. Nothing around it moves either way.
const [hover, setHover] = React.useState(false)
const lit = hover || editing || scrubbing
// The mark under the hand on the bar, named under it. One name at a time:
// marks sit close (24 and 25 fps) and every name at once is a pile of letters.
const [aim, setAim] = React.useState<NumberMark | null>(null)
const onBarHover = (e: React.PointerEvent<HTMLSpanElement>) => {
const box = track.current?.getBoundingClientRect()
if (!box || box.width === 0) return
const x = e.clientX - box.left
let best: NumberMark | null = null
for (const m of named) {
const at = shareOf(m.value, range)
if (at === null) continue
const d = Math.abs(at * box.width - x)
if (
d <= AIM_PX &&
(best === null ||
d < Math.abs((shareOf(best.value, range) ?? 0) * box.width - x))
)
best = m
}
setAim(best)
}
return (
<span
ref={here}
data-slot="number"
data-scrubbing={scrubbing ? "" : undefined}
data-lit={lit ? "" : undefined}
data-invalid={invalid || field?.invalid ? "" : undefined}
data-disabled={disabled ? "" : undefined}
data-readonly={held ? "" : undefined}
data-inherited={ghost ? "" : undefined}
data-material={m}
onPointerEnter={() => setHover(true)}
onPointerLeave={() => setHover(false)}
className={cn(
// The grid: the padding IS the two insets, and every row runs exactly
// between them. The value row is 20 px, the strip under it 12 px, with
// the same padding above and below whichever rows there are.
"group/number relative inline-grid min-w-0 grid-cols-1 tabular-nums",
SIZE[size][m],
// A track between two chevrons needs room to be a track at all.
chevrons && "min-w-32",
schemeOf(s),
m === "glass"
? glassFace(
size,
!!chevrons ||
(bar !== false && min !== undefined && max !== undefined),
)
: PLAIN_FACE,
ghost && (m === "glass" ? GLASS_GHOST : PLAIN_GHOST),
"data-disabled:pointer-events-none data-disabled:opacity-40",
className,
)}
style={
{
"--number-width": `${chars + 0.25}ch`,
"--number-share": share === null ? undefined : `${share * 100}%`,
} as React.CSSProperties
}
>
{/* The value row. One rule: the value (and its unit) ends on the right
inset, always, so a column of fields lines its numbers up; what names
the value (the handle, the mark's name) starts on the left inset. */}
<span
data-slot="number-row"
className="flex h-5 items-center justify-between gap-3"
>
<span data-slot="number-names" className="flex items-center gap-1.5">
{handle !== undefined && (
<span
aria-hidden
data-slot="number-handle"
onPointerDown={onScrubDown}
className={cn(SCRUB, "text-current/55")}
>
{handle}
</span>
)}
{named.length > 0 && (
// Every name stacked in one cell, only the current one seen: the
// slot is as wide as the widest name always, so moving between
// marks moves nothing.
<span data-slot="number-mark" aria-hidden className="grid">
{named.map((m) => (
<span
key={m.value}
className={cn(
"col-start-1 row-start-1 whitespace-nowrap text-current/40",
(m !== mark || editing) && "invisible",
)}
>
{handle !== undefined ? `· ${m.label}` : m.label}
</span>
))}
</span>
)}
</span>
<span data-slot="number-reading" className="flex items-center gap-1.5">
<input
ref={input}
type="text"
role="spinbutton"
aria-label={label}
aria-valuenow={value}
aria-valuemin={min}
aria-valuemax={max}
aria-valuetext={[
`${shown(value)}${unit.label ? ` ${unit.label}` : ""}`,
mark?.label,
]
.filter(Boolean)
.join(", ")}
id={field?.wiring.id}
aria-describedby={described}
aria-required={field?.wiring["aria-required"]}
aria-invalid={invalid || field?.invalid || undefined}
inputMode={min !== undefined && min >= 0 ? "decimal" : "text"}
autoComplete="off"
spellCheck={false}
disabled={disabled ?? field?.wiring.disabled}
readOnly={held}
data-slot="number-value"
value={text}
onChange={(e) => setDraft(e.target.value)}
onPointerDown={onScrubDown}
onFocus={() => {
setEditing(true)
setDraft(edit(value))
selectAll()
}}
onBlur={() => {
// Leaving commits what reads as a number and forgets what does not.
commit()
setEditing(false)
}}
onKeyDown={onKeyDown}
className={cn(
"h-5 w-(--number-width) min-w-0 bg-transparent p-0 text-right leading-5 outline-none",
held ? "cursor-default" : editing ? "cursor-text" : SCRUB,
// Inherited: the digits a step back until they are typed over.
ghost && !editing && "text-current/55",
// Not a number yet: a dashed underline, monochrome like the rest.
"aria-invalid:underline aria-invalid:decoration-current/50 aria-invalid:decoration-dashed aria-invalid:underline-offset-4",
)}
/>
{unit.label && (
// Kept in place while a typed unit stands in for it, so nothing after
// it moves.
<span
data-slot="number-unit"
aria-hidden
className={cn("text-current/55", typedUnit && "invisible")}
>
{unit.label}
</span>
)}
</span>
</span>
{(drawBar || chevrons) && (
// The strip under the value, inset to inset like the row: ‹'s ink on
// the left inset, ›'s on the right, the track between them with equal
// gaps (or, with no chevrons, the track itself inset to inset). Without
// a range to draw (an open field) the chevrons still stand at the ends,
// with nothing between them.
<span
data-slot="number-strip"
data-chevrons={chevrons ? "" : undefined}
className="flex h-3 items-center"
>
{chevrons && chevron(-1)}
{drawBar ? (
<span
aria-hidden
data-slot="number-range"
onPointerDown={onBarDown}
onPointerMove={onBarHover}
onPointerLeave={() => setAim(null)}
className={cn(
// Taller than the strip for the hand (the track is drawn at its
// middle); 4 px past each chevron's target, an equal gap.
"relative -my-1 h-5 flex-1 cursor-ew-resize touch-pan-y",
chevrons && "mx-1",
)}
>
<span
ref={track}
data-slot="number-range-track"
className="absolute inset-x-0 top-1/2 h-px -translate-y-1/2 rounded-full bg-current/12 transition-[height] duration-150 group-data-lit/number:h-[3px] group-data-lit/number:bg-current/18"
>
<span
data-slot="number-range-fill"
className="absolute inset-y-0 left-0 w-(--number-share) rounded-full bg-current/55"
/>
{marks.map((m) => {
const at = shareOf(m.value, range)
return at === null ? null : (
<span
key={m.value}
data-slot="number-range-mark"
className="absolute top-1/2 h-1 w-px -translate-x-1/2 -translate-y-1/2 bg-current/40 transition-[height] duration-150 group-data-lit/number:h-2.5 group-data-lit/number:bg-current/60"
style={{ left: `${at * 100}%` }}
/>
)
})}
<span
data-slot="number-range-thumb"
className="absolute top-1/2 left-(--number-share) size-2.5 -translate-x-1/2 -translate-y-1/2 scale-0 rounded-full bg-current opacity-0 transition-[scale,opacity] duration-150 group-data-lit/number:scale-100 group-data-lit/number:opacity-100"
/>
</span>
{aim && (
// The aimed mark's name, under the field: an overlay, so it takes no
// room and pushes nothing. Near an end it hangs inward.
<span
data-slot="number-range-label"
className={cn(
// A small backing of its own blur and a tint of the text's own
// colour, so it reads over whatever lies beneath, on any surface.
"pointer-events-none absolute top-full z-10 mt-1 whitespace-nowrap rounded-[4px] bg-current/8 px-1 py-px text-[11px] text-current/80 backdrop-blur-md",
(shareOf(aim.value, range) ?? 0) < 0.15
? ""
: (shareOf(aim.value, range) ?? 0) > 0.85
? "-translate-x-full"
: "-translate-x-1/2",
)}
style={{ left: `${(shareOf(aim.value, range) ?? 0) * 100}%` }}
>
{aim.label}
</span>
)}
</span>
) : (
<span className="mx-1 flex-1" />
)}
{chevrons && chevron(1)}
</span>
)}
{name && <input type="hidden" name={name} value={value} />}
</span>
)
}// The arithmetic of a number field, framework-free: how a value is shown, how what a
// person types is read back, and where a step, a drag or a mark lands. The field
// (`ui/number`) binds it to the DOM; `scripts/examples/number.ts` proves it in bun.
//
// Two units, kept apart on purpose. The VALUE is in its own unit (a time is whole
// ms, the registry's one unit for time); what a person SEES and types is in the
// unit they think in (seconds). `NumberUnit.per` is the bridge: how many value
// units one shown unit is. Everything a person reads is a dot decimal whatever
// the browser's locale, because a field that shows "0,25" and refuses "0.25" (or
// the reverse) is the native field's worst habit.
export interface NumberUnit {
/** Said after the value: `s`, `fps`, `tokens`. */
label: string
/** Value units per shown unit: 1000 for a value in ms shown in seconds. */
per?: number
/** Words a person may type after a number, each with its own `per`
* (`ms: 1`, `s: 1000`). The label is always accepted. */
accepts?: Readonly<Record<string, number>>
/** The value is whole (a time in ms, a count): nothing finer than 1. */
whole?: boolean
}
/** A time: the value is whole ms, a person reads and types seconds, and may type
* `250ms`, `2 s`, `1m 30s`, `1.5 min`. */
export const SECONDS: NumberUnit = {
label: "s",
per: 1000,
whole: true,
accepts: {
ms: 1,
s: 1000,
sec: 1000,
secs: 1000,
second: 1000,
seconds: 1000,
m: 60_000,
min: 60_000,
mins: 60_000,
minute: 60_000,
minutes: 60_000,
h: 3_600_000,
hr: 3_600_000,
hour: 3_600_000,
hours: 3_600_000,
},
}
/** A unit given as a bare word: shown after the value, accepted when typed, no
* conversion. */
export function unitOf(unit: NumberUnit | string | undefined): NumberUnit {
if (unit === undefined) return { label: "" }
return typeof unit === "string" ? { label: unit } : unit
}
export interface NumberRange {
min?: number
max?: number
/** The keys' and the drag's step, in value units. */
step: number
/** The value is whole: no step finer than 1, typed values rounded. */
whole?: boolean
}
/** Round off float noise: 0.1 + 0.2 reads back as 0.3. Twelve significant digits
* is far finer than any field shows and far coarser than the noise. */
export function clean(n: number): number {
return Number(n.toPrecision(12))
}
/** Decimals a step needs to be written: 0.25 → 2, 10 → 0, 1e-3 → 3. */
export function decimalsOf(step: number): number {
if (!Number.isFinite(step) || step <= 0)
throw new Error(`number: a step is positive, got ${step}`)
for (let d = 0; d <= 12; d++)
if (Math.abs(Math.round(step * 10 ** d) - step * 10 ** d) < 1e-9) return d
return 12
}
export function clamp(v: number, { min, max }: NumberRange): number {
return Math.min(max ?? Infinity, Math.max(min ?? -Infinity, v))
}
/** A step times a modifier (10 coarse, 0.1 fine), never finer than 1 for a whole
* value. */
export function grainOf(range: NumberRange, mult: number): number {
const g = range.step * mult
return range.whole ? Math.max(1, Math.round(g)) : g
}
/** A raw value onto the grid of `grain` (counted from `min`, or 0 when there is
* none), then into the range. */
export function quantize(v: number, range: NumberRange, grain: number): number {
const base = Number.isFinite(range.min) ? (range.min as number) : 0
const on = base + Math.round((v - base) / grain) * grain
return clean(clamp(range.whole ? Math.round(on) : on, range))
}
/** The thousands group: a thin space (U+2009), which no locale reads as a decimal
* and which reads back as a group when the shown text is pasted. */
export const THIN = " "
/** The value text, in the shown unit with a dot decimal. Decimals come from the
* step as a person sees it (a 10 ms step shown in seconds is 0.01: two), one more
* when the value sits finer than that (a fine drag), and thin-space groups of
* three from 10 000 up. `group: false` is the text a person edits. */
export function formatValue(
value: number,
range: NumberRange,
unit: NumberUnit,
group = true,
): string {
const per = unit.per ?? 1
const shown = value / per
const base = decimalsOf(clean(range.step / per))
const fine = Math.abs(clean(shown * 10 ** base) % 1) > 1e-9
const text = shown.toFixed(fine ? base + 1 : base)
if (!group) return text
const [int, frac] = text.split(".") as [string, string | undefined]
const sign = int.startsWith("-") ? "-" : ""
const digits = sign ? int.slice(1) : int
const grouped =
digits.length > 4 ? digits.replace(/\B(?=(\d{3})+$)/g, THIN) : digits
return `${sign}${grouped}${frac === undefined ? "" : `.${frac}`}`
}
// Reading what a person typed: one or more terms, each a number with an optional
// unit word, added together ("1m 30s"), with + - * / and brackets between them
// ("0.25 * 2", "(1 + 2) / 4"). A bare number is in the shown unit; a unit word
// converts it. A comma is a decimal point ("0,25") unless it groups thousands
// ("1,120"); spaces and underscores inside digits group too. `k` and `M` multiply
// a count. `%` is a hundredth of the shown unit ("80%" is 0.8). Anything else is
// not a number and reads as null: the field keeps the text and says so.
type Op = "+" | "-" | "*" | "/" | "(" | ")"
type Tok = { kind: "num"; v: number } | { kind: "op"; v: Op }
const NUM = /^(?:\d{1,3}(?:[,_\s]\d{3})+(?:\.\d*)?|\d+(?:[.,]\d*)?|[.,]\d+)/
const WORD = /^(?:[a-zA-Zµ]+|%)/
const OPS: Record<string, Op> = {
"+": "+",
"-": "-",
"−": "-",
"*": "*",
"×": "*",
"/": "/",
"÷": "/",
"(": "(",
")": ")",
}
function readNumber(text: string): number {
const groups = /^\d{1,3}(?:[,_\s]\d{3})+/.test(text) && !/^0[,_\s]/.test(text)
const plain = groups ? text.replace(/[,_\s]/g, "") : text.replace(",", ".")
return Number(plain.endsWith(".") ? plain.slice(0, -1) : plain)
}
function factorOf(word: string, unit: NumberUnit): number | null {
const per = unit.per ?? 1
if (word === "%") return per / 100
// The label, in any case, and singular ("1 token" beside "tokens").
const label = unit.label.toLowerCase()
const lower = word.toLowerCase()
if (label && (lower === label || `${lower}s` === label)) return per
const accepts = unit.accepts ?? {}
if (word in accepts) return accepts[word] as number
if (lower in accepts) return accepts[lower] as number
if (word === "k" || word === "K") return per * 1000
if (word === "M") return per * 1_000_000
return null
}
function tokenize(input: string, unit: NumberUnit): Tok[] | null {
const out: Tok[] = []
let s = input.trim()
let unitLast = false
while (s.length > 0) {
if (/^\s/.test(s)) {
s = s.trimStart()
continue
}
const op = OPS[s[0] as string]
if (op) {
out.push({ kind: "op", v: op })
s = s.slice(1)
continue
}
const num = NUM.exec(s)
if (!num) return null
// A grouping space counts only when a group of three ends the number:
// "2 500" is 2500, "2 s" is two seconds, "2 5000" is nothing.
const lit = num[0]
s = s.slice(lit.length)
if (/\s/.test(lit) && /^\d/.test(s)) return null
const n = readNumber(lit)
if (!Number.isFinite(n)) return null
const rest = s.trimStart()
const word = WORD.exec(rest)
let factor = unit.per ?? 1
if (word) {
const f = factorOf(word[0], unit)
if (f === null) return null
factor = f
s = rest.slice(word[0].length)
}
// "1m 30s": a term with a unit followed straight by another adds. Two bare
// numbers side by side ("1 2") say nothing and read as no number.
if (out.at(-1)?.kind === "num") {
if (!(unitLast && word)) return null
out.push({ kind: "op", v: "+" })
}
out.push({ kind: "num", v: n * factor })
unitLast = word !== null
}
return out
}
/** What a person typed, in value units, or null when it is not a number. Not
* clamped and not stepped: that is the caller's, once the text is committed. */
export function parseValue(input: string, unit: NumberUnit): number | null {
const toks = tokenize(input, unit)
if (!toks || toks.length === 0) return null
let i = 0
const isOp = (t: Tok | undefined, ...ops: Op[]): t is Tok & { kind: "op" } =>
t?.kind === "op" && ops.includes(t.v)
// expr := term (('+'|'-') term)* ; term := factor (('*'|'/') factor)*
// factor := ('-'|'+') factor | num | '(' expr ')'
const expr = (): number | null => {
let v = term()
while (v !== null) {
const t = toks[i]
if (!isOp(t, "+", "-")) break
i++
const r = term()
if (r === null) return null
v = t.v === "+" ? v + r : v - r
}
return v
}
const term = (): number | null => {
let v = factor()
while (v !== null) {
const t = toks[i]
if (!isOp(t, "*", "/")) break
i++
const r = factor()
if (r === null) return null
// A product of two quantities in value units would square the unit:
// "2s * 3" means six seconds, so the right side is a plain number.
const plain = r / (unit.per ?? 1)
v = t.v === "*" ? v * plain : v / plain
}
return v
}
const factor = (): number | null => {
const t = toks[i]
if (!t) return null
i++
if (t.kind === "num") return t.v
if (t.v === "-" || t.v === "+") {
const f = factor()
return f === null ? null : t.v === "-" ? -f : f
}
if (t.v === "(") {
const v = expr()
if (!isOp(toks[i], ")")) return null
i++
return v
}
return null
}
const v = expr()
if (v === null || i !== toks.length || !Number.isFinite(v)) return null
return clean(v)
}
/** A value an arrow key or the wheel moves to: `dir` steps of `step × mult`. */
export function stepBy(
value: number,
dir: number,
range: NumberRange,
mult = 1,
): number {
// A coarse step adds ten steps and lands on the plain grid (250 → 350, not the
// next hundred); a fine one lands on its own finer grid.
return quantize(
value + dir * grainOf(range, mult),
range,
grainOf(range, Math.min(mult, 1)),
)
}
/** The next mark past the value in `dir` (PageUp, PageDown), or the end of the
* range when there is none that way. */
export function markBy(
value: number,
dir: 1 | -1,
marks: readonly number[],
range: NumberRange,
): number {
const sorted = [...marks].sort((a, b) => a - b)
const next =
dir > 0
? sorted.find((m) => m > value + 1e-9)
: sorted.reverse().find((m) => m < value - 1e-9)
return next ?? clamp(dir > 0 ? Infinity : -Infinity, range)
}
// The drag. A design tool's scrub: horizontal travel is value, at a fixed rate per
// pixel, with the pointer locked so the hand is never stopped by a screen edge.
// A range that is known spreads across `RANGE_PX` of travel; an open one moves a
// step every `STEP_PX`. The drag keeps its own unrounded value, held inside the
// range, so travel back from past an end responds at once instead of first
// unwinding the overshoot. A mark holds the value while the hand is within
// `MARK_PX` of it: easy to land on, never a wall.
export const RANGE_PX = 240
export const STEP_PX = 4
export const MARK_PX = 6
/** Value per pixel of drag at the plain speed. */
export function perPixel(range: NumberRange): number {
const { min, max, step } = range
if (Number.isFinite(min) && Number.isFinite(max))
return Math.max(
((max as number) - (min as number)) / RANGE_PX,
step / STEP_PX,
)
return step / STEP_PX
}
export interface Drag {
/** The drag's own value, unrounded, held inside the range. */
raw: number
/** What the field shows: the raw value on its grain, or the mark it is near. */
value: number
}
export function startDrag(value: number): Drag {
return { raw: value, value }
}
/** One movement of the hand, `dx` px, at `mult` (0.1 fine, 10 coarse). */
export function dragBy(
drag: Drag,
dx: number,
range: NumberRange,
marks: readonly number[],
mult = 1,
): Drag {
const rate = perPixel(range) * mult
const raw = clamp(drag.raw + dx * rate, range)
const pull = MARK_PX * rate
// The nearest mark in reach: two marks closer than the reach (24 and 25 fps)
// split it between them.
const near = marks.reduce<number | undefined>(
(best, m) =>
Math.abs(m - raw) <= pull &&
(best === undefined || Math.abs(m - raw) < Math.abs(best - raw))
? m
: best,
undefined,
)
// A fine drag lands on tenths of the step (never finer than 1 for a whole value),
// a plain or coarse one on the step.
const value =
near !== undefined
? clean(clamp(near, range))
: quantize(raw, range, grainOf(range, Math.min(mult, 1)))
return { raw, value }
}
/** Where the value sits in its range, 0 to 1, or null for an open range. */
export function shareOf(value: number, range: NumberRange): number | null {
const { min, max } = range
if (!Number.isFinite(min) || !Number.isFinite(max) || max === min) return null
return Math.min(
1,
Math.max(
0,
(value - (min as number)) / ((max as number) - (min as number)),
),
)
}