component — date & time

Year Picker

A decade grid for picking a year, plus the popover field that wraps it. Pages by decade with the boundary years dimmed in place, honours minValue/maxValue, and yields a CalendarDate on January 1 so it composes with the rest of Date & time.

  • calendar
  • date
  • year
  • picker
  • form
  • input
  • interactive

06 examples

Default

One calendar decade per page, with the year either side dimmed in place. The current year is ringed in ink.

2020 – 2029
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030

Preselected

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

2010 – 2019
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020

Bounded

minValue and maxValue dim the years outside the range and make them unselectable. A year is offered if any of its days is in range.

2020 – 2029
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030

Controlled

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

2020 – 2029
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030

Selected:

Popover field

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

Disabled

The whole picker dims and nothing takes focus.

2020 – 2029
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030

source

year-picker.tsx

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

"use client"

import {
  CalendarDate,
  type DateValue,
  getLocalTimeZone,
  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"

/**
 * Year Picker — quebi design system
 *
 * A decade grid for picking a year, and the popover field that wraps it. The
 * page is a calendar decade (2020–2029) plus the year either side, dimmed the
 * way a calendar dims its outside-month days, so the boundary years are one
 * click away rather than one page away.
 *
 * 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. It is the primitive rather than
 * `@/components/list-box` on purpose: that wrapper is the dropdown surface (its
 * own border and a check icon per item), and a calendar cell is not a
 * menu item.
 *
 * The value is a `CalendarDate` on January 1 of the chosen year — clamped into
 * `minValue`/`maxValue` when either of those lands mid-year — so it composes
 * with the rest of the Date & time category. Sibling of Month Picker and Week
 * Picker; the three share their cell styling with Calendar.
 */

/** Cells per page: the ten years of a decade, plus the one either side. */
const YEARS_PER_PAGE = 12

/** The first year of the calendar decade `year` falls in. */
const decadeOf = (year: number) => Math.floor(year / 10) * 10

export interface YearPickerProps {
  /** The selected year, as a date inside it. Makes the picker controlled. */
  value?: CalendarDate | null
  /** The initially selected year, as a date inside it. */
  defaultValue?: CalendarDate | null
  /** Called with January 1 of the chosen year, clamped into min/max. */
  onChange?: (value: CalendarDate) => void
  /** Years ending before this are not selectable. */
  minValue?: DateValue
  /** Years 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 YearPicker({
  value,
  defaultValue,
  onChange,
  minValue,
  maxValue,
  isDisabled,
  isReadOnly,
  autoFocus,
  className,
  "aria-label": ariaLabel,
  "aria-labelledby": ariaLabelledBy,
  "aria-describedby": ariaDescribedBy,
}: YearPickerProps) {
  const { locale } = useLocale()
  const now = today(getLocalTimeZone())
  const isControlled = value !== undefined

  const [uncontrolled, setUncontrolled] = useState<CalendarDate | null>(defaultValue ?? null)
  const selected = isControlled ? (value ?? null) : uncontrolled

  const [decadeStart, setDecadeStart] = useState(() => decadeOf((selected ?? now).year))

  // Follow the value when it moves from outside — a parent that sets the year
  // to 1998 means the page showing 2020–2029 is now showing the wrong decade.
  // Paging by hand does not go through here, so the user's own navigation is
  // never yanked back.
  const [lastSelectedYear, setLastSelectedYear] = useState(selected?.year)
  if (selected?.year !== lastSelectedYear) {
    setLastSelectedYear(selected?.year)
    if (selected) setDecadeStart(decadeOf(selected.year))
  }

  const formatter = getDateTimeFormat(locale, { year: "numeric", timeZone: "UTC" })
  const label = `${formatter.format(yearStart(decadeStart).toDate("UTC"))} – ${formatter.format(
    yearStart(decadeStart + 9).toDate("UTC"),
  )}`

  const years = Array.from({ length: YEARS_PER_PAGE }, (_, index) => decadeStart - 1 + index)
  // 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 disabledYears = isDisabled
    ? years
    : years.filter((year) => !isYearAvailable(year, minValue, maxValue))

  const select = (year: number) => {
    const next = clamp(yearStart(year), minValue, maxValue)
    if (!isControlled) setUncontrolled(next)
    // A boundary year lives on the page of its own decade; follow it there so
    // the selection stays visible after the click.
    setDecadeStart(decadeOf(year))
    setLastSelectedYear(next.year)
    onChange?.(next)
  }

  return (
    <div
      data-slot="year-picker"
      className={cn("w-fit", isDisabled && "opacity-50", className)}
    >
      <PagerHeader
        label={label}
        isDisabled={isDisabled}
        previousLabel="Previous decade"
        nextLabel="Next decade"
        onPrevious={() => setDecadeStart(decadeStart - 10)}
        onNext={() => setDecadeStart(decadeStart + 10)}
      />
      <ListBoxPrimitive
        aria-label={ariaLabel ?? (ariaLabelledBy ? undefined : "Year")}
        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={disabledYears}
        // Escape belongs to whatever is above the grid (the popover, usually);
        // the default would spend it clearing the selection instead.
        escapeKeyBehavior="none"
        selectedKeys={selected ? [selected.year] : []}
        onSelectionChange={(keys) => {
          if (isReadOnly || keys === "all") return
          const [key] = [...keys]
          if (typeof key === "number") select(key)
        }}
        className="grid grid-cols-3 gap-1 outline-hidden"
      >
        {years.map((year) => {
          const isOutsideDecade = year < decadeStart || year > decadeStart + 9
          return (
            <ListBoxItemPrimitive
              key={year}
              id={year}
              textValue={formatter.format(yearStart(year).toDate("UTC"))}
              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",
                  isOutsideDecade && "text-quebi-fg-subtle",
                  year === now.year && !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",
                )
              }
            >
              {formatter.format(yearStart(year).toDate("UTC"))}
            </ListBoxItemPrimitive>
          )
        })}
      </ListBoxPrimitive>
    </div>
  )
}

export interface YearPickerFieldProps extends Omit<YearPickerProps, "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",
)

/**
 * YearPickerField — the Year Picker behind a trigger.
 *
 * The compact shape: a button showing the selected year, and 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 YearPickerField({
  value,
  defaultValue,
  onChange,
  placeholder = "Select year",
  placement = "bottom start",
  className,
  isDisabled,
  "aria-label": ariaLabel,
  ...props
}: YearPickerFieldProps) {
  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, { year: "numeric", timeZone: "UTC" })

  return (
    <Popover isOpen={isOpen} onOpenChange={setIsOpen}>
      <FieldTrigger
        aria-label={ariaLabel ?? "Year"}
        isDisabled={isDisabled}
        className={cn(fieldTriggerStyles, "w-40", 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">
        <YearPicker
          {...props}
          autoFocus
          aria-label={ariaLabel ?? "Year"}
          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 decade, since paging moves nothing into focus.
 */
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"
          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"
          isDisabled={isDisabled}
          onPress={onNext}
        >
          <Next data-slot="icon" />
        </Button>
      </div>
    </header>
  )
}

/** January 1 of `year`. */
const yearStart = (year: number) => new CalendarDate(year, 1, 1)

/** True if any day of `year` falls inside `[minValue, maxValue]`. */
function isYearAvailable(year: number, minValue?: DateValue, maxValue?: DateValue) {
  if (minValue && new CalendarDate(year, 12, 31).compare(minValue) < 0) return false
  if (maxValue && yearStart(year).compare(maxValue) > 0) return false
  return true
}

/**
 * `date` pulled inside `[minValue, maxValue]`.
 *
 * January 1 of a selectable year can still sit before a mid-year `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) {
  if (minValue && date.compare(minValue) < 0) return toCalendarDate(minValue)
  if (maxValue && date.compare(maxValue) > 0) return toCalendarDate(maxValue)
  return date
}