{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "month-picker",
  "title": "Month Picker",
  "description": "A month picker: a year of twelve months as a grid, with arrows on the year, that hands back a plain \"YYYY-MM\" string — \"2026-08\" — and never a day or a time zone. Reach for it wherever the thing being chosen is the month itself: a billing or subscription cycle, the period on an invoice or a statement, a monthly report or export, the target month on an expense claim or a timesheet, payroll and accounting periods, budget and forecast months, a cohort in a retention table, the month a goal or OKR is scored in, \"as of\" month on a snapshot, the archive month on a blog or changelog, card expiry, and the period selector above an analytics dashboard, chart or ledger — usually paired as two of them for a from/to range. Common asks it answers: \"month picker\", \"month year picker\", \"monthpicker react\", \"select a month component\", \"month and year select\", \"billing period picker\", \"monthly report period selector\", \"choose month for dashboard\", \"shadcn month picker\", \"shadcn calendar month only\", \"MUI DatePicker views month equivalent\", \"antd DatePicker picker=month equivalent\", \"react-datepicker showMonthYearPicker alternative\", \"YYYY-MM input\". shadcn/ui has no month selection anywhere: its calendar is a react-day-picker wrapper that pulls in react-day-picker and date-fns and returns a Date for a day, and captionLayout=\"dropdown\" adds month and year dropdowns for *moving* through that grid rather than for answering with a month; the Date Picker page is that same calendar inside a popover; select, native-select and combobox are empty controls that know nothing about months. Distinct from pulld date-input, which types a full date down to the day, and from pulld calendar-heatmap, which draws a year of days rather than choosing one of its months. The value is a calendar month rather than an instant, and the component holds that line: there is deliberately no Date accessor, because handing one back means having silently picked a day and a zone — the bug that starts a billing period on the last day of the previous month for everyone west of UTC. `toMonthValue(date)` reads local fields going in (the \"toISOString().slice(0, 7)\" one-liner is a month early for half the planet after 22:00), `parseMonthValue` gives back { year, month } and rejects anything that is not a bare month, and the strings sort and compare as they read. Month names come from `Intl.DateTimeFormat`, so the grid is already in the reader's language with zero dependencies — no date library, no icon package, one file. Both the locale and the \"which month is now\" marker are resolved after mount, so a server render and the browser's first paint agree instead of tripping a hydration mismatch, and passing `locale` skips the swap entirely. It is a real `role=\"grid\"` with a roving tabindex — one tab stop for the whole year, then arrow keys inside it. Left and right step a month and cross into the neighbouring year at the edges rather than dead-ending in December, up and down move a row and follow the `columns` prop, Home and End go to January and December (rows here are a layout choice, not a calendar week), and PageUp/PageDown hold the month and walk the years. On an RTL page left and right follow the writing direction instead of running backwards. Every cell is named with the month spelled out and its year — \"August 2026\", localised — because \"Aug\" alone stops meaning anything once the arrows have moved, and the current month carries aria-current=\"date\". `min` and `max` take the same \"YYYY-MM\" strings and stop the year arrows as well as the cells, `isMonthDisabled` handles scattered holes like closed accounting periods without locking the arrows, and unavailable months are marked with aria-disabled rather than disabled so they can still be reached and read instead of being invisibly skipped. Give it a `name` and it posts with a plain form or a server action through a hidden input. Uncontrolled, controlled, or controlled on the year alone; a value set from outside pulls the grid to that year so the selection is never off screen. Styled entirely with shadcn tokens (primary, accent, input, ring, muted-foreground), so it follows light and dark mode.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/ui/month-picker.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst MONTHS_IN_YEAR = 12\n\n/**\n * The locale the month names are formatted in before hydration.\n *\n * The runtime's own locale is a property of the machine, so the server's and the browser's can\n * disagree — and month names are the one thing on this component that would then differ between\n * the two renders. Both sides therefore start from the same constant and the real locale swaps in\n * on mount. Passing `locale` skips the swap entirely and is the fix if the first paint matters.\n */\nconst HYDRATION_LOCALE = \"en-US\"\n\n/** \"2026-08\". Anchored so a stray day part (\"2026-08-01\") is rejected rather than half-read. */\nconst MONTH_VALUE_PATTERN = /^(\\d{4,})-(0[1-9]|1[0-2])$/\n\n/** A single ordering key for a calendar month, so `min`/`max` compare with `<` instead of strings. */\nfunction ordinalOf(year: number, monthIndex: number): number {\n  return year * MONTHS_IN_YEAR + monthIndex\n}\n\n/**\n * The years a `YYYY-MM` string can actually hold.\n *\n * Navigation is unbounded unless `min`/`max` say otherwise, so without these the arrows walk past\n * year zero and `valueOf` starts emitting things like \"00-1-12\" — a string this component's own\n * parser rejects, handed to the caller as though it were a month. The far end is the same story\n * one digit up.\n */\nconst FIRST_ORDINAL = ordinalOf(0, 0)\nconst LAST_ORDINAL = ordinalOf(9999, MONTHS_IN_YEAR - 1)\n\nfunction valueOf(year: number, monthIndex: number): string {\n  return `${String(year).padStart(4, \"0\")}-${String(monthIndex + 1).padStart(2, \"0\")}`\n}\n\n/**\n * The calendar month a `Date` falls in, read from its **local** fields — the month the person\n * looking at that date would name.\n *\n * `toISOString().slice(0, 7)` is the tempting one-liner and it is wrong for half the planet: it\n * reads UTC, so 23:00 on 31 August in Berlin comes back as September.\n */\nexport function toMonthValue(date: Date): string {\n  return valueOf(date.getFullYear(), date.getMonth())\n}\n\n/**\n * `{ year, month }` with **month 1–12** as written in the string, or null when the string is not a\n * month value.\n *\n * There is deliberately no `Date` accessor here. A month is a calendar span, not an instant, and\n * anything that hands back a `Date` has quietly chosen a day and a time zone on the caller's\n * behalf — the bug that makes a billing period start on the last day of the previous month for\n * everyone west of UTC. Build the instant where you know the zone: `new Date(year, month - 1, 1)`.\n */\nexport function parseMonthValue(value: string): { year: number; month: number } | null {\n  const parsed = MONTH_VALUE_PATTERN.exec(value)\n  if (!parsed) return null\n  return { year: Number(parsed[1]), month: Number(parsed[2]) }\n}\n\nfunction ordinalFromValue(value: string | undefined): number | null {\n  if (!value) return null\n  const parsed = parseMonthValue(value)\n  return parsed ? ordinalOf(parsed.year, parsed.month - 1) : null\n}\n\n/**\n * The first of a month, as UTC, safe for years under 100.\n *\n * `new Date(Date.UTC(50, 0, 1))` is the year **1950**: the two-digit-year rule from the original\n * Date constructor still applies to `Date.UTC`. Setting the year afterwards is the documented way\n * out, and it is the difference between a year-99 archive picker being right and being off by\n * nineteen centuries without saying so.\n */\nfunction firstOfMonthUTC(year: number, monthIndex: number): Date {\n  const at = new Date(Date.UTC(2000, monthIndex, 1))\n  at.setUTCFullYear(year)\n  return at\n}\n\nexport interface MonthPickerProps\n  extends Omit<\n    React.ComponentPropsWithoutRef<\"div\">,\n    \"onChange\" | \"value\" | \"defaultValue\" | \"children\"\n  > {\n  /** Controlled month as `YYYY-MM`, e.g. \"2026-08\". Pair with `onValueChange`. */\n  value?: string\n  /** Starting month for an uncontrolled picker. Ignored once `value` is passed. */\n  defaultValue?: string\n  /** Called with the chosen `YYYY-MM`. Never called for a month outside `min`/`max`. */\n  onValueChange?: (value: string) => void\n  /** Controlled year on display. Pair with `onYearChange` — see the note on that prop. */\n  year?: number\n  /**\n   * Year the grid opens on when nothing is selected. Also the way to make a server render\n   * deterministic: without any of `value`, `defaultValue` or `year`, the opening year comes from\n   * the machine's clock, which server and browser can disagree about on New Year's Eve.\n   */\n  defaultYear?: number\n  /**\n   * Called with the year the grid moved to. **Required if you pass `year`**: arrow keys leave the\n   * displayed year at its edges, and a controlled year that is never updated pins them inside it.\n   */\n  onYearChange?: (year: number) => void\n  /** Earliest selectable month as `YYYY-MM`, inclusive. Also stops the year arrows. */\n  min?: string\n  /** Latest selectable month as `YYYY-MM`, inclusive. Also stops the year arrows. */\n  max?: string\n  /**\n   * Disables individual months on top of `min`/`max` — closed accounting periods, months with no\n   * data. Called with `YYYY-MM`. Unlike `min`/`max` it does not stop the year arrows, since holes\n   * can be scattered and a year of them is still worth being able to look at.\n   */\n  isMonthDisabled?: (value: string) => boolean\n  /** BCP-47 tag for the month names, e.g. \"ja-JP\". Defaults to the browser's own locale. */\n  locale?: string\n  /** How month names are written. Default \"short\" (\"Aug\"); \"long\" gives \"August\". */\n  monthFormat?: \"short\" | \"long\" | \"narrow\"\n  /** Months per row. 12 divides by all three, so no row is ever short. Default 3. */\n  columns?: 2 | 3 | 4\n  /** Submits the value with a surrounding form, through a hidden input. */\n  name?: string\n  /** Accessible name of the back arrow. */\n  previousYearLabel?: string\n  /** Accessible name of the forward arrow. */\n  nextYearLabel?: string\n}\n\n/**\n * A year of months as a grid — pick the month itself, not a day inside it.\n *\n * ```tsx\n * const [month, setMonth] = React.useState(\"2026-08\")\n *\n * return <MonthPicker value={month} onValueChange={setMonth} max={toMonthValue(new Date())} />\n * ```\n *\n * The value is a plain `YYYY-MM` string: sortable, comparable, free of any day or time zone, and\n * the same thing an API means by `?period=2026-08`.\n */\nexport const MonthPicker = React.forwardRef<HTMLDivElement, MonthPickerProps>(function MonthPicker(\n  {\n    className,\n    value: valueProp,\n    defaultValue,\n    onValueChange,\n    year: yearProp,\n    defaultYear,\n    onYearChange,\n    min,\n    max,\n    isMonthDisabled,\n    locale,\n    monthFormat = \"short\",\n    columns = 3,\n    name,\n    previousYearLabel = \"Previous year\",\n    nextYearLabel = \"Next year\",\n    ...props\n  },\n  ref\n) {\n  const captionId = React.useId()\n\n  /**\n   * Guards the two things here that come from the machine rather than from props: the locale the\n   * month names are formatted in, and which month is \"this\" one. Both are rendered as their\n   * neutral form until mount, so the server's output and the browser's first render agree.\n   */\n  const [mounted, setMounted] = React.useState(false)\n  React.useEffect(() => {\n    setMounted(true)\n  }, [])\n\n  const isValueControlled = valueProp !== undefined\n  const [uncontrolledValue, setUncontrolledValue] = React.useState(defaultValue ?? \"\")\n  const value = isValueControlled ? valueProp : uncontrolledValue\n\n  const isYearControlled = yearProp !== undefined\n  const [uncontrolledYear, setUncontrolledYear] = React.useState(() => {\n    const selected = parseMonthValue(valueProp ?? defaultValue ?? \"\")\n    if (selected) return selected.year\n    if (defaultYear !== undefined) return defaultYear\n    return new Date().getFullYear()\n  })\n  const displayYear = isYearControlled ? yearProp : uncontrolledYear\n\n  const [focusedIndex, setFocusedIndex] = React.useState(() => {\n    const selected = parseMonthValue(valueProp ?? defaultValue ?? \"\")\n    return selected ? selected.month - 1 : 0\n  })\n\n  /**\n   * A selection made from outside pulls the grid to it. Without this a parent that sets the value\n   * to a month in another year leaves the grid where it was, showing twelve unselected cells with\n   * no hint that the choice landed somewhere off screen.\n   *\n   * Written as an adjustment during render rather than an effect so the corrected year paints in\n   * the same commit as the new value, with no frame showing the old one.\n   */\n  const [lastValue, setLastValue] = React.useState(value)\n  if (value !== lastValue) {\n    setLastValue(value)\n    const selected = parseMonthValue(value)\n    if (selected) {\n      setFocusedIndex(selected.month - 1)\n      if (!isYearControlled && selected.year !== uncontrolledYear) {\n        setUncontrolledYear(selected.year)\n      }\n    }\n  }\n\n  const labelLocale = locale ?? (mounted ? undefined : HYDRATION_LOCALE)\n  const months = React.useMemo(() => {\n    const short = new Intl.DateTimeFormat(labelLocale, { month: monthFormat, timeZone: \"UTC\" })\n    // The visible label can be an abbreviation, and \"Aug\" on its own stops meaning anything once\n    // the year arrows have moved. Every cell therefore carries the month spelled out with its\n    // year, which is also what makes the grid usable when the caption is off screen.\n    const full = new Intl.DateTimeFormat(labelLocale, {\n      month: \"long\",\n      year: \"numeric\",\n      timeZone: \"UTC\",\n    })\n    return Array.from({ length: MONTHS_IN_YEAR }, (_, index) => {\n      const at = firstOfMonthUTC(displayYear, index)\n      return { text: short.format(at), label: full.format(at) }\n    })\n  }, [labelLocale, monthFormat, displayYear])\n\n  const currentOrdinal = React.useMemo(() => {\n    if (!mounted) return null\n    const now = new Date()\n    return ordinalOf(now.getFullYear(), now.getMonth())\n  }, [mounted])\n\n  const minOrdinal = ordinalFromValue(min)\n  const maxOrdinal = ordinalFromValue(max)\n  const selectedOrdinal = ordinalFromValue(value)\n\n  const outOfRange = (ordinal: number) =>\n    (minOrdinal !== null && ordinal < minOrdinal) || (maxOrdinal !== null && ordinal > maxOrdinal)\n\n  const disabledAt = (index: number) => {\n    const ordinal = ordinalOf(displayYear, index)\n    if (outOfRange(ordinal)) return true\n    return isMonthDisabled ? isMonthDisabled(valueOf(displayYear, index)) : false\n  }\n\n  const canGoBack =\n    ordinalOf(displayYear - 1, MONTHS_IN_YEAR - 1) >= FIRST_ORDINAL &&\n    (minOrdinal === null || ordinalOf(displayYear - 1, MONTHS_IN_YEAR - 1) >= minOrdinal)\n  const canGoForward =\n    ordinalOf(displayYear + 1, 0) <= LAST_ORDINAL &&\n    (maxOrdinal === null || ordinalOf(displayYear + 1, 0) <= maxOrdinal)\n\n  const gridRef = React.useRef<HTMLDivElement>(null)\n  const cellsRef = React.useRef<Array<HTMLButtonElement | null>>([])\n  // Set only by keyboard navigation, so the grid never steals focus on mount or on a parent's\n  // unrelated re-render — it moves focus when, and only when, the user asked it to.\n  const focusPending = React.useRef(false)\n\n  React.useEffect(() => {\n    if (!focusPending.current) return\n    focusPending.current = false\n    cellsRef.current[focusedIndex]?.focus()\n  }, [focusedIndex, displayYear])\n\n  const changeYear = (next: number) => {\n    if (!isYearControlled) setUncontrolledYear(next)\n    onYearChange?.(next)\n  }\n\n  const select = (index: number) => {\n    if (disabledAt(index)) return\n    const next = valueOf(displayYear, index)\n    setFocusedIndex(index)\n    if (!isValueControlled) setUncontrolledValue(next)\n    onValueChange?.(next)\n  }\n\n  /** Walks the calendar, not the grid: a step off either edge lands in the neighbouring year. */\n  const moveFocusTo = (ordinal: number) => {\n    let target = Math.min(Math.max(ordinal, FIRST_ORDINAL), LAST_ORDINAL)\n    if (minOrdinal !== null && target < minOrdinal) target = minOrdinal\n    if (maxOrdinal !== null && target > maxOrdinal) target = maxOrdinal\n    const nextYear = Math.floor(target / MONTHS_IN_YEAR)\n    focusPending.current = true\n    setFocusedIndex(target - nextYear * MONTHS_IN_YEAR)\n    if (nextYear !== displayYear) changeYear(nextYear)\n  }\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    // The rows are laid out with the writing direction, so on an RTL page the cell to the right of\n    // the focused one is the *earlier* month. Read at event time, where there is a real element and\n    // no render to keep deterministic.\n    const rtl = gridRef.current\n      ? getComputedStyle(gridRef.current).direction === \"rtl\"\n      : false\n    const inline = rtl ? -1 : 1\n    const current = ordinalOf(displayYear, focusedIndex)\n    let target: number\n    switch (event.key) {\n      case \"ArrowRight\":\n        target = current + inline\n        break\n      case \"ArrowLeft\":\n        target = current - inline\n        break\n      case \"ArrowDown\":\n        target = current + columns\n        break\n      case \"ArrowUp\":\n        target = current - columns\n        break\n      // Rows here are a layout choice — two, three or four across — and mean nothing on a calendar,\n      // so Home and End go to the ends of the *year* rather than of the row as they would in a\n      // grid of data.\n      case \"Home\":\n        target = ordinalOf(displayYear, 0)\n        break\n      case \"End\":\n        target = ordinalOf(displayYear, MONTHS_IN_YEAR - 1)\n        break\n      case \"PageUp\":\n        target = current - MONTHS_IN_YEAR\n        break\n      case \"PageDown\":\n        target = current + MONTHS_IN_YEAR\n        break\n      default:\n        return\n    }\n    event.preventDefault()\n    moveFocusTo(target)\n  }\n\n  const rows: number[][] = []\n  for (let start = 0; start < MONTHS_IN_YEAR; start += columns) {\n    rows.push(Array.from({ length: columns }, (_, offset) => start + offset))\n  }\n\n  return (\n    <div ref={ref} className={cn(\"w-full max-w-xs space-y-3\", className)} {...props}>\n      <div className=\"flex items-center justify-between gap-2\">\n        <YearArrow\n          direction=\"back\"\n          label={previousYearLabel}\n          disabled={!canGoBack}\n          onClick={() => changeYear(displayYear - 1)}\n        />\n        {/*\n          Live because the arrows change what the grid means without moving focus: a sighted user\n          watches the year tick over, and this is the same event reaching everyone else.\n        */}\n        <div\n          id={captionId}\n          aria-live=\"polite\"\n          className=\"flex-1 text-center text-sm font-medium tabular-nums\"\n        >\n          {displayYear}\n        </div>\n        <YearArrow\n          direction=\"forward\"\n          label={nextYearLabel}\n          disabled={!canGoForward}\n          onClick={() => changeYear(displayYear + 1)}\n        />\n      </div>\n\n      <div\n        ref={gridRef}\n        role=\"grid\"\n        aria-labelledby={captionId}\n        onKeyDown={handleKeyDown}\n        className=\"space-y-1\"\n      >\n        {rows.map((row) => (\n          <div key={row[0]} role=\"row\" className=\"flex gap-1\">\n            {row.map((index) => {\n              const ordinal = ordinalOf(displayYear, index)\n              const isSelected = selectedOrdinal === ordinal\n              const isCurrent = currentOrdinal === ordinal\n              const isDisabled = disabledAt(index)\n              return (\n                <div key={index} role=\"gridcell\" aria-selected={isSelected} className=\"flex-1\">\n                  <button\n                    ref={(node) => {\n                      cellsRef.current[index] = node\n                    }}\n                    type=\"button\"\n                    // Roving tabindex: one stop for the whole grid, then the arrow keys inside it.\n                    // Twelve tab stops per year is the thing this replaces.\n                    tabIndex={index === focusedIndex ? 0 : -1}\n                    aria-label={months[index].label}\n                    // `aria-disabled` rather than `disabled`, so an unavailable month can still be\n                    // reached and read. A month the arrow keys skip over silently is a month the\n                    // user cannot tell exists.\n                    aria-disabled={isDisabled || undefined}\n                    aria-current={isCurrent ? \"date\" : undefined}\n                    onClick={() => select(index)}\n                    onFocus={() => setFocusedIndex(index)}\n                    className={cn(\n                      \"inline-flex h-9 w-full items-center justify-center rounded-md px-1 text-sm font-normal transition-colors\",\n                      \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n                      isDisabled\n                        ? \"cursor-default text-muted-foreground opacity-50\"\n                        : \"hover:bg-accent hover:text-accent-foreground\",\n                      isCurrent && !isSelected && \"bg-accent/60 font-medium text-accent-foreground\",\n                      isSelected &&\n                        \"bg-primary font-medium text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground\"\n                    )}\n                  >\n                    {months[index].text}\n                  </button>\n                </div>\n              )\n            })}\n          </div>\n        ))}\n      </div>\n\n      {/* Lets the picker post with a plain form or a server action, with no state plumbing. */}\n      {name ? <input type=\"hidden\" name={name} value={value} /> : null}\n    </div>\n  )\n})\n\nfunction YearArrow({\n  direction,\n  label,\n  disabled,\n  onClick,\n}: {\n  direction: \"back\" | \"forward\"\n  label: string\n  disabled: boolean\n  onClick: () => void\n}) {\n  return (\n    <button\n      type=\"button\"\n      onClick={onClick}\n      disabled={disabled}\n      aria-label={label}\n      className={cn(\n        \"inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-input bg-transparent text-muted-foreground transition-colors\",\n        \"hover:bg-accent hover:text-accent-foreground\",\n        \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n        \"disabled:pointer-events-none disabled:opacity-40\"\n      )}\n    >\n      {/* Inline, so two glyphs cost no icon dependency. */}\n      <svg\n        className=\"h-4 w-4 rtl:-scale-x-100\"\n        width=\"16\"\n        height=\"16\"\n        viewBox=\"0 0 24 24\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth=\"2\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        aria-hidden=\"true\"\n      >\n        <path d={direction === \"back\" ? \"m15 18-6-6 6-6\" : \"m9 18 6-6-6-6\"} />\n      </svg>\n    </button>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}