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>
<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' />
</Button>
</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>
);
}
Loading...

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 / maxDate translate to the Calendar disabled matcher
  • Locale aware: optional date-fns Locale passed 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>" using date-fns format(date, 'PPP', { locale })
  • only from set → just the from date
  • neither set → the placeholder prop

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

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 { 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'>
<DateRangeField
lens={lens.focus('range')}
label='Stay'
description='Pick check-in and check-out dates'
placeholder='Pick a date range'
/>
<SubmitButton>Save</SubmitButton>
</form>
</Form>
);
}

Validation

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 { 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'>
<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>
</Form>
);
}

Props

Prop

Type

On this page