Number Field
A shadcn-style number field component built with Ark UI primitives.
import {
NumberFieldInput,
NumberFieldRoot,
} from "@/components/ui/number-field";
const NumberFieldDemo = () => (
<NumberFieldRoot defaultValue="5">
<NumberFieldInput />
</NumberFieldRoot>
);
export default NumberFieldDemo;
Installation
npx shadcn@latest add @ark-cn/number-fieldInstall the dependency required by this primitive:
npm install @ark-ui/react lucide-reactCopy the component source into your app:
TSXcomponents/ui/number-field.tsx
"use client";
import { NumberInput as NumberFieldPrimitive } from "@ark-ui/react/number-input";
import {
ChevronDownIcon,
ChevronUpIcon,
MinusIcon,
PlusIcon,
} from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import { Button } from "@/components/ui/button";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group";
import { cn } from "@/lib/utils";
export type NumberFieldRootProps = NumberFieldPrimitive.RootProps;
export const NumberFieldRoot = ({ ...props }: NumberFieldRootProps) => (
<NumberFieldPrimitive.Root data-slot="number-field" {...props} />
);
export type NumberFieldLabelProps = NumberFieldPrimitive.LabelProps;
export const NumberFieldLabel = ({
className,
...props
}: NumberFieldLabelProps) => (
<NumberFieldPrimitive.Label
className={cn(
"text-sm font-medium text-foreground leading-none select-none",
className,
)}
data-slot="number-field-label"
{...props}
/>
);
export type NumberFieldIncrementTriggerProps =
NumberFieldPrimitive.IncrementTriggerProps;
export const NumberFieldIncrementTrigger = ({
...props
}: NumberFieldIncrementTriggerProps) => (
<NumberFieldPrimitive.IncrementTrigger
data-slot="number-field-increment-trigger"
{...props}
/>
);
export type NumberFieldDecrementTriggerProps =
NumberFieldPrimitive.DecrementTriggerProps;
export const NumberFieldDecrementTrigger = ({
...props
}: NumberFieldDecrementTriggerProps) => (
<NumberFieldPrimitive.DecrementTrigger
data-slot="number-field-decrement-trigger"
{...props}
/>
);
export type NumberFieldControlProps = NumberFieldPrimitive.ControlProps;
export const NumberFieldControl = ({ ...props }: NumberFieldControlProps) => (
<NumberFieldPrimitive.Control data-slot="number-field-control" {...props} />
);
export type NumberFieldInputProps = {
className?: string;
inputClassName?: string;
inputProps?: ComponentProps<typeof NumberFieldPrimitive.Input>;
controlProps?: ComponentProps<typeof NumberFieldPrimitive.Control>;
size?: number | "sm" | "lg" | "default";
withoutControl?: boolean;
showTrigger?: boolean;
triggerVariant?: "between" | "end";
startAddon?: ReactNode;
endAddon?: ReactNode;
};
export const NumberFieldInput = ({
className,
inputClassName,
inputProps,
controlProps,
size = "default",
withoutControl = false,
showTrigger = true,
triggerVariant = "end",
startAddon,
endAddon,
}: NumberFieldInputProps) => {
const NumberInputGroup = () => {
return (
<InputGroup className={className}>
{((showTrigger && triggerVariant === "between") || startAddon) && (
<InputGroupAddon align="inline-start">
{showTrigger && triggerVariant === "between" && (
<NumberFieldDecrementTrigger asChild>
<Button size="icon-xs" type="button" variant="ghost">
<MinusIcon aria-hidden />
</Button>
</NumberFieldDecrementTrigger>
)}
{startAddon}
</InputGroupAddon>
)}
<NumberFieldPrimitive.Input asChild {...inputProps}>
<InputGroupInput size={size} className={inputClassName} />
</NumberFieldPrimitive.Input>
{(showTrigger || endAddon) && (
<InputGroupAddon
align="inline-end"
className={cn(
showTrigger &&
triggerVariant === "end" &&
"flex-col gap-0 border-border border-s p-0 [[data-size=sm]+&]:pe-0!",
)}
>
{endAddon}
{showTrigger && triggerVariant === "end" && (
<div className="flex flex-col divide-y divide-border">
<NumberFieldIncrementTrigger asChild>
<Button
className="size-4 w-6! shrink-0 rounded-none sm:size-4"
size="icon-xs"
type="button"
variant="ghost"
>
<ChevronUpIcon aria-hidden />
</Button>
</NumberFieldIncrementTrigger>
<NumberFieldDecrementTrigger asChild>
<Button
className="size-4 w-6! shrink-0 rounded-none sm:size-4"
size="icon-xs"
type="button"
variant="ghost"
>
<ChevronDownIcon aria-hidden />
</Button>
</NumberFieldDecrementTrigger>
</div>
)}
{showTrigger && triggerVariant === "between" && (
<NumberFieldIncrementTrigger asChild>
<Button size="icon-xs" type="button" variant="ghost">
<PlusIcon aria-hidden />
</Button>
</NumberFieldIncrementTrigger>
)}
</InputGroupAddon>
)}
</InputGroup>
);
};
if (withoutControl) return <NumberInputGroup />;
return (
<NumberFieldPrimitive.Control asChild {...controlProps}>
<NumberInputGroup />
</NumberFieldPrimitive.Control>
);
};
export type NumberFieldScrubberProps = NumberFieldPrimitive.ScrubberProps;
export const NumberFieldScrubber = ({ ...props }: NumberFieldScrubberProps) => (
<NumberFieldPrimitive.Scrubber data-slot="number-field-scrubber" {...props} />
);
export type NumberFieldValueTextProps = NumberFieldPrimitive.ValueTextProps;
export const NumberFieldValueText = ({
className,
...props
}: NumberFieldValueTextProps) => (
<NumberFieldPrimitive.ValueText
className={cn(
"font-variant-numeric text-foreground tabular-nums",
className,
)}
data-slot="number-field-value-text"
{...props}
/>
);
export type NumberFieldContextProps = NumberFieldPrimitive.ContextProps;
export const NumberFieldContext = (props: NumberFieldContextProps) => (
<NumberFieldPrimitive.Context {...props} />
);
export type NumberFieldRootProviderProps =
NumberFieldPrimitive.RootProviderProps;
export const NumberFieldRootProvider = (
props: NumberFieldRootProviderProps,
) => (
<NumberFieldPrimitive.RootProvider
data-slot="number-field-root-provider"
{...props}
/>
);
export {
useNumberInput,
useNumberInputContext,
} from "@ark-ui/react/number-input";
Update import aliases to match your project setup.
Usage
import * as NumberField from "@/components/ui/number-field"Read exported parts in src/components/ui/number-field.tsx and compose the primitive according to the Ark UI pattern for this component.
Examples
Step
import {
NumberFieldInput,
NumberFieldRoot,
} from "@/components/ui/number-field";
const NumberFieldStepDemo = () => (
<div className="flex flex-col gap-2">
<NumberFieldRoot defaultValue="5" step={10}>
<NumberFieldInput />
</NumberFieldRoot>
<NumberFieldRoot defaultValue="0.5" step={0.1}>
<NumberFieldInput />
</NumberFieldRoot>
<NumberFieldRoot defaultValue="0.6" step={0.2}>
<NumberFieldInput />
</NumberFieldRoot>
</div>
);
export default NumberFieldStepDemo;
Variant
import {
NumberFieldInput,
NumberFieldLabel,
NumberFieldRoot,
} from "@/components/ui/number-field";
const NumberFieldVariantDemo = () => (
<div className="flex flex-col gap-2">
<NumberFieldRoot>
<NumberFieldLabel>Number field (End)</NumberFieldLabel>
<NumberFieldInput triggerVariant="end" />
</NumberFieldRoot>
<NumberFieldRoot>
<NumberFieldLabel>Number field (Between)</NumberFieldLabel>
<NumberFieldInput triggerVariant="between" />
</NumberFieldRoot>
</div>
);
export default NumberFieldVariantDemo;
Scrubber
import { ArrowLeftRightIcon } from "lucide-react";
import {
NumberFieldInput,
NumberFieldRoot,
NumberFieldScrubber,
} from "@/components/ui/number-field";
const NumberFieldDemo = () => (
<NumberFieldRoot defaultValue="5" max={10} min={0}>
<NumberFieldInput
startAddon={
<NumberFieldScrubber>
<ArrowLeftRightIcon className="size-4" />
</NumberFieldScrubber>
}
/>
</NumberFieldRoot>
);
export default NumberFieldDemo;
Label
import {
NumberFieldInput,
NumberFieldLabel,
NumberFieldRoot,
} from "@/components/ui/number-field";
const NumberFieldLabelDemo = () => (
<NumberFieldRoot defaultValue="5" max={10} min={0}>
<NumberFieldLabel>Number</NumberFieldLabel>
<NumberFieldInput />
</NumberFieldRoot>
);
export default NumberFieldLabelDemo;
Disabled
import {
NumberFieldInput,
NumberFieldRoot,
} from "@/components/ui/number-field";
const NumberFieldDisabledDemo = () => (
<NumberFieldRoot defaultValue="5" disabled>
<NumberFieldInput />
</NumberFieldRoot>
);
export default NumberFieldDisabledDemo;
Format
import {
NumberFieldInput,
NumberFieldRoot,
} from "@/components/ui/number-field";
const NumberFieldFormatDemo = () => (
<NumberFieldRoot
defaultValue="5"
formatOptions={{
style: "currency",
currency: "USD",
}}
>
<NumberFieldInput />
</NumberFieldRoot>
);
export default NumberFieldFormatDemo;
API reference
This component mirrors the upstream Ark UI primitive. All props and DOM behavior are defined by Ark unless you see an ark-cn-only row below.
NumberFieldInput
| Prop | Type | Description |
|---|---|---|
| size? | "sm" | "default" | "lg" | Input group size. |
| inputClassName? | string | Class on the inner input element. |
| inputProps? | NumberFieldInputProps | Forwarded to the underlying NumberField.Input. |
| controlProps? | NumberFieldControlProps | Forwarded to the wrapping Control. |
| withoutControl? | boolean | Renders only the input group without the wrapping Control. |
| showTrigger? | boolean | Shows increment/decrement UI. |
| triggerVariant? | "between" | "end" | Placement of +/− controls. |
| startAddon? | ReactNode | Slot before the input. |
| endAddon? | ReactNode | Slot after the input. |
See the ARK UI documentation for the full API.
Accessibility
See the Ark UI documentation for clarification.