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><Buttonid={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'><Calendarmode='range'selected={value}onSelect={(range) => field.handleChange(range)}numberOfMonths={2}defaultMonth={value?.from ?? minDate}disabled={disabledMatcher}locale={locale}autoFocus/></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>);}
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:
nameautocompletes from yourdefaultValueson<form.AppField> - Two-month side-by-side display: canonical UX for picking a range that crosses months
- Bounded selection:
minDate/maxDatetranslate to the Calendardisabledmatcher - Locale aware: optional
date-fnsLocalepassed 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>"usingdate-fnsformat(date, 'PPP', { locale }) - only
fromset → just thefromdate - neither set → the
placeholderprop
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
'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 (<formonSubmit={(e) => {e.preventDefault();form.handleSubmit();}}className='space-y-4'><form.AppFieldname='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
'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 (<formonSubmit={(e) => {e.preventDefault();form.handleSubmit();}}className='space-y-4'><form.AppFieldname='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.DateRangeFieldlabel='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