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 https://shuip.plvo.dev/r/tsf-date-range-field.json
pnpm dlx shadcn@latest add https://shuip.plvo.dev/r/tsf-date-range-field.json
bun x shadcn@latest add https://shuip.plvo.dev/r/tsf-date-range-field.json
'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 } 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>;
}
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(() => {
if (!minDate && !maxDate) return undefined;
return { ...(minDate ? { before: minDate } : {}), ...(maxDate ? { after: 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 asChild>
<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' />
</Button>
</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>
);
}
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