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 @shuip/rhf-date-range-field
pnpm dlx shadcn@latest add @shuip/rhf-date-range-field
bun x shadcn@latest add @shuip/rhf-date-range-field
'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, Matcher } 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;
}
export function dateBoundsMatcher(minDate?: Date, maxDate?: Date): Matcher[] | undefined {
if (!minDate && !maxDate) return undefined;
return [...(minDate ? [{ before: minDate }] : []), ...(maxDate ? [{ after: maxDate }] : [])];
}
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(() => dateBoundsMatcher(minDate, 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
render={
<Button
id={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' />
</PopoverTrigger>
<PopoverContent className='w-auto p-0' align='start'>
<Calendar
mode='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>
);
}
The command is the same for Base UI and Radix: the CLI reads the style in your components.json and installs the matching variant.
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 { 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 onSubmit={form.handleSubmit(onSubmit)}>
<DateRangeField lens={lens.focus('stay')} label='Stay' />
</form>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 { 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({
// DateRange declares `from` as a required key that may hold undefined.
from: z.date().or(z.undefined()),
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 onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
<DateRangeField
lens={lens.focus('range')}
label='Stay'
description='Pick check-in and check-out dates'
placeholder='Pick a date range'
/>
<SubmitButton>Save</SubmitButton>
</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 { 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({
// DateRange declares `from` as a required key that may hold undefined.
from: z.date().or(z.undefined()),
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 onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
<DateRangeField
lens={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>
);
}
Props
Prop
Type