Month Picker
Pick a month or a month range: a year at a time in a 4 × 3 grid, year arrows, quick presets, and disabled future months.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/month-picker.json'use client'
import { useRef, useState } from 'react'
import './month-picker.css'
type YM = { y: number; m: number }
const MS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
const FULL = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
const k = (v: YM) => v.y * 12 + v.m
const from = (n: number): YM => ({ y: Math.floor(n / 12), m: n % 12 })
const lbl = (v: YM) => `${MS[v.m]} ${v.y}`
export interface MonthPickerProps {
mode?: 'single' | 'range'
max?: YM
onChange?: (v: { start: YM; end: YM } | YM) => void
fill?: 'dark' | 'light'
className?: string
}
/** Month and month-range picker. */
export function MonthPicker({ mode = 'range', max = { y: 2026, m: 10 }, onChange, fill = 'dark', className }: MonthPickerProps) {
const [year, setYear] = useState(max.y)
const [a, setA] = useState<YM | null>({ y: 2026, m: 7 })
const [b, setB] = useState<YM | null>(mode === 'range' ? { y: 2026, m: 9 } : null)
const [hover, setHover] = useState<YM | null>(null)
const [focus, setFocus] = useState(max.m)
const refs = useRef<(HTMLButtonElement | null)[]>([])
const pick = (v: YM) => {
if (mode === 'single') { setA(v); setB(null); onChange?.(v); return }
if (!a || b) { setA(v); setB(null); return }
const [s, e] = k(v) < k(a) ? [v, a] : [a, v]; setA(s); setB(e); onChange?.({ start: s, end: e })
}
const preset = (s: number, e: number) => { const S = from(s), E = from(e); setA(S); setB(E); setYear(E.y); onChange?.({ start: S, end: E }) }
const lo = a ? k(a) : -1, hi = b ? k(b) : hover && a && mode === 'range' ? k(hover) : lo
const [r0, r1] = [Math.min(lo, hi), Math.max(lo, hi)]
const mx = k(max)
const count = a && b ? k(b) - k(a) + 1 : a ? 1 : 0
return (
<div className={['sd-mpk', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-mpk__h"><button type="button" aria-label="Previous year" onClick={() => setYear(year - 1)}>‹</button><b aria-live="polite">{year}</b><button type="button" aria-label="Next year" disabled={year >= max.y} onClick={() => setYear(year + 1)}>›</button></div>
<div className="sd-mpk__g" role="grid" aria-label={`Months of ${year}`} onPointerLeave={() => setHover(null)}>
{MS.map((m, i) => {
const v = { y: year, m: i }, n = k(v), dis = n > mx
const inR = lo >= 0 && n >= r0 && n <= r1, isS = n === r0 && inR, isE = n === r1 && inR
return (
<button key={m} ref={(el) => { refs.current[i] = el }} type="button" className="sd-mpk__m" disabled={dis} aria-label={`${FULL[i]} ${year}`} aria-pressed={n === lo || (!!b && n === k(b))}
data-in={(mode === 'range' && inR && r0 !== r1) || undefined} data-start={(mode === 'range' && isS) || undefined} data-end={(mode === 'range' && isE) || undefined} data-now={n === mx || undefined}
tabIndex={i === focus ? 0 : -1} onClick={() => pick(v)} onPointerEnter={() => setHover(v)} onFocus={() => setFocus(i)}
onKeyDown={(e) => { const d = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -3, ArrowDown: 3 }[e.key]; if (e.key === 'PageUp') { e.preventDefault(); setYear(year - 1) } else if (e.key === 'PageDown' && year < max.y) { e.preventDefault(); setYear(year + 1) } else if (d !== undefined) { e.preventDefault(); const j = Math.max(0, Math.min(11, i + d)); setFocus(j); refs.current[j]?.focus() } }}>
<span>{m}</span>
</button>
)
})}
</div>
{mode === 'range' && <div className="sd-mpk__p" aria-label="Presets">
<button type="button" onClick={() => preset(mx - (max.m % 3), mx)}>This quarter</button>
<button type="button" onClick={() => preset(mx - 5, mx)}>Last 6 months</button>
<button type="button" onClick={() => preset(max.y * 12, mx)}>Year to date</button>
</div>}
<div className="sd-mpk__v" aria-live="polite">{a ? (mode === 'range' ? (b ? <><b>{lbl(a)} – {lbl(b)}</b> · {count} months</> : <>From <b>{lbl(a)}</b>, pick an end</>) : <b>{FULL[a.m]} {a.y}</b>) : 'No month selected'}</div>
</div>
)
}.sd-mpk { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-mpk[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-mpk :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-mpk :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-mpk .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-mpk { width: min(100%, 300px); padding: 14px; grid-template-columns: minmax(0, 1fr); border-radius: 18px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); display: grid; gap: 10px; }
.sd-mpk__h { display: flex; align-items: center; justify-content: space-between; }
.sd-mpk__h b { font-size: 15px; font-variant-numeric: tabular-nums; }
.sd-mpk__h button { width: 30px; height: 30px; border-radius: 8px; display: grid; place-items: center; font-size: 15px; }
.sd-mpk__h button:hover:not(:disabled) { background: var(--soft); }
.sd-mpk__h button:disabled { opacity: 0.3; }
.sd-mpk__g { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px 0; }
.sd-mpk__m { height: 40px; font-size: 13px; position: relative; }
.sd-mpk__m span { position: relative; z-index: 1; display: grid; place-items: center; height: 100%; margin: 0 4px; border-radius: 10px; }
.sd-mpk__m:hover:not(:disabled) span { box-shadow: inset 0 0 0 1px var(--line); }
.sd-mpk__m[data-in]::before { content: ''; position: absolute; inset: 0; background: var(--soft); }
.sd-mpk__m[data-start]::before { left: 50%; }
.sd-mpk__m[data-end]::before { right: 50%; }
.sd-mpk__m[data-start][data-end]::before { display: none; }
.sd-mpk__m[aria-pressed='true'] span { background: var(--strong) !important; color: var(--on) !important; font-weight: 600; }
.sd-mpk__m[data-now] span { text-decoration: underline; text-underline-offset: 3px; }
.sd-mpk__m:disabled { color: var(--mute) !important; opacity: 0.4; cursor: default; }
.sd-mpk__p { display: flex; gap: 5px; flex-wrap: wrap; }
.sd-mpk__p button { padding: 5px 10px; border-radius: 99px; font-size: 12px; box-shadow: inset 0 0 0 1px var(--line); }
.sd-mpk__p button:hover { background: var(--soft); }
.sd-mpk__v { font-size: 12.5px; color: var(--mute); }
.sd-mpk__v b { color: var(--fg); }
@media (prefers-reduced-motion: reduce) { .sd-mpk *, .sd-mpk *::before, .sd-mpk *::after { animation: none !important; transition: none !important; } }Overview
Months sit in a grid with the year in the header. In range mode the first tap sets the start and the second the end; hovering previews the span.
Presets cover this quarter, last six months and year to date. Months after the max are disabled.
When to use
- Reports, billing statements, expiry dates.
When not to use
- Picking a specific day: use the date picker.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/month-picker.json$
npx shadcn@latest add https://sundaeui.dev/r/month-picker.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/month-picker.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/month-picker.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy month-picker.tsx and month-picker.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { MonthPicker } from '@/components/sundae/month-picker'
<MonthPicker />Props
| Prop | Type | Default |
|---|---|---|
modeSelection type. | 'single' | 'range' | 'range' |
maxLatest selectable month. | { y: number; m: number } | — |
onChangeCalled on selection. | (v) => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Months are a grid of buttons with full month and year names; aria-pressed marks selection.
- Arrow keys move; year buttons are labelled.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-mpk; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
Keyboard
- Arrow keys
- Move between months
- Page Up / Down
- Change year
FAQ
- Is Month Picker free?
- Yes. Month Picker is free for personal and commercial projects, with full source on this page.
- How do I install Month Picker?
- Run npx shadcn@latest add https://sundaeui.dev/r/month-picker.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Month Picker support keyboard and screen readers?
- Months are a grid of buttons with full month and year names; aria-pressed marks selection. Arrow keys move; year buttons are labelled.
- Does Month Picker respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All forms →- Pro
Date Range Presets
Forms · Refined - Pro
Wheel Picker
Forms · Refined Time Slots
Forms · RefinedWeek Strip
Forms · RefinedRepeat Picker
Forms · RefinedTime Field
Forms · Refined