Number Field

Numeric input component integrated with React Hook Form via typed lens binding from @hookform/lenses. Writes back valueAsNumber, supports range sliders and tooltips.

npx shadcn@latest add https://shuip.plvo.dev/r/rhf-number-field.json
pnpm dlx shadcn@latest add https://shuip.plvo.dev/r/rhf-number-field.json
bun x shadcn@latest add https://shuip.plvo.dev/r/rhf-number-field.json
'use client';
import type { Lens } from '@hookform/lenses';
import { InfoIcon } from 'lucide-react';
import type * as React from 'react';
import { useController } from 'react-hook-form';
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field';
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '@/components/ui/input-group';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
export interface NumberFieldProps
extends Omit<React.ComponentProps<typeof InputGroupInput>, 'value' | 'onChange' | 'type'> {
lens: Lens<number>;
label?: string;
description?: string;
tooltip?: React.ReactNode;
type?: 'number' | 'range';
}
export function NumberField({ lens, label, description, tooltip, type = 'number', ...props }: NumberFieldProps) {
const { field, fieldState } = useController(lens.interop());
const id = props.id ?? field.name;
const value = field.value == null || Number.isNaN(field.value) ? '' : field.value;
return (
<Field className='gap-2' data-invalid={fieldState.invalid}>
{label && <FieldLabel htmlFor={id}>{label}</FieldLabel>}
<InputGroup>
<InputGroupInput
{...field}
{...props}
id={id}
type={type}
value={value}
onChange={(e) => field.onChange(e.target.valueAsNumber)}
aria-invalid={fieldState.invalid}
/>
{tooltip && (
<InputGroupAddon align='inline-end'>
<Tooltip>
<TooltipTrigger asChild>
<InputGroupButton aria-label='Info' size='icon-xs'>
<InfoIcon />
</InputGroupButton>
</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
</InputGroupAddon>
)}
</InputGroup>
{fieldState.invalid && <FieldError className='text-xs text-left' errors={[fieldState.error]} />}
{description && <FieldDescription className='text-xs'>{description}</FieldDescription>}
</Field>
);
}
Loading...

NumberField binds a numeric form field (Lens<number>) to a type='number' or type='range' input. The input writes back e.target.valueAsNumber so the form state stays correctly typed as number. Empty input becomes NaN, which validators can check with Number.isNaN(value).

For text fields, use InputField instead.

Built-in features

  • Typed lens binding: lens.focus('quantity') requires a numeric form field — typing is enforced
  • valueAsNumber write-back: form state stays number, not string
  • Range slider support: pass type='range' to render a slider
  • Tooltip integration: optional InfoIcon button with tooltip content via tooltip prop
  • InputGroup ready: built on shadcn InputGroup for seamless addon integration
  • Zod validation: native integration with react-hook-form and Zod via resolver

Setup

import { useLens } from '@hookform/lenses';
import { useForm } from 'react-hook-form';
import { Form } from '@/components/ui/form';
import { NumberField } from '@/components/ui/shuip/react-hook-form/number-field';

const form = useForm<{ quantity: number; ratio: number }>({
  defaultValues: { quantity: 1, ratio: 0.5 },
});
const lens = useLens({ control: form.control });

<Form {...form}>
  <form onSubmit={form.handleSubmit(onSubmit)}>
    <NumberField lens={lens.focus('quantity')} label='Quantity' />
    <NumberField lens={lens.focus('ratio')} type='range' label='Ratio' min={0} max={1} step={0.1} />
  </form>
</Form>

Examples

Default

Loading...
'use client';
import { useLens } from '@hookform/lenses';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Form } from '@/components/ui/form';
import { NumberField } from '@/components/ui/shuip/react-hook-form/number-field';
import { SubmitButton } from '@/components/ui/shuip/submit-button';
const zodSchema = z.object({
quantity: z.number().min(1, 'Must be at least 1'),
ratio: z.number().min(0).max(1),
});
type Values = z.infer<typeof zodSchema>;
export default function RhfNumberFieldExample() {
const form = useForm<Values>({
defaultValues: { quantity: 1, ratio: 0.5 },
resolver: zodResolver(zodSchema),
});
const lens = useLens({ control: form.control });
async function onSubmit(values: Values) {
try {
alert(`Quantity: ${values.quantity}\nRatio: ${values.ratio}`);
} catch (error) {
console.error(error);
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
<NumberField lens={lens.focus('quantity')} label='Quantity' description='Integer quantity' placeholder='1' />
<NumberField
lens={lens.focus('ratio')}
type='range'
label='Ratio'
description='Rendered as a range slider'
min={0}
max={1}
step={0.1}
/>
<SubmitButton>Submit</SubmitButton>
</form>
</Form>
);
}

Props

Prop

Type

On this page