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.
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 (
<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
'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