Month Field

Month + year picker integrated with React Hook Form via typed lens binding from @hookform/lenses. Stores a Date normalized to the first day of the selected month.

npx shadcn@latest add https://shuip.plvo.dev/r/rhf-month-field.json
pnpm dlx shadcn@latest add https://shuip.plvo.dev/r/rhf-month-field.json
bun x shadcn@latest add https://shuip.plvo.dev/r/rhf-month-field.json
'use client';
import type { Lens } from '@hookform/lenses';
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 { useController } from 'react-hook-form';
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 { cn } from '@/lib/utils';
export interface MonthFieldProps {
lens: Lens<Date | undefined>;
label?: string;
description?: string;
placeholder?: string;
minDate?: Date;
maxDate?: Date;
locale?: Locale;
disabled?: boolean;
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({
lens,
label,
description,
placeholder = 'Pick a month',
minDate,
maxDate,
locale,
disabled,
className,
}: MonthFieldProps) {
const { field, fieldState } = useController(lens.interop());
const [open, setOpen] = React.useState(false);
const value: Date | undefined = field.value;
const handleSelect = (date: Date | undefined) => {
if (!date) {
field.onChange(undefined);
return;
}
field.onChange(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={fieldState.invalid}>
{label && <FieldLabel htmlFor={field.name}>{label}</FieldLabel>}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
id={field.name}
type='button'
variant='outline'
disabled={disabled}
onBlur={field.onBlur}
aria-invalid={fieldState.invalid}
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'>
<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>
{fieldState.invalid && <FieldError className='text-xs text-left' errors={[fieldState.error]} />}
{description && <FieldDescription className='text-xs'>{description}</FieldDescription>}
</Field>
);
}
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.

The field binds to the form via a typed lens from @hookform/lenses: lens.focus('billingMonth') autocompletes from your form's value type — no <MyForm> generic at the call site.

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
  • Typed lens binding: lens.focus('month') autocompletes from your form's value type

Setup

import { useLens } from '@hookform/lenses';
import { useForm } from 'react-hook-form';
import { Form } from '@/components/ui/form';
import { MonthField } from '@/components/ui/shuip/react-hook-form/month-field';

type Values = { billingMonth: Date | undefined };

const form = useForm<Values>({ defaultValues: { billingMonth: undefined } });
const lens = useLens({ control: form.control });

<Form {...form}>
  <form onSubmit={form.handleSubmit(onSubmit)}>
    <MonthField lens={lens.focus('billingMonth')} label='Billing month' />
  </form>
</Form>

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

To format the stored value elsewhere, use the same 'MMMM yyyy' pattern from date-fns:

import { format } from 'date-fns';
format(values.billingMonth, 'MMMM yyyy'); // 'May 2026'

Examples

Default

Loading...
'use client';
import { useLens } from '@hookform/lenses';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Form } from '@/components/ui/form';
import { MonthField } from '@/components/ui/shuip/react-hook-form/month-field';
import { SubmitButton } from '@/components/ui/shuip/submit-button';
const zodSchema = z.object({
billingMonth: z.date({ message: 'Pick a billing month' }),
});
type FormValues = { billingMonth: Date | undefined };
type SubmitValues = z.infer<typeof zodSchema>;
export default function RhfMonthFieldExample() {
const form = useForm<FormValues, unknown, SubmitValues>({
defaultValues: { billingMonth: undefined },
resolver: zodResolver(zodSchema),
});
const lens = useLens({ control: form.control });
async function onSubmit(values: SubmitValues) {
alert(
`Billing month: ${values.billingMonth.toLocaleDateString('en-US', { year: 'numeric', month: 'long' })}\nISO: ${values.billingMonth.toISOString()}`,
);
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
<MonthField
lens={lens.focus('billingMonth')}
label='Billing month'
description='The first day of the selected month is stored.'
/>
<SubmitButton>Submit</SubmitButton>
</form>
</Form>
);
}

Validation

Loading...
'use client';
import { useLens } from '@hookform/lenses';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Form } from '@/components/ui/form';
import { MonthField } from '@/components/ui/shuip/react-hook-form/month-field';
import { SubmitButton } from '@/components/ui/shuip/submit-button';
const startOfCurrentMonth = (): Date => {
const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), 1);
};
const zodSchema = z.object({
startMonth: z
.date({ message: 'Start month is required' })
.refine((d) => d.getTime() >= startOfCurrentMonth().getTime(), {
message: 'Must be the current month or later',
}),
});
type FormValues = { startMonth: Date | undefined };
type SubmitValues = z.infer<typeof zodSchema>;
export default function RhfMonthFieldValidationExample() {
const form = useForm<FormValues, unknown, SubmitValues>({
defaultValues: { startMonth: undefined },
resolver: zodResolver(zodSchema),
});
const lens = useLens({ control: form.control });
async function onSubmit(values: SubmitValues) {
alert(`Start month: ${values.startMonth.toISOString()}`);
}
const tenYearsFromNow = new Date();
tenYearsFromNow.setFullYear(tenYearsFromNow.getFullYear() + 10);
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
<MonthField
lens={lens.focus('startMonth')}
label='Subscription start'
description='Past months are disabled in the calendar and rejected by Zod.'
minDate={startOfCurrentMonth()}
maxDate={tenYearsFromNow}
/>
<SubmitButton>Start subscription</SubmitButton>
</form>
</Form>
);
}

Props

Prop

Type

On this page