Date Field

Single-date picker integrated with React Hook Form via typed lens binding from @hookform/lenses. Wraps the shadcn Calendar inside a Popover triggered by an outline Button.

npx shadcn@latest add @shuip/rhf-date-field
pnpm dlx shadcn@latest add @shuip/rhf-date-field
bun x shadcn@latest add @shuip/rhf-date-field
'use client';

import type { Lens } from '@hookform/lenses';
import { format } from 'date-fns';
import type { Locale } from 'date-fns/locale';
import { enUS } from 'date-fns/locale';
import { CalendarIcon } from 'lucide-react';
import * as React from 'react';
import type { 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 DateFieldProps {
  lens: Lens<Date | undefined>;
  label?: string;
  description?: string;
  placeholder?: string;
  minDate?: Date;
  maxDate?: Date;
  locale?: Locale;
  disabled?: boolean;
  dateFormat?: string;
  triggerProps?: React.ComponentProps<typeof Button>;
}

export function DateField({
  lens,
  label,
  description,
  placeholder = 'Pick a date',
  minDate,
  maxDate,
  locale = enUS,
  disabled,
  dateFormat = 'PPP',
  triggerProps,
}: DateFieldProps) {
  const { field, fieldState } = useController(lens.interop());
  const [open, setOpen] = React.useState(false);

  const value = field.value as Date | undefined;
  const disabledMatchers: Matcher[] = [];
  if (minDate) disabledMatchers.push({ before: minDate });
  if (maxDate) disabledMatchers.push({ after: maxDate });

  return (
    <Field className='gap-2' data-invalid={fieldState.invalid}>
      {label && <FieldLabel htmlFor={field.name}>{label}</FieldLabel>}
      <Popover open={open} onOpenChange={setOpen}>
        <PopoverTrigger
          render={
            <Button
              type='button'
              id={field.name}
              variant='outline'
              disabled={disabled}
              aria-invalid={fieldState.invalid}
              onBlur={field.onBlur}
              {...triggerProps}
              className={cn(
                'w-full justify-between font-normal',
                !value && 'text-muted-foreground',
                triggerProps?.className,
              )}
            />
          }
        >
          <span>{value ? format(value, dateFormat, { locale }) : placeholder}</span>
          <CalendarIcon className='size-4 opacity-50' />
        </PopoverTrigger>
        <PopoverContent className='w-auto p-0' align='start'>
          <Calendar
            mode='single'
            selected={value}
            onSelect={(date) => {
              field.onChange(date);
              setOpen(false);
            }}
            disabled={disabledMatchers.length > 0 ? disabledMatchers : undefined}
            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...

DateField is a single-date picker that encapsulates React Hook Form's field management with the shadcn Calendar primitive. The trigger is an outline Button (full-width) that shows the selected date formatted via date-fns; clicking it opens a Popover containing the calendar.

The field binds to the form via a typed lens from @hookform/lenses. The value type is Date | undefined — undefined represents an empty selection (matching react-day-picker's convention).

Built-in features

  • Typed lens binding: lens.focus('dueDate') — Lens<Date | undefined> is enforced at compile time
  • Locale-aware formatting: defaults to en-US; pass any date-fns/locale via the locale prop
  • Bounded selection: minDate / maxDate disable days outside the range in the calendar UI
  • Accessible trigger: a real <Button> with aria-invalid reflecting fieldState.invalid
  • Zod validation: integrates with z.date().min(...).max(...) via the standard resolver

Setup

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

const form = useForm<{ dueDate: Date | undefined }>({
  defaultValues: { dueDate: undefined },
});
const lens = useLens({ control: form.control });

<form onSubmit={form.handleSubmit(onSubmit)}>
  <DateField lens={lens.focus('dueDate')} label='Due date' />
</form>

Bounded selection

Use minDate and maxDate to disable out-of-range days directly in the calendar:

<DateField
  lens={lens.focus('appointment')}
  label='Appointment'
  minDate={new Date()}
  maxDate={oneYearFromNow}
/>

Disabled days remain visible but cannot be selected. Validate the same bounds in your schema so submission also rejects out-of-range values.

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 { DateField } from '@/components/ui/shuip/react-hook-form/date-field';
import { SubmitButton } from '@/components/ui/shuip/submit-button';

const zodSchema = z.object({
  dueDate: z.date({ message: 'Pick a due date' }),
});

type FormValues = { dueDate: Date | undefined };
type SubmitValues = z.infer<typeof zodSchema>;

export default function RhfDateFieldExample() {
  const form = useForm<FormValues, unknown, SubmitValues>({
    defaultValues: { dueDate: undefined },
    resolver: zodResolver<FormValues, unknown, SubmitValues>(zodSchema),
  });
  const lens = useLens({ control: form.control });

  async function onSubmit(values: SubmitValues) {
    alert(`Due date: ${values.dueDate.toISOString()}`);
  }

  return (
    <form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
      <DateField lens={lens.focus('dueDate')} label='Due date' description='When should this task be done?' />
      <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 { DateField } from '@/components/ui/shuip/react-hook-form/date-field';
import { SubmitButton } from '@/components/ui/shuip/submit-button';

const today = new Date();
today.setHours(0, 0, 0, 0);

const oneYearFromNow = new Date(today);
oneYearFromNow.setFullYear(oneYearFromNow.getFullYear() + 1);

const zodSchema = z.object({
  appointment: z
    .date({ message: 'Pick an appointment date' })
    .min(today, 'Appointment must be in the future')
    .max(oneYearFromNow, 'Appointment must be within a year'),
});

type FormValues = { appointment: Date | undefined };
type SubmitValues = z.infer<typeof zodSchema>;

export default function RhfDateFieldValidationExample() {
  const form = useForm<FormValues, unknown, SubmitValues>({
    defaultValues: { appointment: undefined },
    resolver: zodResolver<FormValues, unknown, SubmitValues>(zodSchema),
  });
  const lens = useLens({ control: form.control });

  async function onSubmit(values: SubmitValues) {
    alert(`Appointment: ${values.appointment.toLocaleDateString()}`);
  }

  return (
    <form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
      <DateField
        lens={lens.focus('appointment')}
        label='Appointment'
        description='Within the next 12 months'
        minDate={today}
        maxDate={oneYearFromNow}
      />
      <SubmitButton>Book</SubmitButton>
    </form>
  );
}

Props

Prop

Type

On this page