Year Picker

Decade-grid year picker with keyboard nav, min/max bounds, single or range mode.

Example

Installation

npx shadcn@latest add https://hirael.com/r/year-picker.json

API

<YearPicker />

PropTypeDefault
mode"range" | "single"
valuenumber | YearRange
defaultValuenumber | YearRange
onValueChange((year: number) => void) | ((range: YearRange) => void)
minYearnumber
maxYearnumber
disabledboolean
openboolean
defaultOpenboolean
onOpenChange((open: boolean) => void) | ((open: boolean) => void)
childrenReact.ReactNode

<YearPickerTrigger />

+ native element props
PropTypeDefault
placeholderstring"Pick a year"
childrenReact.ReactNode

<YearPickerContent />

+ native element props

No props of its own — forwards everything to the underlying element.

Component source

"use client";

import * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";

import { cn } from "@/lib/utils";
import { Button } from "@/registry/hirael/ui/button";
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/registry/hirael/ui/popover";

export type YearRange = { from: number; to?: number };
export type YearPickerMode = "single" | "range";

type YearPickerContextValue =
  | {
      mode: "single";
      value: number | undefined;
      setValue: (year: number) => void;
      minYear: number;
      maxYear: number;
      decadeStart: number;
      setDecadeStart: (n: number) => void;
      open: boolean;
      setOpen: (open: boolean) => void;
      disabled?: boolean;
    }
  | {
      mode: "range";
      value: YearRange | undefined;
      setValue: (year: number) => void;
      minYear: number;
      maxYear: number;
      decadeStart: number;
      setDecadeStart: (n: number) => void;
      open: boolean;
      setOpen: (open: boolean) => void;
      disabled?: boolean;
    };

const YearPickerContext = React.createContext<YearPickerContextValue | null>(
  null,
);

function useYearPicker() {
  const ctx = React.useContext(YearPickerContext);
  if (!ctx) {
    throw new Error(
      "YearPicker compound components must be used inside <YearPicker>",
    );
  }
  return ctx;
}

const YEARS_PER_VIEW = 12;

function viewStartFor(year: number) {
  const base = year - (year % 10);
  return base - 1;
}

export type YearPickerProps =
  | {
      mode?: "single";
      value?: number;
      defaultValue?: number;
      onValueChange?: (year: number) => void;
      minYear?: number;
      maxYear?: number;
      disabled?: boolean;
      open?: boolean;
      defaultOpen?: boolean;
      onOpenChange?: (open: boolean) => void;
      children?: React.ReactNode;
    }
  | {
      mode: "range";
      value?: YearRange;
      defaultValue?: YearRange;
      onValueChange?: (range: YearRange) => void;
      minYear?: number;
      maxYear?: number;
      disabled?: boolean;
      open?: boolean;
      defaultOpen?: boolean;
      onOpenChange?: (open: boolean) => void;
      children?: React.ReactNode;
    };

function YearPicker(props: YearPickerProps) {
  const {
    minYear = 1900,
    maxYear = 2100,
    disabled,
    open: openProp,
    defaultOpen = false,
    onOpenChange,
    children,
  } = props;
  const mode = props.mode ?? "single";

  const singleValueProp =
    mode === "single"
      ? (props as Extract<YearPickerProps, { mode?: "single" }>).value
      : undefined;
  const singleDefaultValue =
    mode === "single"
      ? (props as Extract<YearPickerProps, { mode?: "single" }>).defaultValue
      : undefined;
  const singleOnValueChange =
    mode === "single"
      ? (props as Extract<YearPickerProps, { mode?: "single" }>).onValueChange
      : undefined;
  const rangeValueProp =
    mode === "range"
      ? (props as Extract<YearPickerProps, { mode: "range" }>).value
      : undefined;
  const rangeDefaultValue =
    mode === "range"
      ? (props as Extract<YearPickerProps, { mode: "range" }>).defaultValue
      : undefined;
  const rangeOnValueChange =
    mode === "range"
      ? (props as Extract<YearPickerProps, { mode: "range" }>).onValueChange
      : undefined;

  const [openInternal, setOpenInternal] = React.useState(defaultOpen);
  const open = openProp !== undefined ? openProp : openInternal;
  const setOpen = React.useCallback(
    (next: boolean) => {
      if (openProp === undefined) setOpenInternal(next);
      onOpenChange?.(next);
    },
    [openProp, onOpenChange],
  );

  const [singleInternal, setSingleInternal] = React.useState<
    number | undefined
  >(singleDefaultValue);
  const [rangeInternal, setRangeInternal] = React.useState<
    YearRange | undefined
  >(rangeDefaultValue);

  const singleValue =
    mode === "single" ? (singleValueProp ?? singleInternal) : undefined;
  const rangeValue =
    mode === "range" ? (rangeValueProp ?? rangeInternal) : undefined;

  const anchorYear =
    (mode === "single" ? singleValue : rangeValue?.from) ??
    new Date().getFullYear();
  const [decadeStart, setDecadeStart] = React.useState<number>(
    viewStartFor(anchorYear),
  );

  const controlledAnchorYear =
    mode === "single" ? singleValueProp : rangeValueProp?.from;
  const [prevControlledAnchorYear, setPrevControlledAnchorYear] =
    React.useState(controlledAnchorYear);
  if (controlledAnchorYear !== prevControlledAnchorYear) {
    setPrevControlledAnchorYear(controlledAnchorYear);
    if (controlledAnchorYear !== undefined) {
      setDecadeStart(viewStartFor(controlledAnchorYear));
    }
  }

  const [prevOpen, setPrevOpen] = React.useState(open);
  if (open !== prevOpen) {
    setPrevOpen(open);
    if (open) setDecadeStart(viewStartFor(anchorYear));
  }

  const setValueSingle = React.useCallback(
    (year: number) => {
      if (singleValueProp === undefined) setSingleInternal(year);
      singleOnValueChange?.(year);
      setOpen(false);
    },
    [singleValueProp, singleOnValueChange, setOpen],
  );

  const setValueRange = React.useCallback(
    (year: number) => {
      const current = rangeValueProp ?? rangeInternal;
      let next: YearRange;
      if (!current || (current.from && current.to)) {
        next = { from: year };
      } else if (year < current.from) {
        next = { from: year, to: current.from };
      } else {
        next = { from: current.from, to: year };
      }
      if (rangeValueProp === undefined) setRangeInternal(next);
      rangeOnValueChange?.(next);
      if (next.to !== undefined) setOpen(false);
    },
    [rangeValueProp, rangeOnValueChange, rangeInternal, setOpen],
  );

  const ctx = React.useMemo<YearPickerContextValue>(() => {
    if (mode === "single") {
      return {
        mode: "single",
        value: singleValue,
        setValue: setValueSingle,
        minYear,
        maxYear,
        decadeStart,
        setDecadeStart,
        open,
        setOpen,
        disabled,
      };
    }
    return {
      mode: "range",
      value: rangeValue,
      setValue: setValueRange,
      minYear,
      maxYear,
      decadeStart,
      setDecadeStart,
      open,
      setOpen,
      disabled,
    };
  }, [
    mode,
    singleValue,
    rangeValue,
    setValueSingle,
    setValueRange,
    minYear,
    maxYear,
    decadeStart,
    open,
    setOpen,
    disabled,
  ]);

  return (
    <YearPickerContext.Provider value={ctx}>
      <Popover open={open} onOpenChange={setOpen}>
        {children}
      </Popover>
    </YearPickerContext.Provider>
  );
}

function formatYearValue(ctx: YearPickerContextValue, placeholder: string) {
  if (ctx.mode === "single") return ctx.value ? String(ctx.value) : placeholder;
  if (!ctx.value) return placeholder;
  if (ctx.value.to === undefined) return `${ctx.value.from} – …`;
  return `${ctx.value.from}${ctx.value.to}`;
}

function YearPickerTrigger({
  placeholder = "Pick a year",
  className,
  children,
  ...props
}: Omit<React.ComponentProps<"button">, "children"> & {
  placeholder?: string;
  children?: React.ReactNode;
}) {
  const ctx = useYearPicker();
  const empty = ctx.value === undefined;
  return (
    <PopoverTrigger asChild>
      <button
        type="button"
        disabled={ctx.disabled}
        data-slot="year-picker-trigger"
        data-state={ctx.open ? "open" : "closed"}
        className={cn(
          "inline-flex h-9 w-full items-center justify-between gap-2 rounded-sm border border-input bg-transparent px-3 text-start text-sm font-mono tabular-nums outline-none transition-colors",
          "hover:border-ring/60 focus-visible:border-ring data-[state=open]:border-ring",
          empty && "text-muted-foreground font-sans",
          "disabled:cursor-not-allowed disabled:opacity-50",
          className,
        )}
        {...props}
      >
        {children ?? formatYearValue(ctx, placeholder)}
      </button>
    </PopoverTrigger>
  );
}

function isInRange(year: number, range: YearRange | undefined) {
  if (!range || range.to === undefined) return false;
  return year > range.from && year < range.to;
}

function isEndpoint(year: number, range: YearRange | undefined) {
  if (!range) return false;
  return year === range.from || year === range.to;
}

function YearPickerContent({
  className,
  ...props
}: React.ComponentProps<typeof PopoverContent>) {
  const ctx = useYearPicker();
  const years = Array.from(
    { length: YEARS_PER_VIEW },
    (_, i) => ctx.decadeStart + i,
  );
  const today = new Date().getFullYear();

  const canPrev = ctx.decadeStart - YEARS_PER_VIEW >= ctx.minYear - 1;
  const canNext = ctx.decadeStart + YEARS_PER_VIEW <= ctx.maxYear + 1;

  const inBounds = (year: number) => year >= ctx.minYear && year <= ctx.maxYear;
  const selectedYear = ctx.mode === "single" ? ctx.value : ctx.value?.from;
  const tabbableYear =
    selectedYear !== undefined && years.includes(selectedYear)
      ? selectedYear
      : years.includes(today) && inBounds(today)
        ? today
        : (years.find(inBounds) ?? years[0]);

  const gridRef = React.useRef<HTMLDivElement>(null);
  const focusYear = (year: number) => {
    const el = gridRef.current?.querySelector<HTMLButtonElement>(
      `[data-year="${year}"]`,
    );
    el?.focus();
  };

  const handleKey = (e: React.KeyboardEvent, year: number) => {
    const forward =
      getComputedStyle(e.currentTarget).direction === "rtl" ? -1 : 1;
    let next = year;
    switch (e.key) {
      case "ArrowLeft":
        next = year - forward;
        break;
      case "ArrowRight":
        next = year + forward;
        break;
      case "ArrowUp":
        next = year - 4;
        break;
      case "ArrowDown":
        next = year + 4;
        break;
      case "Home":
        next = year - ((year - ctx.decadeStart) % 4);
        break;
      case "End":
        next = year + (3 - ((year - ctx.decadeStart) % 4));
        break;
      case "PageUp":
        ctx.setDecadeStart(ctx.decadeStart - YEARS_PER_VIEW);
        return;
      case "PageDown":
        ctx.setDecadeStart(ctx.decadeStart + YEARS_PER_VIEW);
        return;
      default:
        return;
    }
    e.preventDefault();
    next = Math.max(ctx.minYear, Math.min(ctx.maxYear, next));
    if (next < ctx.decadeStart || next >= ctx.decadeStart + YEARS_PER_VIEW) {
      ctx.setDecadeStart(viewStartFor(next));
      requestAnimationFrame(() => focusYear(next));
    } else {
      focusYear(next);
    }
  };

  return (
    <PopoverContent
      align="start"
      data-slot="year-picker-content"
      className={cn("w-64 p-3", className)}
      {...props}
    >
      <div
        data-slot="year-picker-header"
        className="mb-2 flex items-center justify-between"
      >
        <Button
          type="button"
          variant="ghost"
          size="icon"
          aria-label="Previous years"
          disabled={!canPrev}
          onClick={() => ctx.setDecadeStart(ctx.decadeStart - YEARS_PER_VIEW)}
          className="size-7"
        >
          <ChevronLeft className="size-3.5 rtl:rotate-180" />
        </Button>
        <span
          data-slot="year-picker-caption"
          className="font-mono text-[11px] tabular-nums uppercase tracking-[0.08em] text-muted-foreground"
        >
          {years[0]}{years[years.length - 1]}
        </span>
        <Button
          type="button"
          variant="ghost"
          size="icon"
          aria-label="Next years"
          disabled={!canNext}
          onClick={() => ctx.setDecadeStart(ctx.decadeStart + YEARS_PER_VIEW)}
          className="size-7"
        >
          <ChevronRight className="size-3.5 rtl:rotate-180" />
        </Button>
      </div>
      <div
        ref={gridRef}
        role="grid"
        aria-label="Years"
        data-slot="year-picker-grid"
        className="grid gap-1"
      >
        {Array.from({ length: YEARS_PER_VIEW / 4 }, (_, row) => (
          <div key={row} role="row" className="grid grid-cols-4 gap-1">
            {years.slice(row * 4, row * 4 + 4).map((year) => {
              const out = !inBounds(year);
              const selected =
                ctx.mode === "single"
                  ? ctx.value === year
                  : isEndpoint(year, ctx.value);
              const inRange =
                ctx.mode === "range" ? isInRange(year, ctx.value) : false;
              const isToday = year === today;
              return (
                <button
                  key={year}
                  type="button"
                  role="gridcell"
                  data-year={year}
                  data-slot="year-picker-cell"
                  disabled={out}
                  aria-selected={selected || inRange}
                  aria-current={isToday ? "date" : undefined}
                  onClick={() => ctx.setValue(year)}
                  onKeyDown={(e) => handleKey(e, year)}
                  tabIndex={year === tabbableYear ? 0 : -1}
                  className={cn(
                    "relative h-9 rounded-sm font-mono text-xs tabular-nums outline-none transition-colors",
                    "hover:bg-accent hover:text-accent-foreground",
                    "focus-visible:ring-2 focus-visible:ring-ring",
                    "disabled:opacity-30 disabled:hover:bg-transparent",
                    inRange && "bg-primary/15 text-foreground",
                    selected &&
                      "bg-primary text-primary-foreground hover:bg-primary",
                    !selected && isToday && "ring-1 ring-inset ring-primary/60",
                  )}
                >
                  {year}
                </button>
              );
            })}
          </div>
        ))}
      </div>
    </PopoverContent>
  );
}

export { YearPicker, YearPickerTrigger, YearPickerContent };

Dependencies

shadcn registry

buttonpopover

npm

lucide-react