Datetime Field
Single date and time picker integrated with TanStack Form via React context. Stores a single Date carrying day, hours and minutes.
npx shadcn@latest add https://shuip.plvo.dev/r/tsf-datetime-field.json
pnpm dlx shadcn@latest add https://shuip.plvo.dev/r/tsf-datetime-field.json
bun x shadcn@latest add https://shuip.plvo.dev/r/tsf-datetime-field.json
'use client';import type { Locale } from 'date-fns';import { format, setHours, setMinutes } from 'date-fns';import { CalendarIcon } from 'lucide-react';import * as React from 'react';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 { useFieldContext } from '@/components/ui/shuip/tanstack-form/form-context';import { cn } from '@/lib/utils';export interface DatetimeFieldProps {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({label,description,placeholder = 'Pick a date & time',minDate,maxDate,locale,disabled,step = 60,}: DatetimeFieldProps) {const field = useFieldContext<Date | undefined>();const { isValid, errors } = field.state.meta;const [open, setOpen] = React.useState(false);const value = field.state.value;const handleDateSelect = (next: Date | undefined) => {if (!next) {field.handleChange(undefined);return;}const hours = value?.getHours() ?? 0;const minutes = value?.getMinutes() ?? 0;field.handleChange(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.handleChange(setMinutes(setHours(value, h), m));};const triggerLabel = value ? format(value, 'PPp', locale ? { locale } : undefined) : placeholder;return (<Field className='gap-2' data-invalid={!isValid}>{label && <FieldLabel htmlFor={field.name}>{label}</FieldLabel>}<Popover open={open} onOpenChange={setOpen}><PopoverTrigger asChild><Buttonid={field.name}type='button'variant='outline'disabled={disabled}aria-invalid={!isValid}className={cn('w-full justify-between font-normal', !value && 'text-muted-foreground')}onBlur={field.handleBlur}><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'><Calendarmode='single'selected={value}onSelect={handleDateSelect}disabled={[...(minDate ? [{ before: minDate }] : []), ...(maxDate ? [{ after: maxDate }] : [])]}locale={locale}autoFocus/><InputGroup><InputGroupInputtype='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>{!isValid && (<FieldErrorclassName='text-xs text-left'errors={errors.map((error) => ({ message: typeof error === 'string' ? error : error?.message }))}/>)}{description && <FieldDescription className='text-xs'>{description}</FieldDescription>}</Field>);}
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 reads the surrounding field via useFieldContext from your useAppForm setup.
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
Datevalue: day + hours + minutes live in one field — no separate date/time pieces to reconcile - Context-bound field state: reads the field via
useFieldContext— no prop drilling - Locale-aware label: trigger renders via
date-fnsformat(value, 'PPp', { locale }) - Bounds:
minDate/maxDatedisable out-of-range days on the calendar - Time step: native
<input type="time" step="...">for minute or second granularity
Setup
Register the field component on your useAppForm once, alongside the rest of your shuip TSF components:
// lib/form.ts
import { createFormHook } from '@tanstack/react-form';
import { fieldContext, formContext } from '@/components/ui/shuip/tanstack-form/form-context';
import { DatetimeField } from '@/components/ui/shuip/tanstack-form/datetime-field';
import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';
export const { useAppForm } = createFormHook({
fieldContext,
formContext,
fieldComponents: { DatetimeField },
formComponents: { SubmitButton },
});See the form-context item for details.
Then compose it inside <form.AppField>:
const form = useAppForm({
defaultValues: { scheduledAt: undefined as Date | undefined },
onSubmit: async ({ value }) => save(value),
});
<form.AppField
name='scheduledAt'
children={(field) => <field.DatetimeField label='Scheduled at' />}
/>When the field's value can be undefined, type the default value with an assertion as shown — TanStack Form infers Date | undefined from it.
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 toplaceholder.
Examples
Default
'use client';import { createFormHook } from '@tanstack/react-form';import { DatetimeField } from '@/components/ui/shuip/tanstack-form/datetime-field';import { fieldContext, formContext } from '@/components/ui/shuip/tanstack-form/form-context';import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';const { useAppForm } = createFormHook({fieldContext,formContext,fieldComponents: { DatetimeField },formComponents: { SubmitButton },});export default function TsfDatetimeFieldExample() {const form = useAppForm({defaultValues: {scheduledAt: undefined as Date | undefined,},onSubmit: async ({ value }) => {alert(`Scheduled at: ${value.scheduledAt?.toISOString() ?? 'unset'}`);},});return (<formonSubmit={(e) => {e.preventDefault();form.handleSubmit();}}className='space-y-4'><form.AppFieldname='scheduledAt'children={(field) => <field.DatetimeField label='Scheduled at' description='Pick a date and a time of day.' />}/><form.AppForm><form.SubmitButton>Schedule</form.SubmitButton></form.AppForm></form>);}
Validation
'use client';import { createFormHook } from '@tanstack/react-form';import { DatetimeField } from '@/components/ui/shuip/tanstack-form/datetime-field';import { fieldContext, formContext } from '@/components/ui/shuip/tanstack-form/form-context';import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';const { useAppForm } = createFormHook({fieldContext,formContext,fieldComponents: { DatetimeField },formComponents: { SubmitButton },});export default function TsfDatetimeFieldValidationExample() {const form = useAppForm({defaultValues: {startsAt: undefined as Date | undefined,},onSubmit: async ({ value }) => {alert(`Starts at: ${value.startsAt?.toISOString() ?? 'unset'}`);},});return (<formonSubmit={(e) => {e.preventDefault();form.handleSubmit();}}className='space-y-4'><form.AppFieldname='startsAt'validators={{onChange: ({ value }) => {if (!value) return 'Required';if (value.getTime() <= Date.now()) return 'Must be in the future';return undefined;},}}children={(field) => (<field.DatetimeField label='Starts at' description='Must be a future date and time.' minDate={new Date()} />)}/><form.AppForm><form.SubmitButton>Book</form.SubmitButton></form.AppForm></form>);}
Props
Prop
Type