formsclient

number input

a field for numbers, with two little keys that step it up and down. hold one to keep going, or use the arrow keys.

between 1 and 20

installation

new to blurrow? this sets up your project in one go: the package, the styles and the font. already done it? skip straight to usage. the installation page has the details.

bash
npx @blurrow/ui init
›rather own the code? copy the source instead

let the cli copy it, along with anything it needs:

bash
npx @blurrow/ui add number-input

or by hand: drop these files into components/ui and import from there. number input leans on FieldMessage.tsx, so those come along too.

components/ui/NumberInput.tsxtsx
"use client";
import React, { useEffect, useId, useRef, useState } from "react";
import { FieldKey, FieldLabel, FieldMessage } from "./FieldMessage";
interface NumberInputProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "value" | "defaultValue" | "onChange" | "type" | "size" | "prefix"> {
value?: number | null;
defaultValue?: number | null;
onChange?: (value: number | null) => void;
min?: number;
max?: number;
step?: number;
/** Digits after the decimal point; inferred from `step` when left out */
precision?: number;
label?: string;
hint?: string;
error?: string;
/** A unit shown before or after the number, like "$" or "kg" */
prefix?: React.ReactNode;
suffix?: React.ReactNode;
fullWidth?: boolean;
}
const decimals = (n: number) => (String(n).split(".")[1] ?? "").length;
/* A sunken field with two small raised keys at its right edge. The keys
repeat while held, arrow keys step (shift for ten at a time), and
whatever you type is clamped and rounded when you leave the field. */
export function NumberInput({
value,
defaultValue = null,
onChange,
min = -Infinity,
max = Infinity,
step = 1,
precision,
label,
hint,
error,
prefix,
suffix,
fullWidth = false,
disabled,
className = "",
id,
onBlur,
onKeyDown,
...rest
}: NumberInputProps) {
const uid = useId();
const inputId = id ?? `${uid}-input`;
const messageId = error || hint ? `${inputId}-message` : undefined;
const digits = precision ?? decimals(step);
const [internal, setInternal] = useState<number | null>(defaultValue);
const current = value !== undefined ? value : internal;
const format = (n: number | null) => (n === null ? "" : n.toFixed(digits));
const [draft, setDraft] = useState(format(current));
const editing = useRef(false);
// Follow the controlled value unless someone is mid-way through typing
useEffect(() => {
if (!editing.current) setDraft(format(current));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [current, digits]);
const clamp = (n: number) => Math.min(max, Math.max(min, Number(n.toFixed(digits))));
// The hold-to-repeat timer reads the newest value from here, not a stale render
const latest = useRef(current);
useEffect(() => {
latest.current = current;
}, [current]);
const commit = (n: number | null) => {
const next = n === null || Number.isNaN(n) ? null : clamp(n);
const prev = latest.current;
latest.current = next;
setInternal(next);
setDraft(format(next));
if (next !== prev) onChange?.(next);
};
// An empty field starts from zero, or from the nearest edge of the range
const nudge = (dir: 1 | -1, times = 1) => {
const base = latest.current;
commit(base === null ? clamp(0) : base + dir * step * times);
};
const hold = useRef<{ t?: ReturnType<typeof setTimeout>; i?: ReturnType<typeof setInterval> }>({});
const stopHold = () => {
clearTimeout(hold.current.t);
clearInterval(hold.current.i);
};
useEffect(() => stopHold, []);
const startHold = (dir: 1 | -1) => {
stopHold();
nudge(dir);
hold.current.t = setTimeout(() => {
hold.current.i = setInterval(() => nudge(dir), 60);
}, 400);
};
const atMin = current !== null && current <= min;
const atMax = current !== null && current >= max;
return (
<div className={["group/field flex flex-col gap-1.5", fullWidth ? "w-full" : ""].join(" ")}>
{label && <FieldLabel htmlFor={inputId}>{label}</FieldLabel>}
<div className={["relative isolate", fullWidth ? "w-full" : "w-40", className].join(" ")}>
{prefix && (
<span className="pointer-events-none absolute left-3.5 top-1/2 z-20 -translate-y-1/2 text-[13.5px] text-ink-faint transition-colors duration-200 group-focus-within/field:text-ink-label">
{prefix}
</span>
)}
<input
{...rest}
id={inputId}
type="text"
inputMode={digits > 0 || min < 0 ? "decimal" : "numeric"}
autoComplete="off"
role="spinbutton"
aria-valuenow={current ?? undefined}
aria-valuemin={Number.isFinite(min) ? min : undefined}
aria-valuemax={Number.isFinite(max) ? max : undefined}
aria-invalid={error ? true : undefined}
aria-describedby={messageId}
disabled={disabled}
value={draft}
onChange={(e) => {
editing.current = true;
setDraft(e.target.value.replace(/[^\d.,\-]/g, ""));
}}
onBlur={(e) => {
editing.current = false;
const raw = draft.replace(",", ".").trim();
commit(raw === "" || raw === "-" ? null : parseFloat(raw));
onBlur?.(e);
}}
onKeyDown={(e) => {
onKeyDown?.(e);
if (e.defaultPrevented) return;
const big = e.shiftKey ? 10 : 1;
if (e.key === "ArrowUp") { e.preventDefault(); editing.current = false; nudge(1, big); }
else if (e.key === "ArrowDown") { e.preventDefault(); editing.current = false; nudge(-1, big); }
else if (e.key === "PageUp") { e.preventDefault(); nudge(1, 10); }
else if (e.key === "PageDown") { e.preventDefault(); nudge(-1, 10); }
else if (e.key === "Home" && Number.isFinite(min)) { e.preventDefault(); commit(min); }
else if (e.key === "End" && Number.isFinite(max)) { e.preventDefault(); commit(max); }
else if (e.key === "Enter") { (e.target as HTMLInputElement).blur(); (e.target as HTMLInputElement).focus(); }
}}
className={[
"field relative z-10 w-full rounded-xl py-3 text-[13.5px] tabular-nums text-ink caret-ink",
"placeholder:text-ink-faint focus:placeholder:text-ink-ghost",
"disabled:opacity-40 disabled:cursor-not-allowed",
prefix ? "pl-8" : "pl-4",
suffix ? "pr-[92px]" : "pr-[64px]",
].join(" ")}
/>
{suffix && (
<span className="pointer-events-none absolute right-[64px] top-1/2 z-20 -translate-y-1/2 text-[12.5px] text-ink-faint">
{suffix}
</span>
)}
<div
className={[
"absolute right-1.5 top-1/2 z-20 flex -translate-y-1/2 gap-1",
disabled ? "opacity-40 pointer-events-none" : "",
].join(" ")}
>
<FieldKey label="decrease" focusable={false} disabled={atMin} onPress={() => startHold(-1)} onRelease={stopHold}>
<path d="M5 12h14" />
</FieldKey>
<FieldKey label="increase" focusable={false} disabled={atMax} onPress={() => startHold(1)} onRelease={stopHold}>
<path d="M12 5v14M5 12h14" />
</FieldKey>
</div>
</div>
<FieldMessage id={messageId} error={error} hint={hint} />
</div>
);
}
components/ui/FieldMessage.tsxtsx
"use client";
import React from "react";
/* The label above a field and the line under it, shared by every form
control so they read as one family. */
export function FieldLabel({
id,
htmlFor,
children,
}: {
id?: string;
htmlFor?: string;
children: React.ReactNode;
}) {
const className =
"text-[12.5px] font-medium text-ink-label tracking-[-0.005em] transition-colors duration-200 group-focus-within/field:text-ink-strong";
return htmlFor ? (
<label id={id} htmlFor={htmlFor} className={className}>
{children}
</label>
) : (
<span id={id} className={className}>
{children}
</span>
);
}
/* An error wins over a hint; either is announced through `id` */
export function FieldMessage({ id, error, hint }: { id?: string; error?: string; hint?: string }) {
if (error) {
return (
<p id={id} role="alert" className="text-[11.5px] text-danger font-medium flex items-center gap-1 mt-0.5">
<svg aria-hidden width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" />
<path strokeLinecap="round" d="M12 8v4m0 4h.01" />
</svg>
{error}
</p>
);
}
if (hint) {
return (
<p id={id} className="text-[11.5px] text-ink-muted mt-0.5">
{hint}
</p>
);
}
return null;
}
/* A small raised key that sits inside a field or a panel: steppers,
month arrows, clear buttons. It sinks flush while held. */
export function FieldKey({
label,
disabled,
onClick,
onPress,
onRelease,
focusable = true,
size = 28,
children,
}: {
label: string;
disabled?: boolean;
onClick?: () => void;
/** Pointer went down (for hold-to-repeat) */
onPress?: () => void;
onRelease?: () => void;
focusable?: boolean;
size?: number;
children: React.ReactNode;
}) {
const [pressed, setPressed] = React.useState(false);
const release = () => {
setPressed(false);
onRelease?.();
};
return (
<button
type="button"
aria-label={label}
tabIndex={focusable ? undefined : -1}
disabled={disabled}
onClick={onClick}
onPointerDown={(e) => {
if (e.button !== 0) return;
if (!focusable) e.preventDefault(); // keep focus in the field
setPressed(true);
onPress?.();
}}
onPointerUp={release}
onPointerLeave={() => pressed && release()}
onPointerCancel={release}
className="grid shrink-0 cursor-pointer place-items-center rounded-[9px] text-ink-muted transition-[box-shadow,color,opacity] duration-150 hover:text-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ink disabled:opacity-35 disabled:pointer-events-none"
style={{
width: size,
height: size,
background: "var(--face)",
boxShadow: pressed ? "var(--face-pressed)" : "var(--key-shadow)",
}}
>
<svg
aria-hidden
width="12"
height="12"
fill="none"
stroke="currentColor"
strokeWidth="2.25"
strokeLinecap="round"
strokeLinejoin="round"
viewBox="0 0 24 24"
style={{
transform: pressed ? "translateY(0.5px) scale(0.92)" : "none",
transition: "transform 150ms var(--ease-spring)",
}}
>
{children}
</svg>
</button>
);
}

usage

tsx
import { NumberInput } from "@blurrow/ui";
tsx
<NumberInput label="seats" value={seats} onChange={setSeats} min={1} max={20} />

examples

units and decimals

prefix and suffix hold a unit; step sets the size of each nudge and how many decimals show.

$
kg

error

props

straight from the source, so they're always up to date.

propsts
interface NumberInputProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "value" | "defaultValue" | "onChange" | "type" | "size" | "prefix"> {
value?: number | null;
defaultValue?: number | null;
onChange?: (value: number | null) => void;
min?: number;
max?: number;
step?: number;
/** Digits after the decimal point; inferred from `step` when left out */
precision?: number;
label?: string;
hint?: string;
error?: string;
/** A unit shown before or after the number, like "$" or "kg" */
prefix?: React.ReactNode;
suffix?: React.ReactNode;
fullWidth?: boolean;
}