Number field
A stepper you can press, hold, scrub, or type into. Push past a limit and it pushes back.
$
pnpm dlx shadcn@latest add https://lodeui.com/r/number-field.jsonTeam plan
Up to 10 seats
$104 / mo
8 seats
$12 each. Drag the label to scrub.
2
$4 each, up to 3
components/examples/number-field-demo.tsx
"use client";
import * as React from "react";
import { AnimatedNumber } from "@/components/ui/animated-number";
import { NumberField } from "@/components/ui/number-field";
const SEAT_PRICE = 12;
const GUEST_PRICE = 4;
export default function NumberFieldDemo() {
const [seats, setSeats] = React.useState(8);
const [guests, setGuests] = React.useState(2);
return (
<div className="w-full max-w-md rounded-xl border border-border bg-card p-8 shadow-sm">
<div className="flex items-start justify-between">
<div>
<h3 className="text-lg font-medium">Team plan</h3>
<p className="text-sm text-muted-foreground">Up to 10 seats</p>
</div>
<p className="text-2xl font-medium">
<AnimatedNumber value={seats * SEAT_PRICE + guests * GUEST_PRICE} format={(n) => `$${Math.round(n)}`} />
<span className="text-sm text-muted-foreground"> / mo</span>
</p>
</div>
<label className="mt-8 block text-sm font-medium">Seats</label>
<NumberField
className="mt-3"
aria-label="Seats"
value={seats}
onValueChange={setSeats}
min={1}
max={10}
format={(n) => `${n} ${n === 1 ? "seat" : "seats"}`}
/>
<p className="mt-3 text-[13px] text-muted-foreground">${SEAT_PRICE} each. Drag the label to scrub.</p>
<label className="mt-7 block text-sm font-medium">Guest passes</label>
<NumberField
className="mt-3"
size="sm"
aria-label="Guest passes"
value={guests}
onValueChange={setGuests}
min={0}
max={3}
/>
<p className="mt-3 text-[13px] text-muted-foreground">${GUEST_PRICE} each, up to 3</p>
</div>
);
}When to use
- Small integer quantities: seats, guests, items, replicas.
- Values people adjust by feel, where dragging to scrub is faster than typing.
When not to use
- Large or precise numbers like prices or IDs: use an input.
- A continuous range: use a slider.
- Values with units that need typing (e.g. 12.5 GB): use an input with a trailing unit.
Installation
$
pnpm dlx shadcn@latest add https://lodeui.com/r/number-field.json1Install the dependencies
npm install motion lucide-react2Copy the source into your project
components/ui/number-field.tsx"use client"; import * as React from "react"; import { AnimatePresence, animate, motion, useMotionValue } from "motion/react"; import { Minus, Plus } from "lucide-react"; import { cn } from "@/lib/utils"; export interface NumberFieldProps { value?: number; defaultValue?: number; onValueChange?: (value: number) => void; min?: number; max?: number; step?: number; /** Display formatter, e.g. (n) => `${n} seats`. */ format?: (value: number) => string; size?: "sm" | "md"; /** Drag the label horizontally to change the value. */ scrub?: boolean; disabled?: boolean; className?: string; "aria-label"?: string; } const PX_PER_STEP = 10; const rubberBand = (overflow: number) => Math.sign(overflow) * Math.sqrt(Math.abs(overflow)) * 2.2; export function NumberField({ value, defaultValue = 0, onValueChange, min = -Infinity, max = Infinity, step = 1, format = String, size = "md", scrub = true, disabled, className, ...aria }: NumberFieldProps) { const [internal, setInternal] = React.useState(defaultValue); const current = value ?? internal; const currentRef = React.useRef(current); React.useLayoutEffect(() => { currentRef.current = current; }); const [direction, setDirection] = React.useState(1); const x = useMotionValue(0); const commit = (next: number) => { if (next === currentRef.current) return; setDirection(next > currentRef.current ? 1 : -1); currentRef.current = next; if (value === undefined) setInternal(next); onValueChange?.(next); }; // Past a limit the field nudges toward the push and springs back. const bump = (dir: 1 | -1) => { animate(x, [0, dir * 7, 0], { duration: 0.38, ease: [0.2, 0.8, 0.2, 1] }); }; const nudge = (delta: number) => { const raw = currentRef.current + delta; if (raw > max || raw < min) { const clamped = Math.min(max, Math.max(min, raw)); if (clamped !== currentRef.current) commit(clamped); else bump(delta > 0 ? 1 : -1); return; } commit(raw); }; const onKeyDown = (e: React.KeyboardEvent) => { const map: Record<string, () => void> = { ArrowUp: () => nudge(step), ArrowRight: () => nudge(step), ArrowDown: () => nudge(-step), ArrowLeft: () => nudge(-step), PageUp: () => nudge(step * 10), PageDown: () => nudge(-step * 10), Home: () => Number.isFinite(min) && commit(min), End: () => Number.isFinite(max) && commit(max), }; const action = map[e.key]; if (!action) return; e.preventDefault(); action(); }; // --- scrubbing ----------------------------------------------------- const drag = React.useRef<{ startX: number; startValue: number } | null>(null); const onPointerDown = (e: React.PointerEvent) => { if (!scrub || disabled || e.button !== 0) return; e.currentTarget.setPointerCapture(e.pointerId); drag.current = { startX: e.clientX, startValue: currentRef.current }; }; const onPointerMove = (e: React.PointerEvent) => { if (!drag.current) return; const dx = e.clientX - drag.current.startX; const raw = drag.current.startValue + Math.round(dx / PX_PER_STEP) * step; const clamped = Math.min(max, Math.max(min, raw)); commit(clamped); if (raw !== clamped) { const limitPx = ((clamped - drag.current.startValue) / step) * PX_PER_STEP; x.set(rubberBand(dx - limitPx)); } else { x.set(0); } }; const endDrag = () => { if (!drag.current) return; drag.current = null; animate(x, 0, { type: "spring", stiffness: 500, damping: 30 }); }; const h = size === "sm" ? "h-11" : "h-14"; return ( <div data-slot="number-field" className={cn( "inline-flex max-w-full items-center rounded-lg border border-input bg-transparent p-1", h, size === "sm" ? "w-56 text-base" : "w-[19rem] text-xl", disabled && "pointer-events-none opacity-50", className, )} > <StepButton label="Decrease" onStep={() => nudge(-step)} disabled={disabled}> <Minus /> </StepButton> <motion.div role="spinbutton" tabIndex={disabled ? -1 : 0} aria-label={aria["aria-label"]} aria-valuenow={current} aria-valuemin={Number.isFinite(min) ? min : undefined} aria-valuemax={Number.isFinite(max) ? max : undefined} aria-valuetext={format(current)} aria-disabled={disabled || undefined} onKeyDown={onKeyDown} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={endDrag} onPointerCancel={endDrag} style={{ x }} className={cn( "relative flex h-full flex-1 items-center justify-center overflow-hidden rounded-md font-medium tabular-nums outline-none", "focus-visible:ring-2 focus-visible:ring-ring", scrub && "cursor-ew-resize touch-none select-none", )} > <AnimatePresence initial={false} mode="popLayout" custom={direction}> <motion.span key={current} custom={direction} variants={{ enter: (d: number) => ({ y: d > 0 ? "70%" : "-70%", opacity: 0, filter: "blur(3px)" }), center: { y: "0%", opacity: 1, filter: "blur(0px)" }, exit: (d: number) => ({ y: d > 0 ? "-70%" : "70%", opacity: 0, filter: "blur(3px)" }), }} initial="enter" animate="center" exit="exit" transition={{ type: "spring", bounce: 0, duration: 0.32 }} className="whitespace-nowrap" > {format(current)} </motion.span> </AnimatePresence> </motion.div> <StepButton label="Increase" onStep={() => nudge(step)} disabled={disabled}> <Plus /> </StepButton> </div> ); } /** Press and hold repeats the step, speeding up slightly. */ function StepButton({ label, onStep, disabled, children, }: { label: string; onStep: () => void; disabled?: boolean; children: React.ReactNode; }) { const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null); const stepRef = React.useRef(onStep); React.useLayoutEffect(() => { stepRef.current = onStep; }); const stop = () => { if (timer.current) clearTimeout(timer.current); timer.current = null; }; const start = (e: React.PointerEvent) => { if (e.button !== 0) return; stepRef.current(); let delay = 380; const tick = () => { stepRef.current(); delay = Math.max(50, delay * 0.8); timer.current = setTimeout(tick, delay); }; timer.current = setTimeout(tick, delay); }; React.useEffect(() => stop, []); return ( <button type="button" tabIndex={-1} aria-label={label} disabled={disabled} onPointerDown={start} onPointerUp={stop} onPointerLeave={stop} onPointerCancel={stop} // Keyboard activation (detail === 0) still works for assistive tech. onClick={(e) => e.detail === 0 && onStep()} className={cn( "grid aspect-square h-full shrink-0 place-items-center rounded-md text-muted-foreground", "transition-[background-color,color,transform] duration-150 hover:bg-accent hover:text-foreground active:scale-90", "[&_svg]:size-[1.1em]", )} > {children} </button> ); }
Usage
Import it from @/components/ui/number-field after installing.
example.tsx
import { NumberField } from "@/components/ui/number-field";
export function Seats({ seats, setSeats }: { seats: number; setSeats: (n: number) => void }) {
return (
<NumberField
aria-label="Seats"
value={seats}
onValueChange={setSeats}
min={1}
max={50}
format={(n) => `${n} ${n === 1 ? "seat" : "seats"}`}
/>
);
}API reference
NumberField
A spinbutton with decrement and increment buttons, press-and-hold repeat, drag-to-scrub and limit feedback.
| Prop | Type | Default | Description |
|---|---|---|---|
| value / defaultValue | number | 0 | Controlled or uncontrolled value. |
| onValueChange | (value: number) => void | – | Called on every change, including while scrubbing. |
| min / max | number | -∞ / ∞ | Bounds. Pushing past them nudges the field instead of changing the value. |
| step | number | 1 | Increment for buttons, arrow keys and each 10px of scrubbing. |
| format | (value: number) => string | String | Display text, also used as aria-valuetext. |
| scrub | boolean | true | Drag the value horizontally to change it. |
| size | "sm" | "md" | "md" | 224px or 304px wide, with smaller or larger digits. |
| disabled | boolean | – | Dims and blocks interaction. |
Keyboard interactions
| Keys | Action |
|---|---|
| ↑or→ | Increase by step |
| ↓or← | Decrease by step |
| Page UporPage Down | Change by ten steps |
| HomeorEnd | Jump to min or max |
Accessibility
- The value is a role="spinbutton" with aria-valuenow, min, max and valuetext.
- The − and + buttons are labelled and skipped in tab order, as the spinbutton pattern recommends.
- Keyboard activation of the buttons still works for assistive tech.
Motion
- Digits roll up when the value grows and down when it shrinks (spring, 320ms).
- At a limit the field nudges 7px toward the push and springs back.
- Scrubbing past a limit rubber-bands with square-root resistance.
Responsive behavior
- Fixed widths that shrink with max-w-full in narrow containers.
- Scrubbing uses pointer events, so it works with touch.
Performance
- Hold-to-repeat speeds up from 380ms to 50ms intervals and stops on pointer up or leave.
- Only the value text re-renders on change.
Notes for AI
- Use format for units so screen readers hear them too.
- For non-integers set step (e.g. 0.5) and format with toFixed.
This page as Markdown, for assistants and coding agents:
/components/number-field/markdownThe full library index for assistants is at /llms.txt.
Lode222 doc pagesllms.txt