Date Field

Single-date picker integrated with TanStack Form via React context. Wraps the shadcn Calendar inside a Popover triggered by an outline Button.

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

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 { 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 { useFieldContext } from '@/components/ui/shuip/tanstack-form/form-context';
import { cn } from '@/lib/utils';

export interface DateFieldProps {
  label?: string;
  description?: string;
  placeholder?: string;
  minDate?: Date;
  maxDate?: Date;
  locale?: Locale;
  dateFormat?: string;
  disabled?: boolean;
  fieldProps?: React.ComponentProps<typeof Field>;
  triggerProps?: React.ComponentProps<typeof Button>;
}

export function DateField({
  label,
  description,
  placeholder = 'Pick a date',
  minDate,
  maxDate,
  locale = enUS,
  dateFormat = 'PPP',
  disabled,
  fieldProps,
  triggerProps,
}: DateFieldProps) {
  const field = useFieldContext<Date | undefined>();
  const { isValid, errors } = field.state.meta;
  const [open, setOpen] = React.useState(false);

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

  return (
    <Field className='gap-2' data-invalid={!isValid} {...fieldProps}>
      {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={!isValid}
              onBlur={field.handleBlur}
              {...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.handleChange(date);
              setOpen(false);
            }}
            disabled={disabledMatchers.length > 0 ? disabledMatchers : undefined}
            locale={locale}
            autoFocus
          />
        </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...

DateField is a single-date picker that encapsulates TanStack 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.

It reads the surrounding field via useFieldContext<Date | undefined>(), so you compose it inside a <form.AppField> rather than passing a form instance down. undefined represents an empty selection (matching react-day-picker's convention).

Built-in features

  • Context-bound field state: reads the field via useFieldContext — no prop drilling
  • 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 !isValid
  • Validator-friendly: works with TanStack Form's native validators or any standard schema

Setup

Field components are bound via React context. In your project, create lib/form.ts once:

// lib/form.ts
import { createFormHook } from '@tanstack/react-form';
import { fieldContext, formContext } from '@/components/ui/shuip/tanstack-form/form-context';
import { DateField } from '@/components/ui/shuip/tanstack-form/date-field';
import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';

export const { useAppForm } = createFormHook({
  fieldContext,
  formContext,
  fieldComponents: { DateField },
  formComponents: { SubmitButton },
});

See the form-context item for details.

Then compose the field inside <form.AppField>:

import { useAppForm } from '@/lib/form';

const form = useAppForm({
  defaultValues: { dueDate: undefined as Date | undefined },
  onSubmit: async ({ value }) => saveData(value),
});

<form.AppField
  name='dueDate'
  validators={{
    onChange: ({ value }) => (value ? undefined : 'Pick a due date'),
  }}
  children={(field) => <field.DateField label='Due date' />}
/>

Bounded selection

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

<form.AppField
  name='appointment'
  children={(field) => (
    <field.DateField label='Appointment' minDate={new Date()} maxDate={oneYearFromNow} />
  )}
/>

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

Examples

Default

Loading...
'use client';

import { createFormHook } from '@tanstack/react-form';
import { DateField } from '@/components/ui/shuip/tanstack-form/date-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: { DateField },
  formComponents: { SubmitButton },
});

export default function TsfDateFieldExample() {
  const form = useAppForm({
    defaultValues: {
      dueDate: undefined as Date | undefined,
    },
    onSubmit: async ({ value }) => {
      await new Promise((resolve) => setTimeout(resolve, 500));
      alert(JSON.stringify({ dueDate: value.dueDate?.toISOString() ?? null }, null, 2));
    },
  });

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        form.handleSubmit();
      }}
      className='space-y-4'
    >
      <form.AppField
        name='dueDate'
        validators={{
          onChange: ({ value }) => (value ? undefined : 'Pick a due date'),
        }}
        children={(field) => <field.DateField label='Due date' description='When should this task be done?' />}
      />

      <form.AppForm>
        <form.SubmitButton>Save</form.SubmitButton>
      </form.AppForm>
    </form>
  );
}

Validation

Loading...
'use client';

import { createFormHook } from '@tanstack/react-form';
import { DateField } from '@/components/ui/shuip/tanstack-form/date-field';
import { fieldContext, formContext } from '@/components/ui/shuip/tanstack-form/form-context';
import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';

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

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

const { useAppForm } = createFormHook({
  fieldContext,
  formContext,
  fieldComponents: { DateField },
  formComponents: { SubmitButton },
});

export default function TsfDateFieldValidationExample() {
  const form = useAppForm({
    defaultValues: {
      appointment: undefined as Date | undefined,
    },
    onSubmit: async ({ value }) => {
      await new Promise((resolve) => setTimeout(resolve, 500));
      alert(`Appointment: ${value.appointment?.toLocaleDateString() ?? 'none'}`);
    },
  });

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        form.handleSubmit();
      }}
      className='space-y-4'
    >
      <form.AppField
        name='appointment'
        validators={{
          onChange: ({ value }) => {
            if (!value) return 'Pick an appointment date';
            if (value < today) return 'Appointment must be in the future';
            if (value > oneYearFromNow) return 'Appointment must be within a year';
            return undefined;
          },
        }}
        children={(field) => (
          <field.DateField
            label='Appointment'
            description='Within the next 12 months'
            minDate={today}
            maxDate={oneYearFromNow}
          />
        )}
      />

      <form.AppForm>
        <form.SubmitButton>Book</form.SubmitButton>
      </form.AppForm>
    </form>
  );
}

Props

Prop

Type

On this page