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 @shuip/tsf-datetime-field
pnpm dlx shadcn@latest add @shuip/tsf-datetime-field
bun x shadcn@latest add @shuip/tsf-datetime-field
'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
          render={
            <Button
              id={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' />
        </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>
      {!isValid && (
        <FieldError
          className='text-xs text-left'
          errors={errors.map((error) => ({ message: typeof error === 'string' ? error : error?.message }))}
        />
      )}
      {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...

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 Date value: 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-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

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 to placeholder.

Examples

Default

Loading...
'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 (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        form.handleSubmit();
      }}
      className='space-y-4'
    >
      <form.AppField
        name='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

Loading...
'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 (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        form.handleSubmit();
      }}
      className='space-y-4'
    >
      <form.AppField
        name='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

On this page