Skip to content

Month Picker

Form Inputs / Date and TimeSince 0.3.0
import { MonthPicker } from '@absoluteui/components/month-picker';
import '@absoluteui/components/month-picker/style.css';

Using Angular? See Month Picker for Angular.

Entry of a calendar month without a day. MonthPicker renders locale-ordered month and year segments inside a labelled group, plus a “Choose month” button that opens a non-modal dialog popup with a grid of the twelve months of a year (paged by year). The value is a PlainYearMonth ({ kind: 'PlainYearMonth', year, month }, created with plainYearMonth(year, month)); it has no day and no timezone. Validation is localized and a hidden input submits ISO 8601 YYYY-MM. Month, Year, and Quarter Picker share one engine and behave the same apart from the period.

  • A month without a day: billing or reporting periods, card expiry, a month of employment, a monthly budget.
  • Users should be able to type a month quickly and also look it up in a grid of months.
  • Month fields with bounds or unavailable months (for example months already closed).
  • A specific day: use Date Picker or Date Input.
  • A whole year: use Year Picker. A calendar quarter: use Quarter Picker.
  • A range of months: use two Month Pickers and a cross-field rule.
  • A handful of fixed months that should stay visible: use Radio Group or Select.
import { plainYearMonth } from '@absoluteui/core';
import { MonthPicker } from '@absoluteui/components/month-picker';
const host = document.createElement('div');
document.body.append(host);
const picker = MonthPicker.create(host, {
label: 'Billing month',
name: 'billingMonth',
required: true,
min: plainYearMonth(2024, 1),
max: plainYearMonth(2026, 12),
});
host.addEventListener('aui-change', () => {
host.dataset.valid = String(picker.validity.valid);
});

Unavailable months, custom validation, and the change detail

Section titled “Unavailable months, custom validation, and the change detail”
import { plainYearMonthToString } from '@absoluteui/core';
import { MonthPicker, type MonthPickerChangeDetail } from '@absoluteui/components/month-picker';
const host = document.createElement('div');
document.body.append(host);
MonthPicker.create(host, {
label: 'Report month',
isMonthDisabled: (value) => value.month === 12,
validate: (value) => (value !== null && value.year < 2020 ? 'Reports start in 2020.' : null),
closeOnSelect: false,
});
host.addEventListener('aui-change', (event) => {
const { value } = (event as CustomEvent<MonthPickerChangeDetail>).detail;
host.dataset.month = value ? plainYearMonthToString(value) : '';
});
import { configureAbsoluteUI } from '@absoluteui/core';
import '@absoluteui/components/month-picker';
configureAbsoluteUI({ common: { timezone: 'Europe/Berlin' }, components: { monthPicker: { closeOnSelect: false, validationTiming: 'submit' } } });
import { plainYearMonthToString } from '@absoluteui/core';
import { MonthPicker, type MonthPickerChangeDetail } from '@absoluteui/components/month-picker';
import '@absoluteui/components/month-picker/style.css';
const host = document.querySelector<HTMLElement>('#expiry');
if (host) {
MonthPicker.create(host, { label: 'Card expiry', name: 'expiry', required: true });
host.addEventListener('aui-change', (event) => {
const { value } = (event as CustomEvent<MonthPickerChangeDetail>).detail;
host.dataset.month = value ? plainYearMonthToString(value) : '';
});
}
import { plainYearMonth } from '@absoluteui/core';
import { MonthPicker } from '@absoluteui/components/month-picker';
const host = document.createElement('div');
document.body.append(host);
MonthPicker.create(host, { label: 'Billing month', name: 'billingMonth', required: true, min: plainYearMonth(2024, 1), max: plainYearMonth(2026, 12) });
<div class="aui-field aui-period-picker aui-month-picker" data-aui-control> (consumer host; registered with Form)
<label class="aui-field__label">Label<span class="aui-field__required" aria-hidden="true">*</span></label>
<div class="aui-month-picker__group aui-period-picker__group" role="group" aria-labelledby=label aria-describedby>
<div class="aui-period-picker__fields"> (LTR-isolated run)
<input class="aui-period-picker__segment aui-period-picker__segment--month" aria-label="Month" placeholder="mm">
<span class="aui-period-picker__separator" aria-hidden="true">/</span>
<input class="aui-period-picker__segment aui-period-picker__segment--year" aria-label="Year" placeholder="yyyy">
</div>
<button class="aui-period-picker__button" aria-haspopup="dialog" aria-expanded aria-controls=popup aria-label="Choose month">
</div>
<div class="aui-field__description"> (hidden when empty)
<div class="aui-field__error aui-validation-message"> (hidden when no message is shown)
<input type="hidden" name>
<div class="aui-period-picker__popup" role="dialog" aria-modal="false" aria-label="Choose month" popover="manual">
<div class="aui-period-picker__header">
<button class="aui-period-picker__nav aui-period-picker__prev" aria-label="Previous year">
<div class="aui-period-picker__heading" aria-live="polite">2026</div>
<button class="aui-period-picker__nav aui-period-picker__next" aria-label="Next year">
</div>
<div class="aui-period-picker__grid aui-month-picker__grid" role="grid" aria-labelledby=heading>
<div class="aui-period-picker__row" role="row"> (4 rows of 3)
<div class="aui-period-picker__cell" role="gridcell" aria-label="January 2026" tabindex="-1">
<span class="aui-period-picker__cell-text">Jan</span>
</div> ...
</div> ...
</div>
</div>
</div>

The popup sits inside the host in the DOM (it renders in the top layer), so moving focus between the segments, the button, and the popup does not mark the field touched, and a picker inside a modal Dialog stays inside the dialog’s subtree.

MonthPicker.create(host: HTMLElement, options: MonthPickerOptions): MonthPicker

Global configuration key: monthPicker (see Configuration).

Option Type Default Description
label string Visible label and accessible name of the group (via aria-labelledby). Must be a non-empty string. Required.
hideLabel boolean false Visually hide the label; it remains the accessible name.
value PlainYearMonth | null null Value (years 1-9999). The value passed to create() is the value restored by reset().
min PlainYearMonth | null null Inclusive lower bound (validation and popup). Must not be after max.
max PlainYearMonth | null null Inclusive upper bound (validation and popup).
name string Form field name; a hidden input carries the ISO 8601 month (YYYY-MM).
description string Help text (text only) referenced by the group’s aria-describedby.
required boolean false An empty entry is invalid (value-missing); segments are required and the required indicator is shown.
disabled boolean false Disables the segments and the button; the popup does not open and the value is not submitted.
readOnly boolean false Focusable and submitted, but not editable; the popup does not open.
validate (value: PlainYearMonth | null) => string | null Consumer validation after the built-in checks; return a non-empty message to mark the value invalid (error ‘custom’).
isMonthDisabled (value: PlainYearMonth) => boolean Return true to disable a month: focusable in the popup but not selectable; typed, it is invalid (error ‘unavailable’).
today PlainDate Advanced: fixed today instead of the current date in timezone; it decides the current month. For tests, server rendering, and demos with a fixed clock.
icons Partial<MonthPickerIcons> Icon slot overrides: calendar, previous, next, error.
validationTiming 'blur' | 'input' | 'submit' "blur" When validation messages first appear; afterwards they update live. Globally configurable.
closeOnSelect boolean true Close the popup after a month is picked. Globally configurable.
locale string "en-US" Segment order and separators, month names, headings, labels, and messages. A change rebuilds the segments and keeps the entry. Globally configurable. Inherited from common configuration.
timezone string (IANA) "UTC" Zone used only to determine the current month (aria-current and default focus). Globally configurable. Inherited from common configuration.
direction 'ltr' | 'rtl' "ltr" Popup and grid direction: grid arrow-key mirroring, chevrons, and popup alignment (applied when the popup opens). Globally configurable. Inherited from common configuration.
Property Type Description
host HTMLElement Consumer host; its children are owned by the component. Registered with Form.
group HTMLDivElement The labelled role=group with the segments and the button; control boundary and popup anchor.
button HTMLButtonElement Opens the popup (aria-haspopup=dialog).
popup HTMLDivElement role=dialog aria-modal=false popup (popover=manual).
grid HTMLDivElement The role=grid of months inside the popup.
value PlainYearMonth | null Current value; null when empty, incomplete, or failing a built-in check.
validity MonthPickerValidity { valid, error, message }; recomputed on every change.
isOpen boolean Whether the popup is open.
name string | null Form field name.
label string Label text.
disabled boolean Disabled state.
dirty boolean Value differs from the initial value, or the segments hold an entry that does not parse to a value.
touched boolean Focus has left the whole control (segments, button, and popup) at least once.
options Readonly<MonthPickerOptions> Current instance options.
focusedValue PlainYearMonth The month holding the roving tab stop in the popup grid.
current PlainYearMonth The month containing today in the configured timezone (or the today option).
Method Description
setValue(value: PlainYearMonth | null): void Programmatic value (no events); clears an external custom error. Throws MONTH_PICKER_OPTIONS for a non-PlainYearMonth value.
update(options: Partial<MonthPickerOptions>): void Merge, validate, and apply options. Closes an open popup when it becomes disabled or read-only.
open(): void Open the popup and focus the selected month (or the current month), clamped to min/max. No-op when open, disabled, or read-only.
close(): void Close the popup; focus inside it returns to the button.
reportValidity(): boolean Show messages now (for every validation timing) and return validity.
setCustomError(message: string | null): void External error (server, cross-field rule); null or ‘’ clears it; cleared when the user edits.
getFormValue(): string | null ISO 8601 YYYY-MM, or null when the value is null.
focus(): void Focus the first segment.
reset(): void Close, restore the initial value, and clear dirty/touched/submitted state (no events).
destroy(): void Close, remove rendering, host classes, listeners, form registration, and subscriptions.
Event Detail Bubbles Cancelable Description
aui-change { value: PlainYearMonth | null; validity: MonthPickerValidity } Yes No The user changed the value or its validity error (typing, stepping, a separator, or picking a month).
aui-open null No No The popup opened.
aui-close null No No The popup closed.

All rules live in the aui.components cascade layer, so application styles override them without specificity battles.

Class Purpose
aui-field aui-period-picker aui-month-picker Host.
aui-period-picker--invalid | --disabled | --readonly State modifiers on the host.
aui-field__label | __required | __description | __error Shared field chrome (the error is also aui-validation-message).
aui-month-picker__group aui-period-picker__group Labelled group; control boundary, focus indicator, and popup anchor.
aui-period-picker__fields LTR-isolated run of segments and separators.
aui-period-picker__segment | __segment--month | __segment--year Segment inputs.
aui-period-picker__separator Locale separator text (aria-hidden).
aui-period-picker__button Choose month button.
aui-period-picker__popup Top-layer dialog popup.
aui-period-picker__header | __heading | __nav | __prev | __next Popup header: year heading (aria-live) and previous/next year buttons.
aui-period-picker__grid aui-month-picker__grid Grid of 12 months (4 rows of 3).
aui-period-picker__row Grid row.
aui-period-picker__cell | --selected | --current | --disabled | --empty Month cell and its states.
aui-period-picker__cell-text Short month name in a cell.
Slot Default icon
calendar calendar
previous chevron-left
next chevron-right
error alert-circle

Tokens this stylesheet reads (verified in CI). Override them in a theme or with the Theme Designer.

--aui-border-width | --aui-color-border | --aui-color-border-strong | --aui-color-disabled-bg | --aui-color-error-fg | --aui-color-focus-ring | --aui-color-hover | --aui-color-on-primary | --aui-color-primary | --aui-color-selected | --aui-color-selected-text | --aui-color-surface | --aui-color-surface-sunken | --aui-color-text | --aui-color-text-disabled | --aui-color-text-muted | --aui-control-height-md | --aui-control-height-sm | --aui-control-padding-x | --aui-duration-fast | --aui-easing-standard | --aui-focus-ring-offset | --aui-focus-ring-width | --aui-font-family-sans | --aui-font-size-md | --aui-font-size-sm | --aui-font-size-xs | --aui-font-weight-bold | --aui-font-weight-medium | --aui-font-weight-semibold | --aui-icon-size-sm | --aui-line-height-normal | --aui-line-height-tight | --aui-radius-md | --aui-radius-sm | --aui-shadow-md | --aui-space-1 | --aui-space-2 | --aui-space-3

interface PlainYearMonth { readonly kind: 'PlainYearMonth'; readonly year: number; readonly month: number }
interface MonthPickerConfig { validationTiming: ValidationTiming; closeOnSelect: boolean }
type ValidationTiming = 'blur' | 'input' | 'submit';
type MonthPickerError = 'incomplete' | 'invalid' | 'value-missing' | 'range-underflow' | 'range-overflow' | 'unavailable' | 'custom';
interface MonthPickerValidity { readonly valid: boolean; readonly error: MonthPickerError | null; readonly message: string }
interface MonthPickerChangeDetail { value: PlainYearMonth | null; validity: MonthPickerValidity }
interface MonthPickerIcons { calendar: IconRef; previous: IconRef; next: IconRef; error: IconRef }

Pattern: Segmented month field in a labelled group with a button that opens a non-modal dialog popup (WAI-ARIA APG date picker dialog pattern, non-modal variant) containing a paged role=grid of months with a roving tabindex.

Key Context Action
Digits Month segment Enters up to two digits; a first digit 2-9 is padded (4 becomes 04) and advances; a first 1 waits for a second digit. Non-digits are removed.
Digits Year segment Enters up to four digits and advances when full; fewer than four digits is incomplete.
/ - . , : Space Segment (not read-only) Pads a single-digit month and advances to the next segment.
ArrowUp / ArrowDown Segment (not read-only) Month increments or decrements with wrap (1-12); the year stops at 1 and 9999. An empty month starts at 01 (Up) or 12 (Down); an empty year starts at the current year.
ArrowLeft / ArrowRight Caret at the segment edge, or text selected Previous or next segment in the visual order of the LTR-isolated segment run (not mirrored in RTL).
Backspace Empty segment Moves to the previous segment.
Alt+ArrowDown Segment Opens the popup without changing the segment.
Tab / Shift+Tab Group Moves through the segments, then the button.
Enter / Space Button Opens the popup (or closes it when open); focus moves to the selected month, else the current month, clamped to min/max.
ArrowRight / ArrowLeft Month cell Next / previous month (mirrored in RTL), crossing into the adjacent year.
ArrowDown / ArrowUp Month cell Same column in the next / previous row (plus or minus 3 months), crossing into the adjacent year.
Home / End Month cell First / last month of the row.
PageDown / PageUp Month cell Same month in the next / previous year.
Shift+PageDown / Shift+PageUp Month cell Same month 10 years later / earlier.
Enter / Space Month cell Picks the month unless it is disabled; with closeOnSelect (default) the popup closes and focus returns to the button, otherwise focus stays on the cell. Emits aui-change if the value or validity changed.
Enter / Space Previous / next year button Shows the previous / next year; no-op while aria-disabled.
Escape Popup open Closes the popup without changing the value; focus inside the popup returns to the button. Only the topmost overlay responds, so a surrounding Dialog stays open.
Tab / Shift+Tab Popup Moves between the previous and next year buttons and the focused month; focus leaving the popup (other than to the choose button) closes it.
Element Role
segment container group
each segment (input type=text, inputmode=numeric) textbox (native)
choose month button button (native), aria-haspopup=dialog
popup dialog (aria-modal=false)
previous / next year buttons button (native)
month grid grid
grid row row
month cell gridcell
separators and icons none (aria-hidden=true)
Attribute Element Value
aria-labelledby group Label id
aria-describedby group Help text and visible error message ids
aria-label segment Localized segment name (“Month”, “Year”)
aria-invalid segment “true” only while an error message is shown
aria-label button “Choose month”, or “Change month, {value}” (for example “Change month, March 2026”) when a value is set
aria-haspopup button dialog
aria-expanded button “true” while the popup is open
aria-controls button Popup id
aria-label popup Localized “Choose month”
aria-modal popup “false” (non-modal; the page stays reachable)
aria-live heading polite (announces the year when the page changes)
aria-label previous / next buttons “Previous year” / “Next year”
aria-disabled previous / next buttons “true” when the page reaches min/max or year 1/9999 (focus stays on the button)
aria-labelledby grid Heading id
aria-label cell Long month and year, for example “March 2026”
aria-selected cell “true” on the selected month only (absent otherwise)
aria-current cell “date” on the current month (configured timezone or today option)
aria-disabled cell “true” for months outside min/max or rejected by isMonthDisabled

Each segment and the button are tab stops. Opening moves focus to one month cell (roving tabindex: exactly one cell has tabindex=0). Movement is clamped to min/max and years 1-9999; disabled months stay focusable. Picking with closeOnSelect, Escape, and closing through the button return focus to the button; focus is moved before the popup hides, so it never falls to the document body. A pointer press outside the popup and the button closes it. Moving focus between the segments, button, and popup does not mark the field touched.

Criterion Level Note
1.3.1 Info and Relationships A
1.4.1 Use of Color A
1.4.3 Contrast (Minimum) AA
1.4.11 Non-text Contrast AA
2.1.1 Keyboard A
2.1.2 No Keyboard Trap A
2.4.3 Focus Order A
2.4.7 Focus Visible AA
3.2.2 On Input A
3.3.1 Error Identification A
3.3.2 Labels or Instructions A
4.1.2 Name, Role, Value A
4.1.3 Status Messages AA

Automated test results: Accessibility and compliance.

  • The label is a <label> associated through aria-labelledby because a group is not labelable; clicking the label focuses the first segment.
  • The required asterisk is aria-hidden; each segment carries the native required state.
  • The segments remain the primary input; the grid is an alternative, and a typed month rejected by isMonthDisabled gets a localized message so both paths agree.
  • The popup is non-modal: focus is not trapped and the rest of the page stays reachable.
  • Errors appear after focus leaves the whole control by default, so moving between segments does not trigger messages.
  • Screen-reader behavior (dialog announcement, page changes, cell states) has not yet been manually verified with NVDA, JAWS, and VoiceOver.
  • Segment arrow navigation follows visual order and is not mirrored in RTL, because segments render as an LTR run.

empty, segment focused, partial (incomplete), valid, invalid (message shown), open, cell focused, cell selected, current month, cell disabled, disabled, read-only, required.

From Trigger To
empty user types part of a month dirty, incomplete; message shown only with validationTiming: 'input'
incomplete last segment filled valid, or invalid / range-underflow / range-overflow / unavailable / custom; aui-change
closed button, Alt+ArrowDown, or open() open; focus on the selected (else current) month; aui-open
open Enter, Space, or click on an enabled month value set, aui-change if changed; with closeOnSelect closed, focus on the button, aui-close
open Escape, the button, or close() closed; value kept; focus inside the popup returns to the button; aui-close
open Tab out of the popup, or a pointer press outside the popup and button closed; focus moves on
any focus leaves the host touched; messages shown (blur timing)
any reportValidity() (for example on form submit) submitted; messages shown for every timing
invalid (custom error) user edits custom error cleared, validity recomputed
any reset() initial value; pristine, untouched, closed
  • The segments stay the primary input; the grid is an alternative. A typed month rejected by isMonthDisabled gets a localized message, so the typed and picked paths agree.
  • The popup is non-modal: focus is not trapped and the page stays reachable. Escape and outside presses close it through the shared overlay stack, so only the topmost overlay responds.
  • Focus is moved before the popup hides, so it never falls to the document body.
  • Moving between segments, the button, and the popup does not count as leaving the field; errors appear after the whole control loses focus.
  • The label is a <label> associated through aria-labelledby; clicking it focuses the first segment. The required asterisk is aria-hidden.
  • Manual screen-reader verification is pending (see Known limitations).

The group hugs its segments and button, up to 100% of the container; segments wrap inside the group in very narrow containers. The popup is positioned under the group (bottom-start), is at most the viewport width minus a gutter, caps its height to the available space (--aui-available-height), and scrolls when taller. Cells are at least 4rem wide and one control height tall.

Segment order and separators come from the locale’s Intl year/month pattern. Segments render as an LTR-isolated run in every direction, so ArrowLeft and ArrowRight in segments follow that visual order and are not mirrored. The button sits at the inline end. The popup and grid take direction: grid ArrowLeft/ArrowRight are mirrored, the chevrons are flipped, and the popup aligns to the inline start of the group.

Role tokens only. The focused segment uses color-selected / color-selected-text; the selected month uses color-primary / color-on-primary; the current month is bold with an inset color-border-strong border; unavailable months are muted and struck through. In forced-colors mode the focused segment and the selected month use Highlight / HighlightText, the current month gets a CanvasText outline, disabled cells and buttons use GrayText, and the popup has a CanvasText border.

configureAbsoluteUI({ common: { timezone: 'Europe/Berlin' }, components: { monthPicker: { closeOnSelect: false, validationTiming: 'submit' } } });

Changes apply to existing instances immediately; a locale change rebuilds the segments and keeps the entry.

Instance options override global and theme defaults. All options are dynamic (update()); there are no init-only options.

Values are PlainYearMonth and never shift. The configured timezone (default UTC, never the host zone) only decides the current month: the cell marked aria-current="date" and the month focused when the popup opens on an empty field. For example, at 2026-09-30 19:00 UTC the current month is September 2026 in UTC but October 2026 with timezone: 'Asia/Kolkata'. The advanced today option pins today to a fixed PlainDate (tests, server rendering, demos).

icons.calendar (default calendar) for the button, icons.previous / icons.next (defaults chevron-left / chevron-right) for the page buttons, and icons.error (default alert-circle) for the validation message.

All text uses textContent and Intl formatting; no HTML parsing. Validation messages never include the entered value; range messages include only the formatted bounds. The popup uses the Popover API; no inline handlers. CSP-safe: positioning writes styles through the CSSOM.

No persistence, network access, or telemetry. When name is set, a hidden input carries YYYY-MM; it is empty while the entry is empty or fails a built-in check, and disabled (not submitted) while the picker is disabled. A read-only picker is submitted.

The popup and its 12 cells are created once per picker; opening and paging only re-render cell text and state. Segment keyboard and input handling share one delegated listener each; the grid has one keydown and one click listener. One configuration subscription per instance. Budget: under 5 ms average per MonthPicker.create across 100 instances in headless Chromium.

100 pickers per page (performance test); years 1 to 9999.

  • A single 1 in the month segment does not advance but already counts as January; 13 or 00 reports invalid (“Enter a valid month.”).
  • A month with only one segment filled reports incomplete (“Enter a complete month.”); a year with fewer than four digits reports incomplete (“Enter the year with 4 digits.”).
  • Built-in errors (incomplete, invalid, range-*, unavailable) set value to null; a custom error keeps the value (and the hidden input value).
  • Opening an empty field focuses the current month in the configured zone, clamped to min/max.
  • Clicking a month outside min/max or rejected by isMonthDisabled does nothing; Enter on it keeps the popup open.
  • update({ value }) changes the value but not the value restored by reset() (the value passed to create()).
  • Inside a modal Dialog, the first Escape closes the popup and the second closes the dialog.
Code Category Cause
MONTH_PICKER_OPTIONS configuration Missing or blank label; value/min/max not a PlainYearMonth with year 1-9999; min after max; non-function validate or isMonthDisabled; today not a PlainDate. Details name the property (for example monthPicker.value).
CFG_INVALID_VALUE configuration Invalid validationTiming, closeOnSelect, locale, timezone, or direction (reported by the configuration engine).
Validation (validity.error) validation incomplete, invalid, value-missing, range-underflow, range-overflow, unavailable, custom.

Built-in checks run in that order: empty (value-missing when required), partial (incomplete), four-digit year (incomplete), segment range (invalid), min, max, isMonthDisabled (unavailable), then validate, then an external custom error. English messages: “This field is required.”, “Enter a complete month.”, “Enter the year with 4 digits.”, “Enter a valid month.”, “Enter {min} or later.”, “Enter {max} or earlier.”, “This month is not available.”. A failed construction leaves no rendering, listeners, registrations, or subscriptions behind.

  • Gregorian calendar only; years 1 to 9999.
  • Only ASCII digits are accepted in segments.
  • Single months only; ranges need two pickers.
  • No Cancel/OK buttons: picking commits.
  • Segment arrow navigation is not mirrored in RTL.
  • Screen-reader behavior has not yet been manually verified with NVDA, JAWS, and VoiceOver.
  • HTML in the label, help text, cells, or messages.
  • Non-Gregorian calendars.
  • Custom cell content or grid layouts.