Steppers you can hold, a label you can drag, digits that roll into place.
01Preview
orders-db
Postgres 16 · eu-west-2
We pause scaling when the month's bill reaches this
Estimated$216.00$216.00 / month
02Install
Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:
npm install @base-ui/react @number-flow/react03Usage
import { NumberField } from "@/components/ui/number-field";
<NumberField label="Replicas" defaultValue={3} min={1} max={8} />
<NumberField
label="Monthly spend cap"
defaultValue={1200}
step={50}
format={{ style: "currency", currency: "USD", maximumFractionDigits: 0 }}
/>04Source
"use client";
import { NumberField as Base } from "@base-ui/react/number-field";
import NumberFlow, { type Format } from "@number-flow/react";
import { useId, useState } from "react";
import { cn } from "@/lib/cn";
import { Minus, Plus } from "@/lib/icons";
import { ease } from "@/lib/motion";
type BaseRootProps = React.ComponentProps<typeof Base.Root>;
export type NumberFieldProps = Omit<BaseRootProps, "children" | "className" | "render"> & {
/** Visible label. Drag it sideways to scrub the value. */
label: React.ReactNode;
/** A short unit after the number, like "GB" or "seats". For currency or percent, use `format`. */
unit?: string;
/** A hint under the field. */
description?: React.ReactNode;
/** Let the label scrub the value when dragged. */
scrub?: boolean;
/** Pixels of drag per step while scrubbing. */
scrubSensitivity?: number;
size?: "sm" | "md" | "lg";
placeholder?: string;
className?: string;
};
const sizes = {
sm: { group: "h-7 rounded-md", pad: "pl-2", btn: "w-7", text: "text-base sm:text-[12.5px]", icon: 12 },
md: { group: "h-8 rounded-lg", pad: "pl-2.5", btn: "w-8", text: "text-base sm:text-[13px]", icon: 14 },
lg: { group: "h-9 rounded-lg", pad: "pl-3", btn: "w-9", text: "text-base sm:text-[13px]", icon: 14 },
};
// Digits spin on the expo ease-out, quicker than the default so a held stepper or a fast scrub stays legible.
const roll = { duration: 420, easing: `cubic-bezier(${ease.out.join(",")})` };
type Reason = Parameters<NonNullable<BaseRootProps["onValueChange"]>>[1]["reason"];
const typedReasons: Reason[] = ["input-change", "input-clear", "input-paste"];
export function NumberField({
label,
unit,
description,
scrub = true,
scrubSensitivity = 4,
size = "md",
placeholder,
className,
id: idProp,
value: valueProp,
defaultValue,
onValueChange,
format,
locale,
...rest
}: NumberFieldProps) {
const autoId = useId();
const id = idProp ?? autoId;
const descId = `${id}-description`;
// Mirror the value so the rolling display can follow it whether or not the field is controlled.
const [inner, setInner] = useState<number | null>(defaultValue ?? null);
const value = valueProp !== undefined ? valueProp : inner;
// While someone types, show the real text; any step, scrub or blur hands back to the rolling digits.
const [typing, setTyping] = useState(false);
const s = sizes[size];
const rolling = !typing && value != null;
return (
<Base.Root
id={id}
value={valueProp}
defaultValue={defaultValue}
format={format}
locale={locale}
onValueChange={(next, details) => {
setInner(next);
setTyping(typedReasons.includes(details.reason));
onValueChange?.(next, details);
}}
className={cn("group/number flex min-w-0 flex-col gap-1.5", className)}
{...rest}
>
{scrub ? (
<Base.ScrubArea
direction="horizontal"
pixelSensitivity={scrubSensitivity}
className="group/scrub flex w-fit cursor-ew-resize touch-none select-none items-center gap-1 data-disabled:cursor-default data-readonly:cursor-default"
>
<Label id={id}>{label}</Label>
{/* A quiet ↔ that surfaces on hover, so the scrub is discoverable without a tooltip. */}
<svg
aria-hidden
width="12"
height="12"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
className="-translate-x-0.5 text-fg-4 opacity-0 transition-[opacity,translate] duration-150 ease-out group-hover/scrub:translate-x-0 group-hover/scrub:opacity-100 group-data-disabled/scrub:hidden group-data-readonly/scrub:hidden group-data-scrubbing/scrub:translate-x-0 group-data-scrubbing/scrub:text-fg-2 group-data-scrubbing/scrub:opacity-100 pointer-coarse:hidden"
>
<path d="M5.5 5 2.5 8l3 3M10.5 5l3 3-3 3M3 8h10" />
</svg>
<Base.ScrubAreaCursor className="drop-shadow-[0_1px_1px_var(--overlay)]">
<svg width="24" height="14" viewBox="0 0 24 14" aria-hidden className="block">
<path d="M18.5 4.8H5.5V1.5L1 7l4.5 5.5V9.2h13v3.3L23 7l-4.5-5.5z" fill="var(--fg)" stroke="var(--frame)" strokeWidth="1" strokeLinejoin="round" />
</svg>
</Base.ScrubAreaCursor>
</Base.ScrubArea>
) : (
<Label id={id}>{label}</Label>
)}
<Base.Group
className={cn(
"group/box relative flex w-full items-stretch overflow-hidden border border-line-2 bg-raised text-fg shadow-[var(--shadow)]",
"transition-[border-color,box-shadow,background-color] duration-150 ease-out",
"hover:border-fg-4 focus-within:border-fg-3 focus-within:ring-3 focus-within:ring-fg/8 hover:focus-within:border-fg-3",
"data-scrubbing:border-fg-3 data-scrubbing:ring-3 data-scrubbing:ring-fg/8",
"data-invalid:border-danger/70 data-invalid:focus-within:border-danger data-invalid:focus-within:ring-danger/15",
"data-disabled:cursor-not-allowed data-disabled:opacity-50 data-disabled:shadow-none data-disabled:hover:border-line-2",
"data-readonly:bg-frame data-readonly:shadow-none data-readonly:hover:border-line-2",
s.group,
)}
>
<div className={cn("relative flex min-w-0 flex-1 items-center", s.pad)}>
<Base.Input
placeholder={placeholder}
aria-describedby={description != null ? descId : undefined}
onBlur={() => setTyping(false)}
className={cn(
"h-full w-full min-w-0 bg-transparent tabular text-fg caret-fg outline-none placeholder:text-fg-4 disabled:cursor-not-allowed",
s.text,
// The text stays in place for the caret and selection; the digits drawn over it roll.
rolling && "text-transparent selection:bg-fg/20",
)}
/>
{rolling && (
<span aria-hidden className={cn("pointer-events-none absolute inset-y-0 left-0 flex items-center whitespace-nowrap tabular text-fg", s.text, s.pad)}>
<NumberFlow
value={value}
locales={locale}
format={format as Format | undefined}
spinTiming={roll}
transformTiming={roll}
/>
</span>
)}
</div>
{unit && (
<span aria-hidden className={cn("flex shrink-0 select-none items-center pl-1.5 pr-2.5 text-fg-3", s.text)}>
{unit}
</span>
)}
<Stepper kind="decrement" size={size} />
<Stepper kind="increment" size={size} />
</Base.Group>
{description != null && (
<p id={descId} className="text-pretty text-[12px] leading-4 text-fg-3">
{description}
</p>
)}
</Base.Root>
);
}
function Label({ id, children }: { id: string; children: React.ReactNode }) {
return (
<label
htmlFor={id}
className="cursor-[inherit] text-[12.5px] font-medium text-fg-2 transition-colors duration-150 group-hover/scrub:text-fg group-data-scrubbing/scrub:text-fg group-data-disabled/number:text-fg-3"
>
{children}
</label>
);
}
function Stepper({ kind, size }: { kind: "increment" | "decrement"; size: NonNullable<NumberFieldProps["size"]> }) {
const Part = kind === "increment" ? Base.Increment : Base.Decrement;
const s = sizes[size];
const Icon = kind === "increment" ? Plus : Minus;
return (
<Part
aria-label={kind === "increment" ? "Increase" : "Decrease"}
className={cn(
"group/step relative grid shrink-0 place-items-center border-l border-line text-fg-3 outline-none",
"transition-[background-color,color] duration-150 ease-out hover:bg-hover hover:text-fg active:bg-hover",
"focus-visible:bg-hover focus-visible:text-fg",
"data-disabled:pointer-events-none data-disabled:text-fg-4 data-disabled:opacity-60",
// Touch gets a 44px target without the button drawing any bigger.
"before:absolute before:inset-x-0 before:-inset-y-1.5 before:content-[''] pointer-coarse:before:-inset-y-2",
s.btn,
)}
>
<Icon
size={s.icon}
className="transition-transform duration-150 ease-out group-active/step:scale-[0.8] group-active/step:duration-75 motion-reduce:transition-none"
/>
</Part>
);
}05Props
| Prop | Type | Default | Description |
|---|---|---|---|
| label* | ReactNode | — | Visible label. Dragging it sideways scrubs the value. |
| value | number | null | — | The value, when controlled. Pair with onValueChange. |
| defaultValue | number | — | The starting value, when uncontrolled. |
| onValueChange | (value: number | null, details) => void | — | Called on every change; details.reason says whether it came from typing, a stepper, the keyboard, the wheel or a scrub. |
| onValueCommitted | (value: number | null, details) => void | — | Called when a change settles: on blur, on releasing a stepper or ending a scrub. |
| min | number | — | Lowest value. The minus stepper disables when it's reached. |
| max | number | — | Highest value. The plus stepper disables when it's reached. |
| step | number | 1 | Amount per stepper press, arrow key and scrub step. Shift uses largeStep, Alt uses smallStep. |
| largeStep | number | 10 | Step with Shift held. |
| unit | string | — | A short unit after the number, like "GB" or "seats". Use format for currency and percent. |
| format | Intl.NumberFormatOptions | — | Formatting for both the input and the rolling digits. |
| locale | Intl.LocalesArgument | — | Locale for formatting and parsing. Set it when you server-render, so server and browser agree. |
| description | ReactNode | — | A hint under the field, linked to the input. |
| scrub | boolean | true | Let the label scrub the value when dragged. |
| scrubSensitivity | number | 4 | Pixels of drag per step. |
| size | "sm" | "md" | "lg" | "md" | 28, 32 or 36px tall; the steppers stay square. |
| allowWheelScrub | boolean | false | Let the mouse wheel change the value while the field is focused and hovered. |
| disabled | boolean | false | Dims the field and ignores input. |
| readOnly | boolean | false | Shows the value flat, steppers unavailable, still focusable and selectable. |
06Notes
Behavior
- Holding a stepper steps once, waits 400ms, then repeats every 60ms; releasing commits once, not per step.
- The rolling digits draw over the real input, which keeps its caret and selection underneath. Typing hands the display back to the plain text; the next step, scrub or blur rolls again.
- Values typed past min or max are clamped when the field loses focus, and the digits roll to the clamped number so the correction is visible.
- The steppers disable at the bounds rather than silently doing nothing, and they're 44px tall on touch through an invisible extension.
Motion
- Digits spin for 420ms on the expo ease-out, faster than the default so a held stepper or a quick scrub stays legible; they roll up when the value rises and down when it falls.
- The stepper icon squeezes to 0.8 for 75ms while pressed; the field's border and 3px halo come up while scrubbing, so the drag has a visible target.
- A small ↔ slides 2px in beside the label on hover to show it can be dragged. Reduced motion shows the digits without spinning and drops the icon squeeze.
Accessibility
- Base UI NumberField: the input has the number field role description, ↑/↓ step, Shift and Alt change the step, Home and End jump to min and max.
- The label is a real label for the input; the rolling digits are aria-hidden, so screen readers read the input's own formatted value once.
- Steppers are labeled Increase and Decrease and expose disabled at the bounds.