component — date & time

Month Picker

A grid of one year's months with a year stepper in the header, plus the popover field that wraps it. Month names are locale-formatted through the shared Intl cache, and the value is a CalendarDate on the first of the month. Gregorian unless you pass a calendar, which is how Calendar's own header keeps a thirteen-month year thirteen months long.

  • calendar
  • date
  • month
  • picker
  • form
  • input
  • interactive

06 examples

Default

Twelve locale-formatted month names and a year stepper. The current month is ringed in ink.

2026
Jan
Feb
Mär
Apr
Mai
Jun
Jul
Aug
Sep
Okt
Nov
Dez

Preselected

defaultValue takes any date inside the month; the grid opens on its year.

2024
Jan
Feb
Mär
Apr
Mai
Jun
Jul
Aug
Sep
Okt
Nov
Dez

Bounded

minValue and maxValue dim the months outside the range. A month is offered if any of its days is in range, and the emitted value is clamped into the bounds.

2026
Jan
Feb
Mär
Apr
Mai
Jun
Jul
Aug
Sep
Okt
Nov
Dez

Controlled

Drive the value from state; the grid follows it to the right year.

2026
Jan
Feb
Mär
Apr
Mai
Jun
Jul
Aug
Sep
Okt
Nov
Dez

Selected:

Popover field

The compact shape: a trigger showing month and year, with the grid in a popover that closes on selection.

Disabled

The whole picker dims and nothing takes focus.

2026
Jan
Feb
Mär
Apr
Mai
Jun
Jul
Aug
Sep
Okt
Nov
Dez

source

month-picker.tsx

Copy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.

"use client"

import {
  type Calendar,
  type CalendarDate,
  type DateValue,
  endOfMonth,
  getLocalTimeZone,
  GregorianCalendar,
  startOfYear,
  toCalendar,
  toCalendarDate,
  today,
} from "@internationalized/date"
import { ChevronDown, ChevronLeft, ChevronRight } from "lucide-react"
import { useState } from "react"
import {
  Button as FieldTrigger,
  ListBoxItem as ListBoxItemPrimitive,
  ListBox as ListBoxPrimitive,
  useLocale,
} from "react-aria-components"
import { Button } from "@/components/button"
import { Popover, PopoverContent } from "@/components/popover"
import { getDateTimeFormat } from "@/lib/intl"
import { cn } from "@/lib/utils"

/**
 * Month Picker — quebi design system
 *
 * A grid of the twelve months of one year, with a year stepper in the header,
 * and the popover field that wraps it. Same geometry and cell styling as Year
 * Picker, so the two read as one control in two resolutions.
 *
 * The grid is a react-aria ListBox with `layout="grid"`, so roving focus, arrow
 * keys, typeahead and the selection state come from react-aria rather than from
 * hand-rolled key handling. See the note on `orientation` at the ListBox for why
 * a grid that reads left-to-right is a *vertical* one as far as react-aria's
 * keyboard delegate is concerned.
 *
 * Month names come from `getDateTimeFormat` in `@/lib/intl`, never a bare
 * `toLocaleString()`: the site is prerendered, so an implicit locale is a
 * hydration bug, and the shared formatter cache is what keeps twelve names from
 * costing twelve `Intl.DateTimeFormat` constructions per render.
 *
 * The value is a `CalendarDate` on the first of the chosen month — clamped into
 * `minValue`/`maxValue` when either lands mid-month.
 *
 * The grid counts in whatever `calendar` it is handed, and Gregorian when it is
 * handed none: a standalone picker has no state above it to inherit one from,
 * while `Calendar`'s header passes `state.focusedDate.calendar` down. That is
 * why the page is a `CalendarDate` on the first of the year rather than a plain
 * number, and why the cell count is `getMonthsInYear` rather than twelve — a
 * Hebrew leap year has thirteen months, and a number cannot say which year's.
 */

export interface MonthPickerProps {
  /**
   * The calendar system the grid counts in. Gregorian by default; `Calendar`'s
   * header passes its own state's, so a Hebrew or Japanese calendar keeps
   * counting its own months and naming its own years.
   */
  calendar?: Calendar
  /** The selected month, as a date inside it. Makes the picker controlled. */
  value?: CalendarDate | null
  /** The initially selected month, as a date inside it. */
  defaultValue?: CalendarDate | null
  /** Called with the first of the chosen month, clamped into min/max. */
  onChange?: (value: CalendarDate) => void
  /** Months ending before this are not selectable. */
  minValue?: DateValue
  /** Months starting after this are not selectable. */
  maxValue?: DateValue
  isDisabled?: boolean
  isReadOnly?: boolean
  /** Move focus into the grid on mount — what the popover field wants. */
  autoFocus?: boolean
  className?: string
  "aria-label"?: string
  "aria-labelledby"?: string
  "aria-describedby"?: string
}

export function MonthPicker({
  calendar: calendarProp,
  value,
  defaultValue,
  onChange,
  minValue,
  maxValue,
  isDisabled,
  isReadOnly,
  autoFocus,
  className,
  "aria-label": ariaLabel,
  "aria-labelledby": ariaLabelledBy,
  "aria-describedby": ariaDescribedBy,
}: MonthPickerProps) {
  const { locale } = useLocale()
  const calendar = calendarProp ?? GREGORIAN
  const now = toCalendar(today(getLocalTimeZone()), calendar)
  const isControlled = value !== undefined

  const [uncontrolled, setUncontrolled] = useState<CalendarDate | null>(defaultValue ?? null)
  const given = isControlled ? (value ?? null) : uncontrolled
  // A value handed in from another calendar system is still about one month;
  // converting is what lets `keyOf` compare it against the cells on screen.
  const selected = given ? toCalendar(given, calendar) : null

  // The page, as the first day of the year on screen. `startOfYear` rather than
  // "month 1" because an era can begin mid-year: in the Japanese calendar the
  // first year of an era starts at the month the era did.
  const [page, setPage] = useState(() => startOfYear(selected ?? now))
  // A locale switch can change the calendar system under a mounted picker, and
  // a page left in the old one would count its months with the new one's rules.
  const visible = page.calendar.identifier === calendar.identifier ? page : startOfYear(now)

  // Follow the value when it moves from outside; paging by hand does not go
  // through here, so the user's own navigation is never yanked back.
  const [lastSelected, setLastSelected] = useState(selected?.toString())
  if (selected?.toString() !== lastSelected) {
    setLastSelected(selected?.toString())
    if (selected) setPage(startOfYear(selected))
  }

  const monthFormatter = getDateTimeFormat(locale, { month: "short", timeZone: "UTC" })
  const longMonthFormatter = getDateTimeFormat(locale, { month: "long", timeZone: "UTC" })
  const yearFormatter = getDateTimeFormat(locale, { year: "numeric", timeZone: "UTC" })

  const months = Array.from({ length: calendar.getMonthsInYear(visible) }, (_, index) =>
    visible.set({ month: index + 1 }),
  )
  // react-aria's ListBox has no `isDisabled` of its own — a disabled grid is one
  // where every cell is disabled, which is also what stops it taking focus.
  const disabledMonths = isDisabled
    ? months.map(keyOf)
    : months.filter((month) => !isMonthAvailable(month, minValue, maxValue)).map(keyOf)

  const select = (key: string) => {
    const month = months.find((candidate) => keyOf(candidate) === key)
    if (!month) return
    const next = clamp(month, minValue, maxValue)
    if (!isControlled) setUncontrolled(next)
    setLastSelected(next.toString())
    onChange?.(next)
  }

  return (
    <div data-slot="month-picker" className={cn("w-fit", isDisabled && "opacity-50", className)}>
      <PagerHeader
        label={yearFormatter.format(visible.toDate("UTC"))}
        isDisabled={isDisabled}
        previousLabel="Previous year"
        nextLabel="Next year"
        onPrevious={() => setPage(startOfYear(visible.subtract({ years: 1 })))}
        onNext={() => setPage(startOfYear(visible.add({ years: 1 })))}
      />
      <ListBoxPrimitive
        aria-label={ariaLabel ?? (ariaLabelledBy ? undefined : "Month")}
        aria-labelledby={ariaLabelledBy}
        aria-describedby={ariaDescribedBy}
        layout="grid"
        // Vertical, though the grid reads left-to-right, and this is the one
        // thing about it that is not obvious. `orientation` tells
        // `ListKeyboardDelegate` how the *collection order* maps onto the
        // layout, not which way the rows run: with `grid` + `vertical` it walks
        // left/right by collection order and up/down by comparing cell
        // geometry, which is exactly a wrapping 3-column grid. With `grid` +
        // `horizontal` it sends left/right through `findKey(..., isSameColumn)`
        // instead, and that compares every candidate against the rect it
        // started from rather than the previous one — so from any cell every
        // later cell is either in a different row or in the same column, every
        // candidate is skipped, and it returns null. Arrow keys did nothing at
        // all (task #170).
        orientation="vertical"
        selectionMode="single"
        disallowEmptySelection
        autoFocus={autoFocus}
        disabledKeys={disabledMonths}
        // Escape belongs to whatever is above the grid (the popover, usually);
        // the default would spend it clearing the selection instead.
        escapeKeyBehavior="none"
        selectedKeys={selected ? [keyOf(selected)] : []}
        onSelectionChange={(keys) => {
          if (isReadOnly || keys === "all") return
          const [key] = [...keys]
          if (typeof key === "string") select(key)
        }}
        className="grid grid-cols-3 gap-1 outline-hidden"
      >
        {months.map((month) => {
          const date = month.toDate("UTC")
          // `era` is part of the answer: in an era calendar `year` counts from
          // the start of the era, so two different years can both be year 1.
          const isCurrent =
            month.era === now.era && month.year === now.year && month.month === now.month
          return (
            <ListBoxItemPrimitive
              key={keyOf(month)}
              id={keyOf(month)}
              textValue={longMonthFormatter.format(date)}
              aria-label={`${longMonthFormatter.format(date)} ${yearFormatter.format(date)}`}
              className={({ isSelected, isDisabled: isItemDisabled, isFocusVisible }) =>
                cn(
                  "flex h-10 w-20 cursor-default items-center justify-center rounded-(--q-radius-control) text-sm text-quebi-fg tabular-nums outline-hidden transition-colors duration-150 hover:bg-quebi-raised",
                  isCurrent && !isSelected && "ring-1 ring-inset ring-quebi-fg",
                  isSelected && "bg-quebi-signal text-quebi-on-signal hover:bg-quebi-signal",
                  isItemDisabled && "text-quebi-fg-subtle hover:bg-transparent",
                  isFocusVisible &&
                    "ring-2 ring-quebi-focus ring-offset-2 ring-offset-quebi-bg",
                )
              }
            >
              {monthFormatter.format(date)}
            </ListBoxItemPrimitive>
          )
        })}
      </ListBoxPrimitive>
    </div>
  )
}

export interface MonthPickerFieldProps extends Omit<MonthPickerProps, "autoFocus"> {
  /** Shown on the trigger while nothing is selected. */
  placeholder?: string
  /** Where the popover opens; `bottom start` by default. */
  placement?: "bottom" | "bottom start" | "bottom end" | "top" | "top start" | "top end"
}

/**
 * The trigger is field-shaped, so it is drawn like `SelectTrigger`: `Input`'s
 * box at `sm` (38px), its edge and a 1px shadow in the focus colour while
 * focused or open.
 */
const fieldTriggerStyles = cn(
  "inline-flex cursor-default items-center justify-between gap-x-2 px-(--q-field-px) py-2 text-sm text-quebi-fg tabular-nums",
  "quebi-field",
  "transition-[border-color,box-shadow] duration-150",
  "outline-none focus:shadow-(--q-field-focus) aria-expanded:shadow-(--q-field-focus)",
  "disabled:cursor-not-allowed disabled:opacity-50",
  "*:data-[slot=icon]:size-4 *:data-[slot=icon]:shrink-0",
)

/**
 * MonthPickerField — the Month Picker behind a trigger.
 *
 * A button showing the selected month and year, with the grid in a popover that
 * closes on selection. The popover has no segmented input to focus the way Date
 * Picker does, so the grid takes focus itself on open.
 */
export function MonthPickerField({
  value,
  defaultValue,
  onChange,
  placeholder = "Select month",
  placement = "bottom start",
  className,
  isDisabled,
  "aria-label": ariaLabel,
  ...props
}: MonthPickerFieldProps) {
  const { locale } = useLocale()
  const [isOpen, setIsOpen] = useState(false)
  const [uncontrolled, setUncontrolled] = useState<CalendarDate | null>(defaultValue ?? null)
  const selected = value !== undefined ? (value ?? null) : uncontrolled
  const formatter = getDateTimeFormat(locale, {
    month: "long",
    year: "numeric",
    timeZone: "UTC",
  })

  return (
    <Popover isOpen={isOpen} onOpenChange={setIsOpen}>
      <FieldTrigger
        aria-label={ariaLabel ?? "Month"}
        isDisabled={isDisabled}
        className={cn(fieldTriggerStyles, "w-48", className)}
      >
        <span className={cn(!selected && "text-quebi-fg-subtle")}>
          {selected ? formatter.format(selected.toDate("UTC")) : placeholder}
        </span>
        <ChevronDown data-slot="icon" className="text-quebi-fg-subtle" />
      </FieldTrigger>
      <PopoverContent placement={placement} className="w-auto max-w-none p-3">
        <MonthPicker
          {...props}
          autoFocus
          aria-label={ariaLabel ?? "Month"}
          value={selected}
          onChange={(next) => {
            if (value === undefined) setUncontrolled(next)
            onChange?.(next)
            setIsOpen(false)
          }}
        />
      </PopoverContent>
    </Popover>
  )
}

interface PagerHeaderProps {
  label: string
  previousLabel: string
  nextLabel: string
  onPrevious: () => void
  onNext: () => void
  isDisabled?: boolean
}

/**
 * The prev / label / next row above the grid.
 *
 * Not `CalendarHeader` from `@/components/calendar`: its buttons are
 * `slot="previous"` / `slot="next"`, which only resolve inside a react-aria
 * Calendar. These page local state instead. `aria-live` on the label is what
 * announces the new year, since paging moves nothing into focus.
 *
 * Both carry `slot={null}`, because this grid *is* rendered inside a react-aria
 * Calendar — `CalendarHeader`'s `select` variant opens it in a popover, and a
 * `Button` under a Calendar reads a `ButtonContext` that is a slot map. Without
 * a `slot` it throws ("A slot prop is required"); with `previous` or `next` it
 * would page the calendar's visible range instead of this grid's year.
 */
function PagerHeader({
  label,
  previousLabel,
  nextLabel,
  onPrevious,
  onNext,
  isDisabled,
}: PagerHeaderProps) {
  const { direction } = useLocale()
  const Previous = direction === "rtl" ? ChevronRight : ChevronLeft
  const Next = direction === "rtl" ? ChevronLeft : ChevronRight

  return (
    <header
      data-slot="picker-header"
      className="flex w-full items-center justify-between gap-1.5 ps-1.5 pe-1 pt-1 pb-4"
    >
      <span aria-live="polite" className="font-medium text-quebi-fg text-sm tabular-nums">
        {label}
      </span>
      <div className="flex items-center gap-1">
        <Button
          aria-label={previousLabel}
          size="sq-sm"
          className="size-8 sm:size-7 **:data-[slot=icon]:text-quebi-fg-muted"
          isCircle
          intent="ghost"
          slot={null}
          isDisabled={isDisabled}
          onPress={onPrevious}
        >
          <Previous data-slot="icon" />
        </Button>
        <Button
          aria-label={nextLabel}
          size="sq-sm"
          className="size-8 sm:size-7 **:data-[slot=icon]:text-quebi-fg-muted"
          isCircle
          intent="ghost"
          slot={null}
          isDisabled={isDisabled}
          onPress={onNext}
        >
          <Next data-slot="icon" />
        </Button>
      </div>
    </header>
  )
}

/** What a picker with no calendar system of its own counts in. */
const GREGORIAN = new GregorianCalendar()

/**
 * A stable collection key for a month — `AD-2026-09`, not an index into a page.
 * Era-qualified for the reason `isCurrent` is: year 1 of two eras is two years.
 */
const keyOf = (date: CalendarDate) =>
  `${date.era}-${date.year}-${String(date.month).padStart(2, "0")}`

/** True if any day of `month` falls inside `[minValue, maxValue]`. */
function isMonthAvailable(month: CalendarDate, minValue?: DateValue, maxValue?: DateValue) {
  if (minValue && endOfMonth(month).compare(minValue) < 0) return false
  if (maxValue && month.compare(maxValue) > 0) return false
  return true
}

/**
 * `date` pulled inside `[minValue, maxValue]`.
 *
 * The first of a selectable month can still sit before a mid-month `minValue`,
 * and handing a consumer a value its own bounds reject is the kind of thing
 * that only shows up in their validation.
 */
function clamp(date: CalendarDate, minValue?: DateValue, maxValue?: DateValue) {
  // Back into `date`'s calendar on the way out: `compare` is absolute, so a
  // bound given in another system compares fine but must not be handed on raw.
  if (minValue && date.compare(minValue) < 0)
    return toCalendar(toCalendarDate(minValue), date.calendar)
  if (maxValue && date.compare(maxValue) > 0)
    return toCalendar(toCalendarDate(maxValue), date.calendar)
  return date
}