Subframe
Components & interactionsScrubbable number input

Precision scrub

A compact numerical field sets the spacing of a white typographic specimen on a deep plum proofing surface.

Drag the value, click to type, or use arrow keys; Shift accelerates and Alt refines

Subframe Original · MIT

Add to your project

Component source

Standalone React component. Styles are included.

React only. No additional packages.

Render the default export inside a container with a defined height. Its optional active prop stops movement when false. This example works with React 19; Next.js projects can use it as a client component.

View source code
/**
 * MIT License
 * Copyright (c) 2026 Subframe
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */
"use client"

import * as React from "react"

const clamp = (value: number) => Math.round(Math.max(-3, Math.min(12, value)) * 10) / 10

export default function Example({ active = true }: { active?: boolean }) {
  const [tracking, setTracking] = React.useState(-1.5)
  const [editing, setEditing] = React.useState(false)
  const [draft, setDraft] = React.useState("")
  const [dragging, setDragging] = React.useState(false)
  const [nudge, setNudge] = React.useState(0)
  const [fine, setFine] = React.useState(false)
  const chip = React.useRef<HTMLDivElement>(null)
  const input = React.useRef<HTMLInputElement>(null)
  const value = React.useRef(tracking)
  const finishing = React.useRef(false)
  const drag = React.useRef<{
    id: number
    start: number
    last: number
    before: number
    amount: number
    moved: boolean
  } | null>(null)
  const descriptionId = React.useId()

  const change = React.useCallback((next: number) => {
    value.current = clamp(next)
    setTracking(value.current)
  }, [])
  const finishDrag = React.useCallback(
    (cancel: boolean) => {
      const current = drag.current
      if (!current) return
      if (cancel) change(current.before)
      drag.current = null
      setDragging(false)
      setNudge(0)
      setFine(false)
    },
    [change],
  )
  React.useEffect(() => {
    const escape = (event: KeyboardEvent) => {
      if (event.key === "Escape") finishDrag(true)
    }
    const cancel = () => finishDrag(true)
    window.addEventListener("keydown", escape)
    window.addEventListener("blur", cancel)
    return () => {
      window.removeEventListener("keydown", escape)
      window.removeEventListener("blur", cancel)
    }
  }, [finishDrag])
  React.useEffect(() => {
    if (!active) finishDrag(true)
  }, [active, finishDrag])
  React.useEffect(() => {
    if (editing) {
      input.current?.focus()
      input.current?.select()
    }
  }, [editing])

  function startEditing() {
    finishing.current = false
    setDraft(value.current.toFixed(1))
    setEditing(true)
  }
  function finishEditing(cancel = false, restoreFocus = false) {
    if (finishing.current) return
    finishing.current = true
    const number = Number(draft)
    if (!cancel && draft.trim() && Number.isFinite(number)) change(number)
    setEditing(false)
    if (restoreFocus) chip.current?.focus()
  }

  return (
    <div className="sf-precision-scrub" data-dragging={dragging} data-paused={!active}>
      <div className="tracking-specimen" aria-label="Typesetting preview">
        <div className="tracking-guide" />
        <span style={{ letterSpacing: `${tracking}px` }}>minimum</span>
        <div className="tracking-baseline" />
      </div>
      <div className="tracking-inspector">
        <span className="tracking-label">Tracking</span>
        <div
          className="tracking-chip"
          ref={chip}
          role={editing ? undefined : "spinbutton"}
          tabIndex={editing ? -1 : 0}
          aria-label={editing ? undefined : "Tracking in pixels"}
          aria-valuemin={editing ? undefined : -3}
          aria-valuemax={editing ? undefined : 12}
          aria-valuenow={editing ? undefined : tracking}
          aria-describedby={descriptionId}
          style={{ transform: `translateX(${nudge}px)` }}
          onPointerDown={(event) => {
            if (editing || event.button !== 0) return
            event.preventDefault()
            event.currentTarget.focus()
            drag.current = {
              id: event.pointerId,
              start: event.clientX,
              last: event.clientX,
              before: value.current,
              amount: value.current,
              moved: false,
            }
            event.currentTarget.setPointerCapture(event.pointerId)
          }}
          onPointerMove={(event) => {
            const current = drag.current
            if (!current || current.id !== event.pointerId) return
            if (!current.moved && Math.abs(event.clientX - current.start) < 4) return
            current.moved = true
            setDragging(true)
            setFine(event.altKey)
            const multiplier = event.altKey ? 0.025 : event.shiftKey ? 0.5 : 0.1
            const next = current.amount + (event.clientX - current.last) * multiplier
            current.last = event.clientX
            current.amount = Math.max(-3, Math.min(12, next))
            setNudge(Math.max(-4, Math.min(4, (next - current.amount) * 2)))
            change(current.amount)
          }}
          onPointerUp={(event) => {
            if (drag.current?.id !== event.pointerId) return
            const click = !drag.current.moved
            finishDrag(false)
            if (click) startEditing()
          }}
          onPointerCancel={() => finishDrag(true)}
          onLostPointerCapture={() => finishDrag(true)}
          onKeyDown={(event) => {
            if (editing) return
            if (event.key === "Enter" || event.key === " ") {
              event.preventDefault()
              startEditing()
              return
            }
            if (["ArrowUp", "ArrowRight", "ArrowDown", "ArrowLeft", "Home", "End"].includes(event.key)) {
              event.preventDefault()
              const step = event.shiftKey ? 1 : 0.1
              change(
                event.key === "Home"
                  ? -3
                  : event.key === "End"
                  ? 12
                  : value.current + (["ArrowUp", "ArrowRight"].includes(event.key) ? step : -step),
              )
            }
          }}
        >
          <svg viewBox="0 0 24 24" className="tracking-arrow" aria-hidden="true">
            <path d="m6 8-4 4 4 4m12-8 4 4-4 4M2 12h20" />
          </svg>
          {editing ? (
            <input
              ref={input}
              type="text"
              inputMode="decimal"
              value={draft}
              aria-label="Edit tracking in pixels"
              onChange={(event) => setDraft(event.target.value)}
              onBlur={() => finishEditing()}
              onKeyDown={(event) => {
                if (event.key === "Enter") {
                  event.preventDefault()
                  finishEditing(false, true)
                }
                if (event.key === "Escape") {
                  event.preventDefault()
                  event.stopPropagation()
                  finishEditing(true, true)
                }
              }}
            />
          ) : (
            <output>{tracking.toFixed(1)}</output>
          )}
          <span className="tracking-unit">px</span>
        </div>
        <button
          className="tracking-reset"
          aria-label="Reset tracking"
          onClick={() => change(-1.5)}
          disabled={tracking === -1.5}
        >
          <svg viewBox="0 0 24 24" aria-hidden="true">
            <path d="M5 9a7.5 7.5 0 1 1-.2 6M5 4v5h5" />
          </svg>
        </button>
      </div>
      <span id={descriptionId} className="tracking-help">
        Drag horizontally or press Enter to type. Arrow keys adjust by 0.1 pixel. Shift increases the step; Alt makes
        dragging finer. Escape cancels.
      </span>
      {dragging && (
        <output className="tracking-delta" aria-live="off">
          {fine ? "Fine · " : ""}
          {tracking - (drag.current?.before ?? tracking) >= 0 ? "+" : ""}
          {(tracking - (drag.current?.before ?? tracking)).toFixed(1)} px
        </output>
      )}
      <style>{`
.sf-precision-scrub{height:100%;width:100%;min-height:0;position:relative;overflow:hidden;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12%;background:#341f39;color:#f6f4ed;container-type:size;font-family:Arial,sans-serif}.sf-precision-scrub *{box-sizing:border-box}.sf-precision-scrub .tracking-specimen{position:relative;width:83%;display:flex;align-items:baseline;justify-content:center;white-space:nowrap;font-family:Arial,Helvetica,sans-serif;font-size:11cqw;font-weight:500;line-height:1.18;letter-spacing:-.04em}.sf-precision-scrub .tracking-specimen>span{position:relative;padding-right:0}.sf-precision-scrub .tracking-guide,.sf-precision-scrub .tracking-baseline{position:absolute;left:0;right:0;height:1px;background:#ffffff18}.sf-precision-scrub .tracking-guide{top:25%}.sf-precision-scrub .tracking-baseline{bottom:14%}.sf-precision-scrub .tracking-inspector{display:flex;align-items:center;gap:16px;font-size:clamp(10px,1.9cqw,14px)}.sf-precision-scrub .tracking-label{color:#c5b6c6}.sf-precision-scrub .tracking-chip{position:relative;display:flex;align-items:center;gap:10px;height:44px;padding:0 13px;border:1px solid #ffffff20;border-radius:9px;background:#ffffff09;cursor:ew-resize;touch-action:none;user-select:none;font-family:monospace;transition:transform 180ms cubic-bezier(.2,.8,.2,1);font-variant-numeric:tabular-nums}.sf-precision-scrub .tracking-chip:hover{background:#ffffff12;border-color:#ffffff40}.sf-precision-scrub .tracking-chip:focus-visible{outline:2px solid #e5a9e2;outline-offset:4px}.sf-precision-scrub .tracking-chip output,.sf-precision-scrub .tracking-chip input{display:block;width:42px;text-align:right;font:inherit;color:inherit}.sf-precision-scrub .tracking-chip input{background:none;border:0;padding:0;outline:none;user-select:text}.sf-precision-scrub .tracking-unit{color:#ae9cae}.sf-precision-scrub .tracking-arrow{width:17px;height:17px;fill:none;stroke:#d5c3d4;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round}.sf-precision-scrub .tracking-reset{display:grid;place-items:center;width:44px;height:44px;background:transparent;border:0;border-radius:6px;color:#c5b6c6;cursor:pointer}.sf-precision-scrub .tracking-reset svg{width:17px;fill:none;stroke:currentColor;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round}.sf-precision-scrub .tracking-reset:hover{background:#ffffff0b;color:white}.sf-precision-scrub .tracking-reset:disabled{opacity:.3;cursor:default}.sf-precision-scrub .tracking-reset:focus-visible{outline:2px solid #e5a9e2;outline-offset:2px}.sf-precision-scrub .tracking-help{position:absolute;width:1px;height:1px;clip-path:inset(50%);overflow:hidden;white-space:nowrap}.sf-precision-scrub .tracking-delta{position:absolute;bottom:12%;font:11px monospace;color:#d6c4d4}.sf-precision-scrub[data-dragging=true] .tracking-chip,.sf-precision-scrub[data-paused=true] .tracking-chip{transition:none}@media(prefers-reduced-motion:reduce){.sf-precision-scrub .tracking-chip{transition:none}}
`}</style>
    </div>
  )
}

Where it works well

Space-efficient numerical editing with direct scrubbing, precise entry, and reversible gestures.

Reduced motion

Values and typesetting update directly; the small boundary response settles immediately.

Things to know

A local letter-spacing control bounded from -3 to 12 pixels; no pointer lock or document editor integration.

More examples