Date Range Field
Date range picker integrated with React Hook Form via typed lens binding from @hookform/lenses. Built on the shadcn Calendar primitive in range mode.
npx shadcn@latest add https://shuip.plvo.dev/r/rhf-date-range-field.json
pnpm dlx shadcn@latest add https://shuip.plvo.dev/r/rhf-date-range-field.json
bun x shadcn@latest add https://shuip.plvo.dev/r/rhf-date-range-field.json
'use client';import type { Lens } from '@hookform/lenses';import type { Locale } from 'date-fns';import { format } from 'date-fns';import { CalendarIcon } from 'lucide-react';import * as React from 'react';import type { DateRange } from 'react-day-picker';import { useController } from 'react-hook-form';import { Button } from '@/components/ui/button';import { Calendar } from '@/components/ui/calendar';import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field';import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';import { cn } from '@/lib/utils';export interface DateRangeFieldProps {lens: Lens<DateRange | undefined>;label?: string;description?: string;placeholder?: string;minDate?: Date;maxDate?: Date;locale?: Locale;disabled?: boolean;}function formatRange(value: DateRange | undefined, locale: Locale | undefined): string | null {if (!value?.from) return null;const fromLabel = format(value.from, 'PPP', { locale });if (!value.to) return fromLabel;return `${fromLabel} – ${format(value.to, 'PPP', { locale })}`;}export function DateRangeField({lens,label,description,placeholder = 'Pick a date range',minDate,maxDate,locale,disabled,}: DateRangeFieldProps) {const { field, fieldState } = useController(lens.interop());const value = field.value as DateRange | undefined;const [open, setOpen] = React.useState(false);const disabledMatcher = React.useMemo(() => {if (!minDate && !maxDate) return undefined;return { ...(minDate ? { before: minDate } : {}), ...(maxDate ? { after: maxDate } : {}) };}, [minDate, maxDate]);const triggerLabel = formatRange(value, locale);return (<Field className='gap-2' data-invalid={fieldState.invalid}>{label && <FieldLabel htmlFor={field.name}>{label}</FieldLabel>}<Popover open={open} onOpenChange={setOpen}><PopoverTrigger asChild><Buttonid={field.name}type='button'variant='outline'disabled={disabled}onBlur={field.onBlur}aria-invalid={fieldState.invalid}className={cn('w-full justify-between font-normal', !triggerLabel && 'text-muted-foreground')}><span className='truncate'>{triggerLabel ?? placeholder}</span><CalendarIcon className='size-4 opacity-60' /></Button></PopoverTrigger><PopoverContent className='w-auto p-0' align='start'><Calendarmode='range'selected={value}onSelect={(range) => field.onChange(range)}numberOfMonths={2}defaultMonth={value?.from ?? minDate}disabled={disabledMatcher}locale={locale}autoFocus/></PopoverContent></Popover>{fieldState.invalid && <FieldError className='text-xs text-left' errors={[fieldState.error]} />}{description && <FieldDescription className='text-xs'>{description}</FieldDescription>}</Field>);}
DateRangeField is a from–to date picker bound to React Hook Form. It wraps the shadcn Calendar primitive in mode='range' inside a Popover, and binds via a typed lens from @hookform/lenses — no string paths, no <MyForm> generic at the call site.
The bound value is a DateRange from react-day-picker:
type DateRange = { from?: Date; to?: Date } | undefined;Built-in features
- Typed lens binding:
lens.focus('range')autocompletes from your form's value type - Two-month side-by-side display: canonical UX for picking a range that crosses months
- Bounded selection:
minDate/maxDatetranslate to the Calendardisabledmatcher - Locale aware: optional
date-fnsLocalepassed through to both label formatting and the underlying Calendar
Setup
The field binds via @hookform/lenses. Declare the value as an optional DateRange in your schema, then focus the lens on the range field:
import type { DateRange } from 'react-day-picker';
import { useLens } from '@hookform/lenses';
import { useForm } from 'react-hook-form';
import { Form } from '@/components/ui/form';
import { DateRangeField } from '@/components/ui/shuip/react-hook-form/date-range-field';
type Values = { stay: DateRange | undefined };
const form = useForm<Values>({ defaultValues: { stay: undefined } });
const lens = useLens({ control: form.control });
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<DateRangeField lens={lens.focus('stay')} label='Stay' />
</form>
</Form>The <Form> wrapper is required — it provides React Hook Form's FormProvider context.
Trigger label
The trigger button renders a label derived from the current value:
- both ends set →
"<from> – <to>"usingdate-fnsformat(date, 'PPP', { locale }) - only
fromset → just thefromdate - neither set → the
placeholderprop
Validation
For a required range, pair the field with a Zod schema that enforces both ends and the ordering. See the validation example below.
const schema = z.object({
range: z
.object({
from: z.date({ message: 'Start date is required' }),
to: z.date({ message: 'End date is required' }),
})
.refine((value) => value.to >= value.from, {
message: 'End date must be on or after the start date',
path: ['to'],
}),
});Examples
Default
'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 { DateRangeField } from '@/components/ui/shuip/react-hook-form/date-range-field';import { SubmitButton } from '@/components/ui/shuip/submit-button';const zodSchema = z.object({range: z.object({from: z.date().optional(),to: z.date().optional(),}).optional(),});type Values = z.infer<typeof zodSchema>;export default function RhfDateRangeFieldExample() {const form = useForm<Values>({defaultValues: { range: undefined },resolver: zodResolver(zodSchema),});const lens = useLens({ control: form.control });async function onSubmit(values: Values) {alert(JSON.stringify(values, null, 2));}return (<Form {...form}><form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'><DateRangeFieldlens={lens.focus('range')}label='Stay'description='Pick check-in and check-out dates'placeholder='Pick a date range'/><SubmitButton>Save</SubmitButton></form></Form>);}
Validation
'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 { DateRangeField } from '@/components/ui/shuip/react-hook-form/date-range-field';import { SubmitButton } from '@/components/ui/shuip/submit-button';const zodSchema = z.object({range: z.object({from: z.date().optional(),to: z.date().optional(),}).optional().check((ctx) => {const range = ctx.value;if (!range?.from) {ctx.issues.push({ code: 'custom', message: 'Start date is required', input: range, path: ['from'] });}if (!range?.to) {ctx.issues.push({ code: 'custom', message: 'End date is required', input: range, path: ['to'] });}if (range?.from && range?.to && range.to < range.from) {ctx.issues.push({code: 'custom',message: 'End date must be on or after the start date',input: range,path: ['to'],});}}),});type Values = z.infer<typeof zodSchema>;export default function RhfDateRangeFieldValidationExample() {const form = useForm<Values>({defaultValues: { range: undefined },resolver: zodResolver(zodSchema),});const lens = useLens({ control: form.control });async function onSubmit(values: Values) {alert(JSON.stringify(values, null, 2));}return (<Form {...form}><form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'><DateRangeFieldlens={lens.focus('range')}label='Booking window'description='Both dates are required and end date must be after start date'placeholder='Select start and end dates'minDate={new Date()}/><SubmitButton>Book</SubmitButton></form></Form>);}
Props
Prop
Type