Date Range Field

Date range picker integrated with TanStack Form via React context. Built on the shadcn Calendar primitive in range mode.

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

import type { Locale } from 'date-fns';
import { format } from 'date-fns';
import { CalendarIcon } from 'lucide-react';
import * as React from 'react';
import type { DateRange, 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 DateRangeFieldProps {
  label?: string;
  description?: string;
  placeholder?: string;
  minDate?: Date;
  maxDate?: Date;
  locale?: Locale;
  fieldProps?: React.ComponentProps<typeof Field>;
  triggerProps?: React.ComponentProps<typeof Button>;
}

export function dateBoundsMatcher(minDate?: Date, maxDate?: Date): Matcher[] | undefined {
  if (!minDate && !maxDate) return undefined;
  return [...(minDate ? [{ before: minDate }] : []), ...(maxDate ? [{ after: maxDate }] : [])];
}

function formatRange(value: DateRange | undefined, locale: Locale | undefined): string | null {
  if (!value?.from) return null;
  const fromLabel = format(value.from, 'PPP', { locale });
  if (!value.to) return fromLabel;
  return `${fromLabel} – ${format(value.to, 'PPP', { locale })}`;
}

export function DateRangeField({
  label,
  description,
  placeholder = 'Pick a date range',
  minDate,
  maxDate,
  locale,
  fieldProps,
  triggerProps,
}: DateRangeFieldProps) {
  const field = useFieldContext<DateRange | undefined>();
  const { isValid, errors } = field.state.meta;
  const value = field.state.value;
  const [open, setOpen] = React.useState(false);

  const disabledMatcher = React.useMemo(() => dateBoundsMatcher(minDate, maxDate), [minDate, maxDate]);

  const triggerLabel = formatRange(value, locale);

  return (
    <Field className='gap-2' data-invalid={!isValid} {...fieldProps}>
      {label && <FieldLabel htmlFor={field.name}>{label}</FieldLabel>}
      <Popover open={open} onOpenChange={setOpen}>
        <PopoverTrigger
          render={
            <Button
              id={field.name}
              type='button'
              variant='outline'
              onBlur={field.handleBlur}
              aria-invalid={!isValid}
              {...triggerProps}
              className={cn(
                'w-full justify-between font-normal',
                !triggerLabel && 'text-muted-foreground',
                triggerProps?.className,
              )}
            />
          }
        >
          <span className='truncate'>{triggerLabel ?? placeholder}</span>
          <CalendarIcon className='size-4 opacity-60' />
        </PopoverTrigger>
        <PopoverContent className='w-auto p-0' align='start'>
          <Calendar
            mode='range'
            selected={value}
            onSelect={(range) => field.handleChange(range)}
            numberOfMonths={2}
            defaultMonth={value?.from ?? minDate}
            disabled={disabledMatcher}
            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...

DateRangeField is a from–to date picker bound to TanStack Form. It wraps the shadcn Calendar primitive in mode='range' inside a Popover, and reads the surrounding field via useFieldContext — compose it inside a <form.AppField> rather than passing a form instance down by prop.

The bound value is a DateRange from react-day-picker:

type DateRange = { from?: Date; to?: Date } | undefined;

Built-in features

  • Context-bound field state: reads the field via useFieldContext — no prop drilling
  • Type-safe field names: name autocompletes from your defaultValues on <form.AppField>
  • Two-month side-by-side display: canonical UX for picking a range that crosses months
  • Bounded selection: minDate / maxDate translate to the Calendar disabled matcher
  • Locale aware: optional date-fns Locale passed through to label formatting and Calendar

Setup

Register the field component once on your useAppForm factory. See the form-context item for details.

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

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

Then declare the field value type as DateRange | undefined:

import type { DateRange } from 'react-day-picker';

const form = useAppForm({
  defaultValues: { stay: undefined } as { stay: DateRange | undefined },
  onSubmit: async ({ value }) => { /* ... */ },
});

<form.AppField
  name='stay'
  children={(field) => <field.DateRangeField label='Stay' />}
/>

Trigger label

The trigger button renders a label derived from the current value:

  • both ends set → "<from> – <to>" using date-fns format(date, 'PPP', { locale })
  • only from set → just the from date
  • neither set → the placeholder prop

Validation

Validation lives on <form.AppField> as with any TanStack field. To require both ends of the range and enforce ordering:

<form.AppField
  name='booking'
  validators={{
    onChange: ({ value }) => {
      if (!value?.from) return 'Start date is required';
      if (!value.to) return 'End date is required';
      if (value.to < value.from) return 'End date must be on or after the start date';
      return undefined;
    },
  }}
  children={(field) => <field.DateRangeField label='Booking' />}
/>

Examples

Default

Loading...
'use client';

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

interface Values {
  stay: DateRange | undefined;
}

export default function TsfDateRangeFieldExample() {
  const form = useAppForm({
    defaultValues: { stay: undefined } as Values,
    onSubmit: async ({ value }) => {
      await new Promise((resolve) => setTimeout(resolve, 1000));
      alert(JSON.stringify(value, null, 2));
    },
  });

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        form.handleSubmit();
      }}
      className='space-y-4'
    >
      <form.AppField
        name='stay'
        children={(field) => <field.DateRangeField label='Stay' description='Pick check-in and check-out dates' />}
      />

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

Validation

Loading...
'use client';

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

interface Values {
  booking: DateRange | undefined;
}

export default function TsfDateRangeFieldValidationExample() {
  const form = useAppForm({
    defaultValues: { booking: undefined } as Values,
    onSubmit: async ({ value }) => {
      await new Promise((resolve) => setTimeout(resolve, 1000));
      alert(JSON.stringify(value, null, 2));
    },
  });

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        form.handleSubmit();
      }}
      className='space-y-4'
    >
      <form.AppField
        name='booking'
        validators={{
          onChange: ({ value }) => {
            if (!value?.from) return 'Start date is required';
            if (!value.to) return 'End date is required';
            if (value.to < value.from) return 'End date must be on or after the start date';
            return undefined;
          },
        }}
        children={(field) => (
          <field.DateRangeField
            label='Booking window'
            description='Both dates are required and end date must be after start date'
            placeholder='Select start and end dates'
            minDate={new Date()}
          />
        )}
      />

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

Props

Prop

Type

On this page