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.

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 { 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>" 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 { 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

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 { 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

On this page