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 https://shuip.plvo.dev/r/rhf-date-field.json
pnpm dlx shadcn@latest add https://shuip.plvo.dev/r/rhf-date-field.json
bun x shadcn@latest add https://shuip.plvo.dev/r/rhf-date-field.json
'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 asChild>
<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' />
</Button>
</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>
);
}
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 | undefinedundefined 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 { Form } from '@/components/ui/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 {...form}>
  <form onSubmit={form.handleSubmit(onSubmit)}>
    <DateField lens={lens.focus('dueDate')} label='Due date' />
  </form>
</Form>

The <Form> wrapper is required — it provides React Hook Form's FormProvider context.

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 { Form } from '@/components/ui/form';
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 Values = z.infer<typeof zodSchema>;
export default function RhfDateFieldExample() {
const form = useForm<Values>({
defaultValues: { dueDate: undefined as unknown as Date },
resolver: zodResolver(zodSchema),
});
const lens = useLens({ control: form.control });
async function onSubmit(values: Values) {
alert(`Due date: ${values.dueDate.toISOString()}`);
}
return (
<Form {...form}>
<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>
</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 { 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 Values = z.infer<typeof zodSchema>;
export default function RhfDateFieldValidationExample() {
const form = useForm<Values>({
defaultValues: { appointment: undefined as unknown as Date },
resolver: zodResolver(zodSchema),
});
const lens = useLens({ control: form.control });
async function onSubmit(values: Values) {
alert(`Appointment: ${values.appointment.toLocaleDateString()}`);
}
return (
<Form {...form}>
<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>
</Form>
);
}

Props

Prop

Type

On this page