← ui

number

ui

a number you drag, type or step, its unit beside it, for input type=number

npx shadcn add @ag/number
Already installed? Preview an update
npx shadcn add @ag/number --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-14
  • Its ‹ › are stepper's StepChevron (the same press, hold and repeat); their slots are step-previous and step-next, where they were number-previous and number-next. number now depends on stepper.
Updated 2026-10-14 · release notes

also called number field, spin button, scrubber

shelf controls

uses field-context, material, setting-context, stepper

used by components

the demo above, verbatim

number.demo.tsx
"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

number.tsx
"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>
  )
}
number-core.ts
// 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)),
    ),
  )
}