Number Input
A numeric field with − / + steppers and keyboard stepping. Built on Input with spinbutton semantics instead of the native number input, so typing is never fought and values stay predictable.
1 to 20 per order.
Installation
Add Number Input to your project with the CK-UI CLI. It copies the source into your project, so the code is yours to change.
npx ck-ui-cli@latest add number-input- Adds
NumberInput.tsxField.tsxInput.tsxLabel.tsxto your CK-UI folder (src/components/ck-ui by default)- Installs
@radix-ui/react-label@radix-ui/react-slotreact-iconsif your project does not have them yet
First time with CK-UI in this project? Run npx ck-ui-cli@latest init once before adding components. The installation guide explains what it sets up.
Manual installationInstall the packages and copy the files yourself.
Install the following dependencies
Run these commands in your terminal to install the required packages
npm install react-icons @radix-ui/react-slot @radix-ui/react-labelAdd the Input component
NumberInput imports ./Input, so add this file next to it
"use client";
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cn } from "@/lib/utils";
import { useField, useFieldControlProps } from "./Field";
type InputSize = "sm" | "md" | "lg";
const sizes = {
root: { sm: "h-8", md: "h-9", lg: "h-10" },
wrapper: { sm: "gap-1.5 px-2", md: "gap-2 px-2.5", lg: "gap-2 px-3" },
icon: { sm: "size-3.5", md: "size-4", lg: "size-[18px]" },
affix: { sm: "px-2", md: "px-2.5", lg: "px-3" },
} as const;
interface InputContextValue {
size: InputSize;
invalid: boolean;
}
const InputContext = React.createContext<InputContextValue>({ size: "md", invalid: false });
export interface InputRootProps extends React.HTMLAttributes<HTMLDivElement> {
size?: InputSize;
/** Error state: red border and aria-invalid on the input. Also taken from Field.Root. */
invalid?: boolean;
/** @deprecated Use `invalid`. */
error?: boolean;
asChild?: boolean;
}
const Root = React.forwardRef<HTMLDivElement, InputRootProps>(({ className, children, size = "md", invalid, error, asChild = false, ...props }, ref) => {
const field = useField();
const isInvalid = Boolean(invalid ?? error ?? field?.invalid);
const Component = asChild ? Slot : "div";
return (
<InputContext.Provider value={{ size, invalid: isInvalid }}>
<Component
ref={ref}
data-invalid={isInvalid || undefined}
className={cn(
"group/input relative flex w-full overflow-hidden rounded-lg border shadow-x-small transition duration-base ease-standard",
sizes.root[size],
// Focus: strong border plus a soft halo outside it (field-focus-within), as in CK-UI's original design.
isInvalid
? "border-destructive outline-destructive/25 focus-within:border-destructive hover:bg-destructive-subtle focus-within:hover:bg-transparent"
: "border-input outline-accent focus-within:border-foreground hover:bg-muted focus-within:hover:bg-transparent",
"field-focus-within",
"has-[input:disabled]:pointer-events-none has-[input:disabled]:cursor-not-allowed has-[input:disabled]:bg-muted has-[input:disabled]:shadow-none",
className
)}
{...props}
>
{children}
</Component>
</InputContext.Provider>
);
});
Root.displayName = "Input.Root";
export interface InputWrapperProps extends React.HTMLAttributes<HTMLDivElement> {
asChild?: boolean;
}
// A <div>, not a <label>: affix text must not become part of the input's accessible name.
// Clicking anywhere in the wrapper still focuses the input.
const Wrapper = React.forwardRef<HTMLDivElement, InputWrapperProps>(({ className, asChild = false, onMouseDown, ...props }, ref) => {
const { size } = React.useContext(InputContext);
const Component = asChild ? Slot : "div";
const handleMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
onMouseDown?.(event);
if (event.defaultPrevented) return;
const target = event.target as HTMLElement;
if (target.closest("input, button, a, select, textarea")) return;
const input = event.currentTarget.querySelector("input");
if (input && !input.disabled) {
event.preventDefault();
input.focus();
}
};
return (
<Component
ref={ref}
onMouseDown={handleMouseDown}
className={cn("flex w-full cursor-text items-center transition-colors duration-base", sizes.wrapper[size], className)}
{...props}
/>
);
});
Wrapper.displayName = "Input.Wrapper";
export interface InputIconProps extends React.SVGAttributes<SVGElement> {
/** Icon component, e.g. `as={RiSearchLine}`. */
as?: React.ElementType;
}
const Icon = React.forwardRef<SVGElement, InputIconProps>(({ as: Component, className, ...props }, ref) => {
const { size, invalid } = React.useContext(InputContext);
if (!Component) return null;
return (
<Component
ref={ref}
aria-hidden="true"
className={cn(
"flex shrink-0 select-none items-center justify-center transition duration-base",
invalid ? "text-destructive" : "text-muted-foreground group-focus-within/input:text-foreground group-has-[input:not(:placeholder-shown)]/input:text-foreground",
"group-has-[input:disabled]/input:text-muted-foreground/60",
sizes.icon[size],
className
)}
{...props}
/>
);
});
Icon.displayName = "Input.Icon";
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(({ className, type = "text", ...props }, ref) => {
const { invalid } = React.useContext(InputContext);
const controlProps = useFieldControlProps(props, { invalid });
return (
<input
ref={ref}
type={type}
className={cn(
"w-full border-none bg-transparent p-0 size-p-sm text-foreground",
// The wrapper (Input.Root) shows focus; suppress the element's own outline and the forms-plugin ring.
"outline-none focus:outline-none focus:ring-0",
"placeholder:text-muted-foreground placeholder:transition-colors",
"disabled:cursor-not-allowed disabled:text-muted-foreground disabled:placeholder:text-muted-foreground/60",
className
)}
{...controlProps}
/>
);
});
Input.displayName = "Input.Input";
export interface InputAffixProps extends React.HTMLAttributes<HTMLDivElement> {
position?: "start" | "end";
}
const Affix = React.forwardRef<HTMLDivElement, InputAffixProps>(({ className, children, position = "start", ...props }, ref) => {
const { size, invalid } = React.useContext(InputContext);
return (
<div
ref={ref}
className={cn(
"flex shrink-0 select-none items-center justify-center size-p-sm transition duration-base",
invalid ? "text-destructive" : "text-muted-foreground group-has-[input:not(:placeholder-shown)]/input:text-foreground",
position === "start" ? "border-r border-input" : "border-l border-input",
sizes.affix[size],
className
)}
{...props}
>
{children}
</div>
);
});
Affix.displayName = "Input.Affix";
export { Root, Wrapper, Icon, Input, Affix };
Add the Field component
NumberInput imports ./Field, so add this file next to it
"use client";
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cn } from "@/lib/utils";
import * as Label from "./Label";
/*
* Field wires a label, description and message to one form control:
* Field.Label → <label for={controlId}>
* Field.Description → referenced by aria-describedby
* Field.Message → referenced by aria-describedby (error text when invalid)
* invalid / required / disabled → aria-invalid, required/aria-required, disabled on the control
* Controls (Input, Textarea, Select, Checkbox, Switch, Radio) read the context through
* useFieldControlProps. It is plain React state: it works with native forms and form libraries.
*/
type FieldContextValue = {
controlId: string;
labelId: string;
descriptionId: string;
messageId: string;
invalid: boolean;
required: boolean;
disabled: boolean;
hasDescription: boolean;
hasMessage: boolean;
setHasDescription: (value: boolean) => void;
setHasMessage: (value: boolean) => void;
};
const FieldContext = React.createContext<FieldContextValue | null>(null);
export function useField() {
return React.useContext(FieldContext);
}
/**
* Provided by Fieldset.Root. A native <fieldset disabled> already disables the controls inside it;
* this lets each Field.Root reflect that state (label styling, `disabled` on the control).
*/
export const FieldsetContext = React.createContext<{ disabled: boolean } | null>(null);
export interface FieldRootProps extends React.HTMLAttributes<HTMLDivElement> {
/** Id for the control; generated when omitted. */
id?: string;
invalid?: boolean;
required?: boolean;
disabled?: boolean;
}
const Root = React.forwardRef<HTMLDivElement, FieldRootProps>(({ id, invalid = false, required = false, disabled: disabledProp = false, className, children, ...props }, ref) => {
const fieldset = React.useContext(FieldsetContext);
const disabled = disabledProp || Boolean(fieldset?.disabled);
const generatedId = React.useId();
const controlId = id ?? `field-${generatedId}`;
const [hasDescription, setHasDescription] = React.useState(false);
const [hasMessage, setHasMessage] = React.useState(false);
const value = React.useMemo<FieldContextValue>(
() => ({
controlId,
labelId: `${controlId}-label`,
descriptionId: `${controlId}-description`,
messageId: `${controlId}-message`,
invalid,
required,
disabled,
hasDescription,
hasMessage,
setHasDescription,
setHasMessage,
}),
[controlId, invalid, required, disabled, hasDescription, hasMessage]
);
return (
<FieldContext.Provider value={value}>
<div
ref={ref}
className={cn("flex flex-col gap-1.5", className)}
data-invalid={invalid || undefined}
data-disabled={disabled || undefined}
{...props}
>
{children}
</div>
</FieldContext.Provider>
);
});
Root.displayName = "Field.Root";
type FieldLabelProps = React.ComponentPropsWithoutRef<typeof Label.Root>;
const FieldLabel = React.forwardRef<React.ElementRef<typeof Label.Root>, FieldLabelProps>(({ children, ...props }, ref) => {
const field = useField();
return (
<Label.Root ref={ref} id={field?.labelId} htmlFor={field?.controlId} disabled={field?.disabled} {...props}>
{children}
{field?.required && <Label.Asterisk />}
</Label.Root>
);
});
FieldLabel.displayName = "Field.Label";
const Description = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(({ className, ...props }, ref) => {
const field = useField();
const setHasDescription = field?.setHasDescription;
React.useEffect(() => {
if (!setHasDescription) return;
setHasDescription(true);
return () => setHasDescription(false);
}, [setHasDescription]);
return <p ref={ref} id={field?.descriptionId} className={cn("size-p-xs text-muted-foreground", className)} {...props} />;
});
Description.displayName = "Field.Description";
export interface FieldMessageProps extends React.HTMLAttributes<HTMLParagraphElement> {
/** Only render while the field is invalid (default true). */
showWhenValid?: boolean;
}
const Message = React.forwardRef<HTMLParagraphElement, FieldMessageProps>(({ className, showWhenValid = false, children, ...props }, ref) => {
const field = useField();
const visible = Boolean(children) && (showWhenValid || !field || field.invalid);
const setHasMessage = field?.setHasMessage;
React.useEffect(() => {
if (!setHasMessage || !visible) return;
setHasMessage(true);
return () => setHasMessage(false);
}, [setHasMessage, visible]);
if (!visible) return null;
return (
<p
ref={ref}
id={field?.messageId}
aria-live="polite"
className={cn("size-p-xs", field?.invalid ? "text-destructive-strong" : "text-muted-foreground", className)}
{...props}
>
{children}
</p>
);
});
Message.displayName = "Field.Message";
type ControlAttributes = {
id?: string;
"aria-describedby"?: string;
"aria-invalid"?: React.AriaAttributes["aria-invalid"];
"aria-required"?: React.AriaAttributes["aria-required"];
required?: boolean;
disabled?: boolean;
};
/**
* Merges Field context into a control's props. Explicit props win; describedby ids are combined.
*/
export function useFieldControlProps<P extends ControlAttributes>(props: P, options: { invalid?: boolean } = {}): P & ControlAttributes {
const field = useField();
const invalid = Boolean(options.invalid || field?.invalid || props["aria-invalid"] === true || props["aria-invalid"] === "true");
const describedBy = [props["aria-describedby"], field?.hasDescription ? field.descriptionId : undefined, field?.hasMessage ? field.messageId : undefined]
.filter(Boolean)
.join(" ");
return {
...props,
id: props.id ?? field?.controlId,
"aria-describedby": describedBy || undefined,
"aria-invalid": invalid || undefined,
required: props.required ?? (field?.required || undefined),
disabled: props.disabled ?? (field?.disabled || undefined),
};
}
/** Applies Field wiring to any custom control element (single child). */
const Control = React.forwardRef<HTMLElement, React.HTMLAttributes<HTMLElement> & ControlAttributes>((props, ref) => {
const controlProps = useFieldControlProps(props);
return <Slot ref={ref} {...controlProps} />;
});
Control.displayName = "Field.Control";
export { Root, FieldLabel as Label, Description, Message, Control };
Add the Label component
NumberInput imports ./Label, so add this file next to it
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@/lib/utils";
export interface LabelRootProps extends React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> {
/** Dims the label. The control itself must also be disabled. */
disabled?: boolean;
}
const Root = React.forwardRef<React.ElementRef<typeof LabelPrimitive.Root>, LabelRootProps>(({ className, disabled, ...props }, ref) => {
return (
<LabelPrimitive.Root
ref={ref}
data-disabled={disabled || undefined}
className={cn(
"group flex cursor-pointer items-center gap-px size-p-sm text-foreground",
"data-[disabled]:pointer-events-none data-[disabled]:text-muted-foreground/60",
className
)}
{...props}
/>
);
});
Root.displayName = "Label.Root";
// Visual required marker. Screen readers get "required" from the control (required / aria-required).
const Asterisk = React.forwardRef<HTMLSpanElement, React.HTMLAttributes<HTMLSpanElement>>(({ className, children, ...props }, ref) => (
<span ref={ref} aria-hidden="true" className={cn("text-primary-strong group-data-[disabled]:text-muted-foreground/60", className)} {...props}>
{children || "*"}
</span>
));
Asterisk.displayName = "Label.Asterisk";
const Sub = React.forwardRef<HTMLSpanElement, React.HTMLAttributes<HTMLSpanElement>>(({ className, ...props }, ref) => (
<span ref={ref} className={cn("size-p-xs text-muted-foreground group-data-[disabled]:text-muted-foreground/60", className)} {...props} />
));
Sub.displayName = "Label.Sub";
export { Root, Asterisk, Sub };
Create or update your NumberInput component
Copy this code to create your NumberInput component
"use client";
import * as React from "react";
import { RiAddLine, RiSubtractLine } from "react-icons/ri";
import { cn } from "@/lib/utils";
import { useField } from "./Field";
import * as Input from "./Input";
/*
* A numeric field with spinbutton semantics (role="spinbutton", aria-valuenow/min/max), built on Input.
* Typing is never blocked: "", "-", "1." are allowed while editing; the value is clamped to min/max and
* normalised when the field is left or Enter is pressed. Keyboard: ArrowUp/Down by `step`, PageUp/PageDown by
* `largeStep`, Home/End to min/max (when set). The − / + buttons are for pointer users (not tab stops).
* Plain, locale-independent numbers ("1234.5"); no grouping separators are parsed.
*/
export interface NumberInputProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "defaultValue" | "onChange" | "size" | "min" | "max" | "step"> {
value?: number | null;
defaultValue?: number | null;
onValueChange?: (value: number | null) => void;
min?: number;
max?: number;
/** Arrow-key and button increment. @default 1 */
step?: number;
/** PageUp/PageDown increment. @default step × 10 */
largeStep?: number;
size?: "sm" | "md" | "lg";
invalid?: boolean;
decrementLabel?: string;
incrementLabel?: string;
/** Classes for the outer Input.Root. */
className?: string;
inputClassName?: string;
}
const decimals = (value: number) => {
const text = String(value);
if (text.includes("e-")) return Number(text.split("e-")[1]);
return text.includes(".") ? text.split(".")[1].length : 0;
};
const clamp = (value: number, min?: number, max?: number) => Math.min(max ?? Infinity, Math.max(min ?? -Infinity, value));
/** Parses plain numbers only; returns undefined for partial input ("-", ".", "1e"). */
export function parseNumber(text: string): number | null | undefined {
const trimmed = text.trim();
if (trimmed === "") return null;
if (!/^[-+]?(\d+\.?\d*|\.\d+)$/.test(trimmed)) return undefined;
const parsed = Number(trimmed);
return Number.isFinite(parsed) ? parsed : undefined;
}
const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
(
{
value: valueProp,
defaultValue = null,
onValueChange,
min,
max,
step = 1,
largeStep,
size = "md",
invalid,
disabled,
readOnly,
decrementLabel = "Decrease",
incrementLabel = "Increase",
className,
inputClassName,
id,
onBlur,
onKeyDown,
...props
},
ref
) => {
const field = useField();
const generatedId = React.useId();
const inputId = id ?? field?.controlId ?? `number-${generatedId}`;
const [uncontrolled, setUncontrolled] = React.useState<number | null>(defaultValue);
const isControlled = valueProp !== undefined;
const value = isControlled ? valueProp : uncontrolled;
const [text, setText] = React.useState(value === null ? "" : String(value));
const textRef = React.useRef(text);
textRef.current = text;
const editingRef = React.useRef(false);
const isDisabled = Boolean(disabled ?? field?.disabled);
const interactive = !isDisabled && !readOnly;
// Follow external value changes unless the user is mid-edit with an equivalent value ("1." vs 1).
React.useEffect(() => {
if (editingRef.current && parseNumber(textRef.current) === value) return;
setText(value === null ? "" : String(value));
}, [value]);
const setValue = (next: number | null) => {
if (!isControlled) setUncontrolled(next);
if (next !== value) onValueChange?.(next);
};
const precision = (base: number) => Math.max(decimals(step), decimals(base));
const stepBy = (amount: number) => {
if (!interactive) return;
const base = value ?? (amount > 0 ? (min ?? 0) - step : (max ?? 0) + step);
const raw = clamp(base + amount, min, max);
const next = Number(raw.toFixed(precision(base)));
setValue(next);
setText(String(next));
};
const commit = () => {
editingRef.current = false;
const parsed = parseNumber(text);
if (parsed === undefined) {
setText(value === null ? "" : String(value));
return;
}
const next = parsed === null ? null : clamp(parsed, min, max);
setValue(next);
setText(next === null ? "" : String(next));
};
const allowNegative = min === undefined || min < 0;
const allowDecimal = !Number.isInteger(step);
const buttonClasses =
"flex h-full w-9 shrink-0 items-center justify-center text-muted-foreground transition-colors duration-fast hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-40";
return (
<Input.Root size={size} invalid={invalid} className={cn("w-auto", className)}>
<button
type="button"
tabIndex={-1}
aria-label={decrementLabel}
aria-controls={inputId}
disabled={!interactive || (value !== null && min !== undefined && value <= min)}
onMouseDown={(event) => event.preventDefault()}
onClick={() => stepBy(-step)}
className={cn(buttonClasses, "border-r border-input")}
>
<RiSubtractLine aria-hidden="true" className="size-4" />
</button>
<Input.Wrapper className="px-2">
<Input.Input
ref={ref}
id={inputId}
role="spinbutton"
inputMode={allowNegative ? "text" : allowDecimal ? "decimal" : "numeric"}
autoComplete="off"
aria-valuenow={value ?? undefined}
aria-valuemin={min}
aria-valuemax={max}
value={text}
disabled={disabled}
readOnly={readOnly}
onChange={(event) => {
editingRef.current = true;
setText(event.target.value);
const parsed = parseNumber(event.target.value);
// Report complete numbers as they are typed; clamping waits for commit.
if (parsed !== undefined) setValue(parsed);
}}
onBlur={(event) => {
commit();
onBlur?.(event);
}}
onKeyDown={(event) => {
onKeyDown?.(event);
if (event.defaultPrevented || !interactive) return;
const big = largeStep ?? step * 10;
const actions: Record<string, () => void> = {
ArrowUp: () => stepBy(step),
ArrowDown: () => stepBy(-step),
PageUp: () => stepBy(big),
PageDown: () => stepBy(-big),
Enter: commit,
};
const jumpTo = (target: number) => {
setValue(target);
setText(String(target));
};
if (min !== undefined) actions.Home = () => jumpTo(min);
if (max !== undefined) actions.End = () => jumpTo(max);
const action = actions[event.key];
if (!action) return;
if (event.key !== "Enter") event.preventDefault();
editingRef.current = false;
action();
}}
className={cn("text-center tabular-nums", inputClassName)}
{...props}
/>
</Input.Wrapper>
<button
type="button"
tabIndex={-1}
aria-label={incrementLabel}
aria-controls={inputId}
disabled={!interactive || (value !== null && max !== undefined && value >= max)}
onMouseDown={(event) => event.preventDefault()}
onClick={() => stepBy(step)}
className={cn(buttonClasses, "border-l border-input")}
>
<RiAddLine aria-hidden="true" className="size-4" />
</button>
</Input.Root>
);
}
);
NumberInput.displayName = "NumberInput.Root";
export { NumberInput as Root };
Test Your Component
- 1. Add the component to a test page
- 2. Check all props and variants work as expected
- 3. Verify responsive behavior
- 4. Test with different content/data
- 5. Ensure accessibility features work correctly
Percentage
Controlled value with a step of 5 and a large step of 25 for PageUp/PageDown.
Arrows: ±5 · PageUp/PageDown: ±25
Decimals and Read-only
Decimal steps are rounded to the step's precision (no 0.30000000000000004). readOnly keeps the value visible and focusable but unchangeable.
−20 to 40, in 0.5° steps. Value: -2.5
Typing and Parsing
- Partial input such as "-", "." or "1." is allowed while typing; complete numbers are reported as you type.
- On blur or Enter the value is clamped to min/max; text that is not a number reverts to the last valid value; an empty field becomes null.
- Only plain numbers are parsed ("1234.5"). Grouping separators and exponents ("1,000", "1e3") are not, so there are no locale surprises.
Accessibility
- The input has
role="spinbutton"witharia-valuenow,aria-valueminandaria-valuemax, and is labelled by Field.Label. - ArrowUp/ArrowDown change by step, PageUp/PageDown by the large step, Home/End go to min/max when they are set.
- The − / + buttons are labelled ("Decrease", "Increase"), disabled at the limits and kept out of the tab order since the keyboard already steps.
- The mobile keyboard follows the range: numeric for whole positive numbers, decimal for decimal steps, full keyboard when negative values are allowed (many numeric keypads have no minus key).
API Reference
Import with import * as NumberInput from "@/components/ui/NumberInput". Other input attributes go to the input; ref points at it.
NumberInput.Root
| Prop | Type | Default | Description |
|---|---|---|---|
value / defaultValue | "number | null" | "null" | The number; null when empty. |
onValueChange | "(value: number | null) => void" | "undefined" | Complete numbers while typing, final value on commit. |
min / max | "number" | "undefined" | Limits (clamped on commit and when stepping). |
step | "number" | "1" | Arrow-key and button increment. |
largeStep | "number" | "step × 10" | PageUp/PageDown increment. |
readOnly / disabled | "boolean" | "false" | Prevent changes; disabled also reads Field. |
invalid | "boolean" | "from Field" | Error state. |
decrementLabel / incrementLabel | "string" | "Decrease / Increase" | Button names. |
size | "sm""md""lg" | "md" | Height. |
className | "string" | "undefined" | Classes for the outer Input.Root (e.g. width). |
parseNumber(text)
The parser NumberInput uses: a number, null for empty text, undefined for partial or invalid text.
| Prop | Type | Default | Description |
|---|---|---|---|
text | "string" | "-" | Raw input text. |
Used in these blocks
Block families with at least one block built on this component. Each page previews its blocks and lists the components and packages they use.