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-inputor 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
1"use client";2
3import React, { useEffect, useId, useRef, useState } from "react";4import { FieldKey, FieldLabel, FieldMessage } from "./FieldMessage";5
6interface NumberInputProps7 extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "value" | "defaultValue" | "onChange" | "type" | "size" | "prefix"> {8 value?: number | null;9 defaultValue?: number | null;10 onChange?: (value: number | null) => void;11 min?: number;12 max?: number;13 step?: number;14 /** Digits after the decimal point; inferred from `step` when left out */15 precision?: number;16 label?: string;17 hint?: string;18 error?: string;19 /** A unit shown before or after the number, like "$" or "kg" */20 prefix?: React.ReactNode;21 suffix?: React.ReactNode;22 fullWidth?: boolean;23}24
25const decimals = (n: number) => (String(n).split(".")[1] ?? "").length;26
27/* A sunken field with two small raised keys at its right edge. The keys28 repeat while held, arrow keys step (shift for ten at a time), and29 whatever you type is clamped and rounded when you leave the field. */30export function NumberInput({31 value,32 defaultValue = null,33 onChange,34 min = -Infinity,35 max = Infinity,36 step = 1,37 precision,38 label,39 hint,40 error,41 prefix,42 suffix,43 fullWidth = false,44 disabled,45 className = "",46 id,47 onBlur,48 onKeyDown,49 ...rest50}: NumberInputProps) {51 const uid = useId();52 const inputId = id ?? `${uid}-input`;53 const messageId = error || hint ? `${inputId}-message` : undefined;54 const digits = precision ?? decimals(step);55
56 const [internal, setInternal] = useState<number | null>(defaultValue);57 const current = value !== undefined ? value : internal;58 const format = (n: number | null) => (n === null ? "" : n.toFixed(digits));59 const [draft, setDraft] = useState(format(current));60 const editing = useRef(false);61
62 // Follow the controlled value unless someone is mid-way through typing63 useEffect(() => {64 if (!editing.current) setDraft(format(current));65 // eslint-disable-next-line react-hooks/exhaustive-deps66 }, [current, digits]);67
68 const clamp = (n: number) => Math.min(max, Math.max(min, Number(n.toFixed(digits))));69
70 // The hold-to-repeat timer reads the newest value from here, not a stale render71 const latest = useRef(current);72 useEffect(() => {73 latest.current = current;74 }, [current]);75
76 const commit = (n: number | null) => {77 const next = n === null || Number.isNaN(n) ? null : clamp(n);78 const prev = latest.current;79 latest.current = next;80 setInternal(next);81 setDraft(format(next));82 if (next !== prev) onChange?.(next);83 };84
85 // An empty field starts from zero, or from the nearest edge of the range86 const nudge = (dir: 1 | -1, times = 1) => {87 const base = latest.current;88 commit(base === null ? clamp(0) : base + dir * step * times);89 };90
91 const hold = useRef<{ t?: ReturnType<typeof setTimeout>; i?: ReturnType<typeof setInterval> }>({});92 const stopHold = () => {93 clearTimeout(hold.current.t);94 clearInterval(hold.current.i);95 };96 useEffect(() => stopHold, []);97 const startHold = (dir: 1 | -1) => {98 stopHold();99 nudge(dir);100 hold.current.t = setTimeout(() => {101 hold.current.i = setInterval(() => nudge(dir), 60);102 }, 400);103 };104
105 const atMin = current !== null && current <= min;106 const atMax = current !== null && current >= max;107
108 return (109 <div className={["group/field flex flex-col gap-1.5", fullWidth ? "w-full" : ""].join(" ")}>110 {label && <FieldLabel htmlFor={inputId}>{label}</FieldLabel>}111 <div className={["relative isolate", fullWidth ? "w-full" : "w-40", className].join(" ")}>112 {prefix && (113 <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">114 {prefix}115 </span>116 )}117 <input118 {...rest}119 id={inputId}120 type="text"121 inputMode={digits > 0 || min < 0 ? "decimal" : "numeric"}122 autoComplete="off"123 role="spinbutton"124 aria-valuenow={current ?? undefined}125 aria-valuemin={Number.isFinite(min) ? min : undefined}126 aria-valuemax={Number.isFinite(max) ? max : undefined}127 aria-invalid={error ? true : undefined}128 aria-describedby={messageId}129 disabled={disabled}130 value={draft}131 onChange={(e) => {132 editing.current = true;133 setDraft(e.target.value.replace(/[^\d.,\-]/g, ""));134 }}135 onBlur={(e) => {136 editing.current = false;137 const raw = draft.replace(",", ".").trim();138 commit(raw === "" || raw === "-" ? null : parseFloat(raw));139 onBlur?.(e);140 }}141 onKeyDown={(e) => {142 onKeyDown?.(e);143 if (e.defaultPrevented) return;144 const big = e.shiftKey ? 10 : 1;145 if (e.key === "ArrowUp") { e.preventDefault(); editing.current = false; nudge(1, big); }146 else if (e.key === "ArrowDown") { e.preventDefault(); editing.current = false; nudge(-1, big); }147 else if (e.key === "PageUp") { e.preventDefault(); nudge(1, 10); }148 else if (e.key === "PageDown") { e.preventDefault(); nudge(-1, 10); }149 else if (e.key === "Home" && Number.isFinite(min)) { e.preventDefault(); commit(min); }150 else if (e.key === "End" && Number.isFinite(max)) { e.preventDefault(); commit(max); }151 else if (e.key === "Enter") { (e.target as HTMLInputElement).blur(); (e.target as HTMLInputElement).focus(); }152 }}153 className={[154 "field relative z-10 w-full rounded-xl py-3 text-[13.5px] tabular-nums text-ink caret-ink",155 "placeholder:text-ink-faint focus:placeholder:text-ink-ghost",156 "disabled:opacity-40 disabled:cursor-not-allowed",157 prefix ? "pl-8" : "pl-4",158 suffix ? "pr-[92px]" : "pr-[64px]",159 ].join(" ")}160 />161 {suffix && (162 <span className="pointer-events-none absolute right-[64px] top-1/2 z-20 -translate-y-1/2 text-[12.5px] text-ink-faint">163 {suffix}164 </span>165 )}166 <div167 className={[168 "absolute right-1.5 top-1/2 z-20 flex -translate-y-1/2 gap-1",169 disabled ? "opacity-40 pointer-events-none" : "",170 ].join(" ")}171 >172 <FieldKey label="decrease" focusable={false} disabled={atMin} onPress={() => startHold(-1)} onRelease={stopHold}>173 <path d="M5 12h14" />174 </FieldKey>175 <FieldKey label="increase" focusable={false} disabled={atMax} onPress={() => startHold(1)} onRelease={stopHold}>176 <path d="M12 5v14M5 12h14" />177 </FieldKey>178 </div>179 </div>180 <FieldMessage id={messageId} error={error} hint={hint} />181 </div>182 );183}components/ui/FieldMessage.tsxtsx
1"use client";2
3import React from "react";4
5/* The label above a field and the line under it, shared by every form6 control so they read as one family. */7
8export function FieldLabel({9 id,10 htmlFor,11 children,12}: {13 id?: string;14 htmlFor?: string;15 children: React.ReactNode;16}) {17 const className =18 "text-[12.5px] font-medium text-ink-label tracking-[-0.005em] transition-colors duration-200 group-focus-within/field:text-ink-strong";19 return htmlFor ? (20 <label id={id} htmlFor={htmlFor} className={className}>21 {children}22 </label>23 ) : (24 <span id={id} className={className}>25 {children}26 </span>27 );28}29
30/* An error wins over a hint; either is announced through `id` */31export function FieldMessage({ id, error, hint }: { id?: string; error?: string; hint?: string }) {32 if (error) {33 return (34 <p id={id} role="alert" className="text-[11.5px] text-danger font-medium flex items-center gap-1 mt-0.5">35 <svg aria-hidden width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">36 <circle cx="12" cy="12" r="10" />37 <path strokeLinecap="round" d="M12 8v4m0 4h.01" />38 </svg>39 {error}40 </p>41 );42 }43 if (hint) {44 return (45 <p id={id} className="text-[11.5px] text-ink-muted mt-0.5">46 {hint}47 </p>48 );49 }50 return null;51}52
53/* A small raised key that sits inside a field or a panel: steppers,54 month arrows, clear buttons. It sinks flush while held. */55export function FieldKey({56 label,57 disabled,58 onClick,59 onPress,60 onRelease,61 focusable = true,62 size = 28,63 children,64}: {65 label: string;66 disabled?: boolean;67 onClick?: () => void;68 /** Pointer went down (for hold-to-repeat) */69 onPress?: () => void;70 onRelease?: () => void;71 focusable?: boolean;72 size?: number;73 children: React.ReactNode;74}) {75 const [pressed, setPressed] = React.useState(false);76 const release = () => {77 setPressed(false);78 onRelease?.();79 };80 return (81 <button82 type="button"83 aria-label={label}84 tabIndex={focusable ? undefined : -1}85 disabled={disabled}86 onClick={onClick}87 onPointerDown={(e) => {88 if (e.button !== 0) return;89 if (!focusable) e.preventDefault(); // keep focus in the field90 setPressed(true);91 onPress?.();92 }}93 onPointerUp={release}94 onPointerLeave={() => pressed && release()}95 onPointerCancel={release}96 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"97 style={{98 width: size,99 height: size,100 background: "var(--face)",101 boxShadow: pressed ? "var(--face-pressed)" : "var(--key-shadow)",102 }}103 >104 <svg105 aria-hidden106 width="12"107 height="12"108 fill="none"109 stroke="currentColor"110 strokeWidth="2.25"111 strokeLinecap="round"112 strokeLinejoin="round"113 viewBox="0 0 24 24"114 style={{115 transform: pressed ? "translateY(0.5px) scale(0.92)" : "none",116 transition: "transform 150ms var(--ease-spring)",117 }}118 >119 {children}120 </svg>121 </button>122 );123}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
you need to be 13 or older
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;}