Skip to content

Year Picker

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

Using Angular? See Year Picker for Angular.

Entry of a calendar year. YearPicker renders one four-digit year segment inside a labelled group, plus a “Choose year” button that opens a non-modal dialog popup with a grid of the ten years of a decade (paged by decade). The value is a plain number (an integer from 1 to 9999); a hidden input submits it as four zero-padded digits (YYYY). It shares its engine with Month and Quarter Picker; this page states every behavior in full, and year-specific details are marked.

  • A calendar year on its own: a model year, a graduation year, a tax or reporting year.
  • Users should be able to type a year quickly and also browse nearby years.
  • Year fields with bounds or unavailable years.
  • A month or a specific day: use Month Picker, Date Picker, or Date Input.
  • A calendar quarter: use Quarter Picker.
  • A fiscal year that does not start in January: the value is a calendar year; label the field accordingly or store the fiscal mapping yourself.
  • An arbitrary number that is not a year: use Number Input.
import { YearPicker } from '@absoluteui/components/year-picker';
const host = document.createElement('div');
document.body.append(host);
const picker = YearPicker.create(host, {
label: 'Model year',
name: 'modelYear',
required: true,
min: 1990,
max: 2027,
});
host.addEventListener('aui-change', () => {
host.dataset.valid = String(picker.validity.valid);
});
import { YearPicker, type YearPickerChangeDetail } from '@absoluteui/components/year-picker';
const closedYears = new Set([2020, 2021]);
const host = document.createElement('div');
document.body.append(host);
YearPicker.create(host, {
label: 'Tax year',
value: 2025,
isYearDisabled: (year) => closedYears.has(year),
validate: (year) => (year !== null && year > 2026 ? 'Future years cannot be filed.' : null),
});
host.addEventListener('aui-change', (event) => {
const { value, validity } = (event as CustomEvent<YearPickerChangeDetail>).detail;
host.dataset.year = value === null ? '' : String(value);
host.dataset.error = validity.error ?? '';
});
import { configureAbsoluteUI } from '@absoluteui/core';
import '@absoluteui/components/year-picker';
configureAbsoluteUI({ components: { yearPicker: { closeOnSelect: false, validationTiming: 'input' } } });
import { YearPicker, type YearPickerChangeDetail } from '@absoluteui/components/year-picker';
import '@absoluteui/components/year-picker/style.css';
const host = document.querySelector<HTMLElement>('#graduation');
if (host) {
YearPicker.create(host, { label: 'Graduation year', name: 'graduation', max: 2030 });
host.addEventListener('aui-change', (event) => {
const { value } = (event as CustomEvent<YearPickerChangeDetail>).detail;
host.dataset.year = value === null ? '' : String(value);
});
}
import { YearPicker } from '@absoluteui/components/year-picker';
const host = document.createElement('div');
document.body.append(host);
YearPicker.create(host, { label: 'Model year', name: 'modelYear', required: true, min: 1990, max: 2027 });
<div class="aui-field aui-period-picker aui-year-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-year-picker__group aui-period-picker__group" role="group" aria-labelledby=label aria-describedby>
<div class="aui-period-picker__fields">
<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 year">
</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 year" popover="manual">
<div class="aui-period-picker__header">
<button class="aui-period-picker__nav aui-period-picker__prev" aria-label="Previous 10 years">
<div class="aui-period-picker__heading" aria-live="polite">2020 - 2029</div>
<button class="aui-period-picker__nav aui-period-picker__next" aria-label="Next 10 years">
</div>
<div class="aui-period-picker__grid aui-year-picker__grid" role="grid" aria-labelledby=heading>
<div class="aui-period-picker__row" role="row"> (2 rows of 5)
<div class="aui-period-picker__cell" role="gridcell" aria-label="2020" tabindex="-1">
<span class="aui-period-picker__cell-text">2020</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 segment, the button, and the popup does not mark the field touched.

YearPicker.create(host: HTMLElement, options: YearPickerOptions): YearPicker

Global configuration key: yearPicker (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 number | null null Value (integer year 1-9999). The value passed to create() is the value restored by reset().
min number | null null Inclusive lower bound (integer year; validation and popup). Must not be after max.
max number | null null Inclusive upper bound (integer year; validation and popup).
name string Form field name; a hidden input carries the year as zero-padded YYYY.
description string Help text (text only) referenced by the group’s aria-describedby.
required boolean false An empty entry is invalid (value-missing); the segment is required and the required indicator is shown.
disabled boolean false Disables the segment 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: number | null) => string | null Consumer validation after the built-in checks; return a non-empty message to mark the value invalid (error ‘custom’).
isYearDisabled (year: number) => boolean Return true to disable a year: 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 year. For tests, server rendering, and demos with a fixed clock.
icons Partial<YearPickerIcons> 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 year is picked. Globally configurable.
locale string "en-US" Year formatting, headings, labels, and messages. Globally configurable. Inherited from common configuration.
timezone string (IANA) "UTC" Zone used only to determine the current year (aria-current, default focus, and the start of stepping an empty segment). 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 segment 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 years inside the popup.
value number | null Current value; null when empty, incomplete, or failing a built-in check.
validity YearPickerValidity { 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 segment holds an entry that does not parse to a value.
touched boolean Focus has left the whole control (segment, button, and popup) at least once.
options Readonly<YearPickerOptions> Current instance options.
focusedValue number The year holding the roving tab stop in the popup grid.
current number The year containing today in the configured timezone (or the today option).
Method Description
setValue(value: number | null): void Programmatic value (no events); clears an external custom error. Throws YEAR_PICKER_OPTIONS for a value that is not an integer 1-9999.
update(options: Partial<YearPickerOptions>): 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 year (or the current year), 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 Zero-padded YYYY, or null when the value is null.
focus(): void Focus the year 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: number | null; validity: YearPickerValidity } Yes No The user changed the value or its validity error (typing, stepping, or picking a year).
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-year-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-year-picker__group aui-period-picker__group Labelled group; control boundary, focus indicator, and popup anchor.
aui-period-picker__fields LTR-isolated container of the segment.
aui-period-picker__segment | __segment--year Year segment input.
aui-period-picker__button Choose year button.
aui-period-picker__popup Top-layer dialog popup.
aui-period-picker__header | __heading | __nav | __prev | __next Popup header: decade heading (aria-live) and previous/next 10 years buttons.
aui-period-picker__grid aui-year-picker__grid Grid of 10 years (2 rows of 5).
aui-period-picker__row Grid row.
aui-period-picker__cell | --selected | --current | --disabled | --empty Year cell and its states; –empty is the year-0 slot on the first page.
aui-period-picker__cell-text Year text 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 YearPickerConfig { validationTiming: ValidationTiming; closeOnSelect: boolean }
type ValidationTiming = 'blur' | 'input' | 'submit';
type YearPickerError = 'incomplete' | 'invalid' | 'value-missing' | 'range-underflow' | 'range-overflow' | 'unavailable' | 'custom';
interface YearPickerValidity { readonly valid: boolean; readonly error: YearPickerError | null; readonly message: string }
interface YearPickerChangeDetail { value: number | null; validity: YearPickerValidity }
interface YearPickerIcons { calendar: IconRef; previous: IconRef; next: IconRef; error: IconRef }

Pattern: Single-segment year 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 years with a roving tabindex.

Key Context Action
Digits Year segment Enters up to four digits; fewer than four digits is incomplete (year 5 is typed 0005). Non-digits are removed.
ArrowUp / ArrowDown Year segment (not read-only) Increments or decrements the year, stopping at 1 and 9999; an empty segment starts at the current year.
Alt+ArrowDown Year segment Opens the popup without changing the segment.
Tab / Shift+Tab Group Moves from the segment to the button.
Enter / Space Button Opens the popup (or closes it when open); focus moves to the selected year, else the current year, clamped to min/max.
ArrowRight / ArrowLeft Year cell Next / previous year (mirrored in RTL), crossing into the adjacent decade.
ArrowDown / ArrowUp Year cell Plus / minus 5 years (next / previous row), crossing into the adjacent decade.
Home / End Year cell First / last year of the row.
PageDown / PageUp Year cell Plus / minus 10 years (next / previous decade).
Shift+PageDown / Shift+PageUp Year cell Plus / minus 100 years.
Enter / Space Year cell Picks the year 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 10 years button Shows the previous / next decade; 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 buttons and the focused year; focus leaving the popup (other than to the choose button) closes it.
Element Role
segment container group
year segment (input type=text, inputmode=numeric) textbox (native)
choose year button button (native), aria-haspopup=dialog
popup dialog (aria-modal=false)
previous / next 10 years buttons button (native)
year grid grid
grid row row
year cell gridcell
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 “Year”
aria-invalid segment “true” only while an error message is shown
aria-label button “Choose year”, or “Change year, {value}” (for example “Change year, 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 year”
aria-modal popup “false” (non-modal; the page stays reachable)
aria-live heading polite (announces the decade, for example “2020 - 2029”, when the page changes)
aria-label previous / next buttons “Previous 10 years” / “Next 10 years”
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 Formatted year, for example “2026” (absent on the empty year-0 slot)
aria-selected cell “true” on the selected year only (absent otherwise)
aria-current cell “date” on the current year (configured timezone or today option)
aria-disabled cell “true” for years outside min/max or rejected by isYearDisabled

The segment and the button are tab stops. Opening moves focus to one year cell (roving tabindex: exactly one cell has tabindex=0; the empty year-0 slot is never focusable). Movement is clamped to min/max and years 1-9999; disabled years 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 segment, 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 segment.
  • The required asterisk is aria-hidden; the segment carries the native required state.
  • The segment remains the primary input; the grid is an alternative, and a typed year rejected by isYearDisabled 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.
  • Screen-reader behavior (dialog announcement, page changes, cell states) has not yet been manually verified with NVDA, JAWS, and VoiceOver.

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

From Trigger To
empty user types fewer than four digits dirty, incomplete; message shown only with validationTiming: 'input'
incomplete fourth digit typed valid, or invalid / range-underflow / range-overflow / unavailable / custom; aui-change
closed button, Alt+ArrowDown, or open() open; focus on the selected (else current) year; aui-open
open Enter, Space, or click on an enabled year 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() submitted; messages shown for every timing
invalid (custom error) user edits custom error cleared, validity recomputed
any reset() initial value; pristine, untouched, closed
  • The segment stays the primary input; the grid is an alternative. A typed year rejected by isYearDisabled gets a localized message, so both paths agree.
  • The popup is non-modal: focus is not trapped. 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.
  • Errors appear after the whole control loses focus. The label focuses the segment when clicked; the required asterisk is aria-hidden.
  • Manual screen-reader verification is pending (see Known limitations).

The group hugs its segment and button, up to 100% of the container. 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.

The year segment renders as an LTR-isolated run. 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 year uses color-primary / color-on-primary; the current year is bold with an inset color-border-strong border; unavailable years are muted and struck through. In forced-colors mode the focused segment and the selected year use Highlight / HighlightText, the current year gets a CanvasText outline, disabled cells and buttons use GrayText, and the popup has a CanvasText border.

configureAbsoluteUI({ components: { yearPicker: { closeOnSelect: false, validationTiming: 'input' } } });

Changes apply to existing instances immediately.

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

Values are plain year numbers and never shift. The configured timezone (default UTC, never the host zone) only decides the current year: the cell marked aria-current="date", the year focused when the popup opens on an empty field, and the starting year for ArrowUp/ArrowDown in an empty segment. At 2026-12-31 19:00 UTC the current year is 2026 in UTC and America/New_York, but 2027 with timezone: 'Asia/Kolkata'. The advanced today option pins today to a fixed PlainDate.

icons.calendar (default calendar), icons.previous / icons.next (defaults chevron-left / chevron-right), and icons.error (default alert-circle).

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

No persistence, network access, or telemetry. When name is set, a hidden input carries the zero-padded YYYY; 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 10 cells are created once per picker; opening and paging only re-render cell text and state. Listeners are delegated; one configuration subscription per instance. Budget: under 5 ms average per YearPicker.create across 100 instances in headless Chromium.

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

  • Decade pages start at multiples of ten; the first page shows years 1 to 9 (heading “1 - 9”) with an empty slot where year 0 would be, and its previous button is aria-disabled.
  • 0000 reports invalid (“Enter a valid year.”); 26 reports incomplete (“Enter the year with 4 digits.”).
  • Year 5 is shown and submitted as 0005; ArrowUp from year 1 in the grid stays on year 1.
  • Built-in errors set value to null; a custom error keeps the value.
  • 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
YEAR_PICKER_OPTIONS configuration Missing or blank label; value/min/max not an integer 1-9999 (for example 2026.5 or 0); min after max; non-function validate or isYearDisabled; today not a PlainDate. Details name the property (for example yearPicker.value).
CFG_INVALID_VALUE configuration Invalid validationTiming, closeOnSelect, locale, timezone, or direction.
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), four-digit year (incomplete), range 1-9999 (invalid), min, max, isYearDisabled (unavailable), then validate, then an external custom error. English messages: “This field is required.”, “Enter the year with 4 digits.”, “Enter a valid year.”, “Enter {min} or later.”, “Enter {max} or earlier.”, “This year is not available.”. A failed construction leaves nothing behind.

  • Years 1 to 9999 only; no year 0, negative years, or eras.
  • The year must be typed with four digits; only ASCII digits are accepted.
  • Calendar years only; fiscal years with a non-January start are not modeled.
  • Single years only; no Cancel/OK buttons (picking commits).
  • 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 or eras.
  • Custom cell content or grid layouts.