Primitives
Number Field
Numeric input with stepper buttons and optional drag-to-change behavior.
Composition
Anatomy
- <NumberField>
- <NumberFieldScrubArea />
- <NumberFieldGroup>
- <NumberFieldDecrement />
- <NumberFieldInput />
- <NumberFieldIncrement />
Installation
First install and activate one skin. Core deliberately contains no visual token defaults.
The Control UI source installs this primitive from src/registry/sources/control-ui/ui/number-field.tsx.
npx shadcn@latest add https://control-ui.dev/r/number-field.jsonDependencies
Control variants
Input groupsrc/registry/sources/control-ui/control-variants.tsSupportinput-groupPrimitiveRaw code
Primary installed source
"use client";import { NumberField as NumberFieldPrimitive } from "@base-ui/react/number-field";import type { ComponentProps, CSSProperties, ReactNode, Ref } from "react";import { createContext, useContext } from "react";import type { ControlSize } from "@/components/control-ui/control-variants";import type { FieldKnobStyle } from "@/components/control-ui/knob-contracts/field-knobs";import { cn } from "@/components/control-ui/lib/cn";import { useIsInsideInputGroup } from "@/components/control-ui/ui/input-group";export type NumberFieldChangeReason = | "input-change" | "input-clear" | "input-blur" | "input-paste" | "keyboard" | "increment-press" | "decrement-press" | "wheel" | "scrub" | "none";export type NumberFieldChangeEventDetails = { reason: NumberFieldChangeReason; event: Event; cancel?: () => void; allowPropagation?: () => void; isCanceled?: boolean; isPropagationAllowed?: boolean; trigger?: Element | undefined;};export type NumberFieldProps = { size?: ControlSize; value?: number | null; defaultValue?: number; onValueChange?: (value: number | null, eventDetails: NumberFieldChangeEventDetails) => void; onValueCommitted?: (value: number | null, eventDetails: NumberFieldChangeEventDetails) => void; min?: number; max?: number; step?: number; smallStep?: number; largeStep?: number; snapOnStep?: boolean; allowOutOfRange?: boolean; allowWheelScrub?: boolean; format?: Intl.NumberFormatOptions; locale?: Intl.LocalesArgument; disabled?: boolean; readOnly?: boolean; required?: boolean; name?: string; form?: string; id?: string; inputRef?: Ref<HTMLInputElement>; className?: string; style?: CSSProperties & FieldKnobStyle; children?: ReactNode;};export type NumberFieldGroupProps = Omit<ComponentProps<"div">, "style"> & { style?: CSSProperties & FieldKnobStyle };export type NumberFieldInputProps = Omit<ComponentProps<"input">, "style"> & { style?: CSSProperties & FieldKnobStyle };export type NumberFieldIncrementProps = ComponentProps<"button"> & { nativeButton?: boolean } & { style?: CSSProperties & FieldKnobStyle };export type NumberFieldDecrementProps = ComponentProps<"button"> & { nativeButton?: boolean } & { style?: CSSProperties & FieldKnobStyle };export type NumberFieldScrubAreaProps = ComponentProps<"span"> & { direction?: "horizontal" | "vertical"; pixelSensitivity?: number; teleportDistance?: number;} & { style?: CSSProperties & FieldKnobStyle };// Group carries surface and Input stays transparent, so stepper and field fuse into one joined segment.// `size` lives on Root and reaches Group through context, so one prop resizes whole unit.const NumberFieldSizeContext = createContext<ControlSize>("md");export function NumberField({ size = "md", value, defaultValue, className, children, ...props }: NumberFieldProps) { return ( <NumberFieldSizeContext.Provider value={size}> {/* explicit, never spread — Base UI decides controlled-ness from value !== undefined on first render */} <NumberFieldPrimitive.Root value={value} defaultValue={defaultValue} className={cn("flex flex-col items-start gap-1.5", className)} {...props} > {children} </NumberFieldPrimitive.Root> </NumberFieldSizeContext.Provider> );}export function NumberFieldGroup({ className, children, ...props }: NumberFieldGroupProps) { const size = useContext(NumberFieldSizeContext); if (useIsInsideInputGroup()) { return ( <NumberFieldPrimitive.Group data-control-ui="number-field" data-field-kind="number-field" data-slot="group" data-control-family="field" className={cn("inline-flex items-stretch overflow-hidden", className)} {...props} > {children} </NumberFieldPrimitive.Group> ); } return ( <NumberFieldPrimitive.Group data-control-ui="number-field" data-field-kind="number-field" data-slot="group" data-control="true" data-control-family="field" data-size={size} className={cn("inline-flex items-stretch overflow-hidden", className)} {...props} > {children} </NumberFieldPrimitive.Group> );}export function NumberFieldInput({ className, ...props }: NumberFieldInputProps) { return ( <NumberFieldPrimitive.Input data-control-ui="number-field" data-control-family="field" data-field-kind="number-field" data-slot="input" className={cn("h-full min-w-0 flex-1 disabled:cursor-not-allowed", className)} {...props} /> );}export function NumberFieldDecrement({ className, children, ...props }: NumberFieldDecrementProps) { return ( <NumberFieldPrimitive.Decrement data-control-ui="number-field" data-control-family="field" data-field-kind="number-field" data-slot="decrement" aria-label="Decrease" className={cn("flex aspect-square h-full shrink-0 cursor-pointer select-none items-center justify-center", className)} {...props} > {children ?? <MinusIcon />} </NumberFieldPrimitive.Decrement> );}export function NumberFieldIncrement({ className, children, ...props }: NumberFieldIncrementProps) { return ( <NumberFieldPrimitive.Increment data-control-ui="number-field" data-control-family="field" data-field-kind="number-field" data-slot="increment" aria-label="Increase" className={cn("flex aspect-square h-full shrink-0 cursor-pointer select-none items-center justify-center", className)} {...props} > {children ?? <PlusIcon />} </NumberFieldPrimitive.Increment> );}// drag-to-change affordance: wrap the field's label, or place inside Group as a prefix icon or suffix unitexport function NumberFieldScrubArea({ className, children, ...props }: NumberFieldScrubAreaProps) { return ( <NumberFieldPrimitive.ScrubArea data-control-ui="number-field" data-control-family="field" data-field-kind="number-field" data-slot="scrub-area" className={cn( "inline-flex shrink-0 cursor-ew-resize touch-pan-y select-none items-center [&>svg]:size-3.5 [&>svg]:shrink-0", className, )} {...props} > {children} <NumberFieldPrimitive.ScrubAreaCursor data-control-ui="number-field" data-control-family="field" data-field-kind="number-field" data-slot="scrub-cursor" > <CursorGrowIcon /> </NumberFieldPrimitive.ScrubAreaCursor> </NumberFieldPrimitive.ScrubArea> );}function MinusIcon() { return ( <svg viewBox="0 0 16 16" className="size-3.5" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true" > <path d="M3 8h10" /> </svg> );}function PlusIcon() { return ( <svg viewBox="0 0 16 16" className="size-3.5" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true" > <path d="M8 3v10M3 8h10" /> </svg> );}function CursorGrowIcon() { return ( <svg data-control-ui="number-field" data-field-kind="number-field" data-control-family="field" data-slot="drag-icon" width="26" height="14" viewBox="0 0 24 14" fill="currentColor" className="block" aria-hidden="true" > <path d="M19.5 5.5 6.5 5.52V2L1 7l5.5 5V8.5h13V12L25 7l-5.5-5v3.5Z" /> </svg> );}Knobs
Typed custom properties the recipe paints with. Set one on the root — style, a utility class, or a skin — and every slot inherits it.
--cui-field-* · 15 knobsHow the cascade resolves--cui-field-radius<length-percentage>var(--nest-radius, var(--radius-control))--cui-field-background<color>var(--control-fill)--cui-field-hover-background<color>var(--hover-fill)--cui-field-affordance-background<color>transparent--cui-field-affordance-shadow*0 0 transparent--cui-field-affordance-foreground<color>var(--muted-foreground)--cui-field-affordance-hover-background<color>var(--hover-fill)--cui-field-foreground<color>var(--foreground)--cui-field-border-color<color>var(--control-rim)--cui-field-border-width<length>var(--control-rim-width)--cui-field-shadow*var(--shadow-inset)--cui-field-backdrop-filter*blur(0px)--cui-field-height<length-percentage>var(--control-h-md)--cui-field-padding-inline<length-percentage>var(--padding-x)--cui-field-font-size<length-percentage>var(--text-body)