Datetime Field

Single date and time picker integrated with React Hook Form via typed lens binding. Stores a single Date carrying day, hours and minutes.

npx shadcn@latest add https://shuip.plvo.dev/r/rhf-datetime-field.json
pnpm dlx shadcn@latest add https://shuip.plvo.dev/r/rhf-datetime-field.json
bun x shadcn@latest add https://shuip.plvo.dev/r/rhf-datetime-field.json
'use client';
import type { Lens } from '@hookform/lenses';
import type { Locale } from 'date-fns';
import { format, setHours, setMinutes } from 'date-fns';
import { CalendarIcon } from 'lucide-react';
import * as React from 'react';
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 { InputGroup, InputGroupInput } from '@/components/ui/input-group';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { cn } from '@/lib/utils';
export interface DatetimeFieldProps {
lens: Lens<Date>;
label?: string;
description?: string;
placeholder?: string;
minDate?: Date;
maxDate?: Date;
locale?: Locale;
disabled?: boolean;
step?: number;
}
const pad2 = (n: number): string => n.toString().padStart(2, '0');
const toTimeInputValue = (date: Date | undefined): string =>
date ? `${pad2(date.getHours())}:${pad2(date.getMinutes())}` : '';
export function DatetimeField({
lens,
label,
description,
placeholder = 'Pick a date & time',
minDate,
maxDate,
locale,
disabled,
step = 60,
}: DatetimeFieldProps) {
const { field, fieldState } = useController(lens.interop());
const [open, setOpen] = React.useState(false);
const value = field.value as Date | undefined;
const handleDateSelect = (next: Date | undefined) => {
if (!next) {
field.onChange(undefined);
return;
}
const hours = value?.getHours() ?? 0;
const minutes = value?.getMinutes() ?? 0;
field.onChange(setMinutes(setHours(next, hours), minutes));
};
const handleTimeChange = (event: React.ChangeEvent<HTMLInputElement>) => {
if (!value) return;
const raw = event.target.value;
if (!raw) return;
const [h, m] = raw.split(':').map((part) => Number.parseInt(part, 10));
if (Number.isNaN(h) || Number.isNaN(m)) return;
field.onChange(setMinutes(setHours(value, h), m));
};
const triggerLabel = value ? format(value, 'PPp', locale ? { locale } : undefined) : placeholder;
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}
aria-invalid={fieldState.invalid}
className={cn('w-full justify-between font-normal', !value && 'text-muted-foreground')}
onBlur={field.onBlur}
>
<span className='truncate'>{triggerLabel}</span>
<CalendarIcon className='size-4 opacity-70' />
</Button>
</PopoverTrigger>
<PopoverContent className='flex w-auto flex-col gap-3 p-3' align='start'>
<Calendar
mode='single'
selected={value}
onSelect={handleDateSelect}
disabled={[...(minDate ? [{ before: minDate }] : []), ...(maxDate ? [{ after: maxDate }] : [])]}
locale={locale}
autoFocus
/>
<InputGroup>
<InputGroupInput
type='time'
step={step}
value={toTimeInputValue(value)}
onChange={handleTimeChange}
disabled={disabled || !value}
aria-label='Time'
/>
</InputGroup>
<Button type='button' size='sm' onClick={() => setOpen(false)}>
OK
</Button>
</PopoverContent>
</Popover>
{fieldState.invalid && <FieldError className='text-xs text-left' errors={[fieldState.error]} />}
{description && <FieldDescription className='text-xs'>{description}</FieldDescription>}
</Field>
);
}
Loading...

DatetimeField is a single-value date + time picker that stores its selection as one Date carrying both the day and the hours/minutes. It wraps shadcn's Calendar and a native <input type="time"> inside a Popover, and binds to the form via a typed lens from @hookform/lenses.

For date-only fields, use Calendar directly. This component is for cases where the time of day matters as much as the date — meeting start, deadline, deliverable, etc.

Built-in features

  • Single Date value: day + hours + minutes live in one field — no separate date/time pieces to reconcile
  • Typed lens binding: lens.focus('scheduledAt') autocompletes from your form's value type
  • Locale-aware label: trigger renders via date-fns format(value, 'PPp', { locale })
  • Bounds: minDate / maxDate disable out-of-range days on the calendar
  • Time step: native <input type="time" step="..."> for minute or second granularity

Setup

import { useLens } from '@hookform/lenses';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Form } from '@/components/ui/form';
import { DatetimeField } from '@/components/ui/shuip/react-hook-form/datetime-field';

const schema = z.object({ scheduledAt: z.date() });
type Values = z.infer<typeof schema>;

const form = useForm<Values>({ defaultValues: { scheduledAt: undefined } });
const lens = useLens({ control: form.control });

<Form {...form}>
  <form onSubmit={form.handleSubmit(onSubmit)}>
    <DatetimeField lens={lens.focus('scheduledAt')} label='Scheduled at' />
  </form>
</Form>

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

Behavior

  • First date pick: when the field is empty and the user picks a day, hours and minutes default to 00:00. When the field already has a value, the existing hours/minutes are preserved.
  • Time change: only the hours/minutes are updated; the date portion is preserved.
  • Trigger label: format(value, 'PPp', { locale }) — e.g. May 22, 2026, 2:30 PM. When empty, falls back to placeholder.

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 { DatetimeField } from '@/components/ui/shuip/react-hook-form/datetime-field';
import { SubmitButton } from '@/components/ui/shuip/submit-button';
const zodSchema = z.object({
scheduledAt: z.date({ message: 'Pick a date & time' }),
});
type Values = z.infer<typeof zodSchema>;
export default function RhfDatetimeFieldExample() {
const form = useForm<Values>({
defaultValues: { scheduledAt: undefined },
resolver: zodResolver(zodSchema),
});
const lens = useLens({ control: form.control });
async function onSubmit(values: Values) {
alert(`Scheduled at: ${values.scheduledAt.toISOString()}`);
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
<DatetimeField
lens={lens.focus('scheduledAt')}
label='Scheduled at'
description='Pick a date and a time of day.'
/>
<SubmitButton>Schedule</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 { DatetimeField } from '@/components/ui/shuip/react-hook-form/datetime-field';
import { SubmitButton } from '@/components/ui/shuip/submit-button';
const zodSchema = z.object({
startsAt: z
.date({ message: 'Required' })
.refine((date) => date.getTime() > Date.now(), { message: 'Must be in the future' }),
});
type Values = z.infer<typeof zodSchema>;
export default function RhfDatetimeFieldValidationExample() {
const form = useForm<Values>({
defaultValues: { startsAt: undefined },
resolver: zodResolver(zodSchema),
});
const lens = useLens({ control: form.control });
async function onSubmit(values: Values) {
alert(`Starts at: ${values.startsAt.toISOString()}`);
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
<DatetimeField
lens={lens.focus('startsAt')}
label='Starts at'
description='Must be a future date and time.'
minDate={new Date()}
/>
<SubmitButton>Book</SubmitButton>
</form>
</Form>
);
}

Props

Prop

Type

On this page