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 @shuip/tsf-month-field
pnpm dlx shadcn@latest add @shuip/tsf-month-field
bun x shadcn@latest add @shuip/tsf-month-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 { 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
          render={
            <Button
              id={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' />
        </PopoverTrigger>
        <PopoverContent className='w-auto p-0' align='start'>
          <Calendar
            mode='single'
            captionLayout='dropdown'
            selected={value}
            onSelect={handleSelect}
            disabled={disabledMatchers.length ? disabledMatchers : undefined}
            startMonth={minMonth}
            endMonth={maxMonth}
            defaultMonth={value ?? minMonth}
            locale={locale}
          />
        </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...

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 / maxDate drive both the calendar's disabled days and its dropdown bounds via startMonth / endMonth
  • Locale aware: locale is forwarded to date-fns format and 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();     // true

Examples

Default

Loading...
'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 (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        form.handleSubmit();
      }}
      className='space-y-4'
    >
      <form.AppField
        name='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

Loading...
'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 (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        form.handleSubmit();
      }}
      className='space-y-4'
    >
      <form.AppField
        name='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.MonthField
            label='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

On this page