Month Field
Month + year picker integrated with TanStack Form via React context. Stores a Date normalized to the first day of the selected month.
npx shadcn@latest add https://shuip.plvo.dev/r/tsf-month-field.json
pnpm dlx shadcn@latest add https://shuip.plvo.dev/r/tsf-month-field.json
bun x shadcn@latest add https://shuip.plvo.dev/r/tsf-month-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 { 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 MonthFieldProps {label?: string;description?: string;placeholder?: string;minDate?: Date;maxDate?: Date;locale?: Locale;disabled?: boolean;fieldProps?: React.ComponentProps<typeof Field>;className?: string;}const toFirstOfMonth = (date: Date): Date => new Date(date.getFullYear(), date.getMonth(), 1);const toLastOfMonth = (date: Date): Date => new Date(date.getFullYear(), date.getMonth() + 1, 0);export function MonthField({label,description,placeholder = 'Pick a month',minDate,maxDate,locale,disabled,fieldProps,className,}: MonthFieldProps) {const field = useFieldContext<Date | undefined>();const { isValid, errors } = field.state.meta;const [open, setOpen] = React.useState(false);const value = field.state.value;const handleSelect = (date: Date | undefined) => {if (!date) {field.handleChange(undefined);return;}field.handleChange(toFirstOfMonth(date));setOpen(false);};const minMonth = minDate ? toFirstOfMonth(minDate) : undefined;const maxMonth = maxDate ? toLastOfMonth(maxDate) : undefined;const disabledMatchers: Matcher[] = [];if (minMonth) disabledMatchers.push({ before: minMonth });if (maxMonth) disabledMatchers.push({ after: maxMonth });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'disabled={disabled}onBlur={field.handleBlur}aria-invalid={!isValid}className={cn('w-full justify-between font-normal', !value && 'text-muted-foreground', className)}>{value ? format(value, 'MMMM yyyy', { locale }) : placeholder}<CalendarIcon className='size-4 opacity-60' /></Button></PopoverTrigger><PopoverContent className='w-auto p-0' align='start'><Calendarmode='single'captionLayout='dropdown'selected={value}onSelect={handleSelect}disabled={disabledMatchers.length ? disabledMatchers : undefined}startMonth={minMonth}endMonth={maxMonth}defaultMonth={value ?? minMonth}locale={locale}/></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>);}
MonthField is a month picker that wraps the shadcn Calendar primitive inside a Popover. The user picks any day in a month and the field stores the first day of that month as a Date — perfect for "billing month", "report period", "subscription start" use cases where the day component is noise.
It reads the surrounding field via useFieldContext<Date | undefined>(), so you compose it inside a <form.AppField> rather than passing a form instance down by prop.
Built-in features
- First-day normalization: any day the user clicks is internally rewritten to
new Date(year, month, 1), so the stored value behaves as a month bucket - Dropdown caption: month and year dropdowns in the calendar header (
captionLayout='dropdown') - Bounded range:
minDate/maxDatedrive both the calendar's disabled days and its dropdown bounds viastartMonth/endMonth - Locale aware:
localeis forwarded todate-fnsformatand to the calendar - Context-bound field state: reads the field via
useFieldContext— no prop drilling
Setup
Register MonthField alongside your other field components in your useAppForm factory:
// lib/form.ts
import { createFormHook } from '@tanstack/react-form';
import { fieldContext, formContext } from '@/components/ui/shuip/tanstack-form/form-context';
import { MonthField } from '@/components/ui/shuip/tanstack-form/month-field';
import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';
export const { useAppForm } = createFormHook({
fieldContext,
formContext,
fieldComponents: { MonthField },
formComponents: { SubmitButton },
});Then bind a Date | undefined field:
const form = useAppForm({
defaultValues: { billingMonth: undefined as Date | undefined },
onSubmit: async ({ value }) => saveBilling(value.billingMonth),
});
<form.AppField
name='billingMonth'
validators={{ onChange: ({ value }) => (value ? undefined : 'Required') }}
children={(field) => <field.MonthField label='Billing month' />}
/>Value shape
The stored value is always either undefined or a Date whose day component is 1. Consumers can compare months by value.getTime() without worrying about time-of-day drift:
const a = new Date(2026, 4, 1); // May 2026
const b = new Date(2026, 4, 1); // May 2026
a.getTime() === b.getTime(); // trueExamples
Default
'use client';import { createFormHook } from '@tanstack/react-form';import { fieldContext, formContext } from '@/components/ui/shuip/tanstack-form/form-context';import { MonthField } from '@/components/ui/shuip/tanstack-form/month-field';import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';const { useAppForm } = createFormHook({fieldContext,formContext,fieldComponents: { MonthField },formComponents: { SubmitButton },});export default function TsfMonthFieldExample() {const form = useAppForm({defaultValues: {billingMonth: undefined as Date | undefined,},onSubmit: async ({ value }) => {if (!value.billingMonth) return;alert(`Billing month: ${value.billingMonth.toLocaleDateString('en-US', { year: 'numeric', month: 'long' })}\nISO: ${value.billingMonth.toISOString()}`,);},});return (<formonSubmit={(e) => {e.preventDefault();form.handleSubmit();}}className='space-y-4'><form.AppFieldname='billingMonth'validators={{onChange: ({ value }) => (value ? undefined : 'Pick a billing month'),}}children={(field) => (<field.MonthField label='Billing month' description='The first day of the selected month is stored.' />)}/><form.AppForm><form.SubmitButton>Submit</form.SubmitButton></form.AppForm></form>);}
Validation
'use client';import { createFormHook } from '@tanstack/react-form';import { fieldContext, formContext } from '@/components/ui/shuip/tanstack-form/form-context';import { MonthField } from '@/components/ui/shuip/tanstack-form/month-field';import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';const { useAppForm } = createFormHook({fieldContext,formContext,fieldComponents: { MonthField },formComponents: { SubmitButton },});const startOfCurrentMonth = (): Date => {const now = new Date();return new Date(now.getFullYear(), now.getMonth(), 1);};export default function TsfMonthFieldValidationExample() {const form = useAppForm({defaultValues: {startMonth: undefined as Date | undefined,},onSubmit: async ({ value }) => {if (!value.startMonth) return;alert(`Start month: ${value.startMonth.toISOString()}`);},});const minDate = startOfCurrentMonth();const tenYearsFromNow = new Date();tenYearsFromNow.setFullYear(tenYearsFromNow.getFullYear() + 10);return (<formonSubmit={(e) => {e.preventDefault();form.handleSubmit();}}className='space-y-4'><form.AppFieldname='startMonth'validators={{onChange: ({ value }) => {if (!value) return 'Start month is required';if (value.getTime() < startOfCurrentMonth().getTime()) {return 'Must be the current month or later';}return undefined;},}}children={(field) => (<field.MonthFieldlabel='Subscription start'description='Past months are disabled in the calendar and rejected by the validator.'minDate={minDate}maxDate={tenYearsFromNow}/>)}/><form.AppForm><form.SubmitButton>Start subscription</form.SubmitButton></form.AppForm></form>);}
Props
Prop
Type