Skip to content
38

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

Usage

import { YearPicker, YearPickerTrigger, YearPickerContent } from "@/components/year-picker"

Paths assume the default @/ alias from your components.json. The example above shows the parts composed.

API

<YearPicker />

PropTypeDefault
minYearnumbernone
maxYearnumbernone
disabledbooleannone
openbooleannone
defaultOpenbooleannone
onOpenChange((open: boolean) => void)none
childrenReact.ReactNodenone
mode"single" | "range"none
valuenumber | YearRange | nullnone
defaultValuenumber | YearRange | nullnone
onValueChange((year: number) => void) | ((range: YearRange) => void)none

<YearPickerTrigger />

+ native element props
PropTypeDefault
placeholderstring'Pick a year'
childrenReact.ReactNodenone

<YearPickerContent />

+ native element props
PropTypeDefault
previousLabelstring'Previous years'
nextLabelstring'Next years'
gridLabel

Accessible name of the year grid.

string'Years'

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/bases/radix/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/registry/hirael/bases/radix/ui/popover';
import { useControllableState } from '@/registry/hirael/hooks/use-controllable-state';

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

interface YearPickerSharedContext {
  setValue: (year: number) => void;
  minYear: number;
  maxYear: number;
  decadeStart: number;
  setDecadeStart: (n: number) => void;
  disabled?: boolean;
}

type YearPickerContextValue =
  | (YearPickerSharedContext & { mode: 'single'; value: number | undefined })
  | (YearPickerSharedContext & { mode: 'range'; value: YearRange | undefined });

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

const 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;
// Each view is a decade plus one year either side, so paging steps a decade and views stay aligned.
const YEARS_PER_PAGE = 10;

const viewStartFor = (year: number) => year - (year % 10) - 1;

interface YearPickerSharedProps {
  minYear?: number;
  maxYear?: number;
  disabled?: boolean;
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  children?: React.ReactNode;
}

/** `null` and `undefined` both mean no selection; `value={null}` stays controlled. */
export type YearPickerProps =
  | (YearPickerSharedProps & {
      mode?: 'single';
      value?: number | null;
      defaultValue?: number | null;
      onValueChange?: (year: number) => void;
    })
  | (YearPickerSharedProps & {
      mode: 'range';
      value?: YearRange | null;
      defaultValue?: YearRange | null;
      onValueChange?: (range: YearRange) => void;
    });

const YearPicker = (props: YearPickerProps) => {
  const {
    minYear = 1900,
    maxYear = 2100,
    disabled,
    open: openProp,
    defaultOpen = false,
    onOpenChange,
    children,
  } = props;
  const mode = props.mode ?? 'single';
  const {
    value: valueProp,
    defaultValue,
    onValueChange,
  } = props as {
    value?: number | YearRange | null;
    defaultValue?: number | YearRange | null;
    onValueChange?: (v: number | YearRange) => void;
  };

  const [open, setOpen] = useControllableState({
    prop: openProp,
    defaultProp: defaultOpen,
    onChange: onOpenChange,
  });

  const [internal, setInternal] = React.useState(defaultValue ?? undefined);
  const value = valueProp !== undefined ? (valueProp ?? undefined) : internal;
  const anchorOf = (v: number | YearRange | null | undefined) =>
    typeof v === 'object' && v ? v.from : (v ?? undefined);
  const anchorYear = anchorOf(value) ?? new Date().getFullYear();
  const [decadeStart, setDecadeStart] = React.useState<number>(viewStartFor(anchorYear));

  const controlledAnchorYear = anchorOf(valueProp);
  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 setValue = React.useCallback(
    (year: number) => {
      const current = value as YearRange | undefined;
      const next: number | YearRange =
        mode === 'single'
          ? year
          : !current || current.to !== undefined
            ? { from: year }
            : year < current.from
              ? { from: year, to: current.from }
              : { from: current.from, to: year };
      if (valueProp === undefined) setInternal(next);
      onValueChange?.(next);
      if (mode === 'single' || (next as YearRange).to !== undefined) setOpen(false);
    },
    [mode, value, valueProp, onValueChange, setOpen],
  );

  const ctx = React.useMemo(
    () =>
      ({ mode, value, setValue, minYear, maxYear, decadeStart, setDecadeStart, disabled }) as YearPickerContextValue,
    [mode, value, setValue, minYear, maxYear, decadeStart, setDecadeStart, disabled],
  );

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

const formatYearValue = (ctx: YearPickerContextValue, placeholder: string) => {
  if (ctx.value === undefined) return placeholder;
  if (ctx.mode === 'single') return String(ctx.value);

  return `${ctx.value.from} – ${ctx.value.to ?? '…'}`;
};

const 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"
        variant="outline"
        disabled={ctx.disabled}
        data-slot="year-picker-trigger"
        data-empty={empty || undefined}
        className={cn('w-full justify-between font-normal tabular-nums data-empty:text-muted-foreground', className)}
        {...props}
      >
        {children ?? formatYearValue(ctx, placeholder)}
      </Button>
    </PopoverTrigger>
  );
};

const isInRange = (year: number, range: YearRange | undefined) =>
  !!range && range.to !== undefined && year > range.from && year < range.to;

const isEndpoint = (year: number, range: YearRange | undefined) =>
  !!range && (year === range.from || year === range.to);

interface YearPickerContentProps extends React.ComponentProps<typeof PopoverContent> {
  previousLabel?: string;
  nextLabel?: string;
  /** Accessible name of the year grid. */
  gridLabel?: string;
}

const YearPickerContent = ({
  previousLabel = 'Previous years',
  nextLabel = 'Next years',
  gridLabel = 'Years',
  className,
  ...props
}: YearPickerContentProps) => {
  const ctx = useYearPicker();
  const years = Array.from({ length: YEARS_PER_VIEW }, (_, i) => ctx.decadeStart + i);
  const today = new Date().getFullYear();

  // Page only while the neighbouring view still holds a selectable year.
  const canPrev = ctx.decadeStart > ctx.minYear;
  const canNext = ctx.decadeStart + YEARS_PER_VIEW <= ctx.maxYear;

  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) => {
    gridRef.current?.querySelector<HTMLButtonElement>(`[data-year="${year}"]`)?.focus();
  };

  const handleKey = (e: React.KeyboardEvent, year: number) => {
    const forward = getComputedStyle(e.currentTarget).direction === 'rtl' ? -1 : 1;
    const column = (year - ctx.decadeStart) % 4;
    const delta = (
      { ArrowLeft: -forward, ArrowRight: forward, ArrowUp: -4, ArrowDown: 4, Home: -column, End: 3 - column } as Record<
        string,
        number
      >
    )[e.key];
    if (e.key === 'PageUp' || e.key === 'PageDown') {
      e.preventDefault();
      const up = e.key === 'PageUp';
      // Same step as the header buttons; at the edge, stay on this view and move to its last selectable year.
      const pageable = up ? canPrev : canNext;
      const start = pageable ? ctx.decadeStart + (up ? -YEARS_PER_PAGE : YEARS_PER_PAGE) : ctx.decadeStart;
      const lo = Math.max(ctx.minYear, start);
      const hi = Math.min(ctx.maxYear, start + YEARS_PER_VIEW - 1);
      const target = Math.max(lo, Math.min(hi, year + (up ? -YEARS_PER_PAGE : YEARS_PER_PAGE)));
      if (start === ctx.decadeStart) {
        focusYear(target);

        return;
      }
      ctx.setDecadeStart(start);
      // The focused cell unmounts with the old view; refocus once the new one renders.
      requestAnimationFrame(() => focusYear(target));

      return;
    }
    if (delta === undefined) return;
    e.preventDefault();
    const next = Math.max(ctx.minYear, Math.min(ctx.maxYear, year + delta));
    if (next < ctx.decadeStart || next >= ctx.decadeStart + YEARS_PER_VIEW) {
      ctx.setDecadeStart(viewStartFor(next));
      requestAnimationFrame(() => focusYear(next));
    } else {
      focusYear(next);
    }
  };

  const navButton = (step: -1 | 1) => (
    <Button
      type="button"
      variant="ghost"
      size="icon"
      aria-label={step < 0 ? previousLabel : nextLabel}
      disabled={step < 0 ? !canPrev : !canNext}
      onClick={() => ctx.setDecadeStart(ctx.decadeStart + step * YEARS_PER_PAGE)}
      className="size-7"
    >
      {step < 0 ? (
        <ChevronLeft className="size-3.5 rtl:rotate-180" />
      ) : (
        <ChevronRight className="size-3.5 rtl:rotate-180" />
      )}
    </Button>
  );

  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">
        {navButton(-1)}
        <span data-slot="year-picker-caption" className="text-xs text-muted-foreground uppercase tabular-nums">
          {years[0]} – {years[years.length - 1]}
        </span>
        {navButton(1)}
      </div>
      <div ref={gridRef} role="grid" aria-label={gridLabel} 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 text-xs tabular-nums transition-colors outline-none',
                    '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-primary/60 ring-inset',
                  )}
                >
                  {year}
                </button>
              );
            })}
          </div>
        ))}
      </div>
    </PopoverContent>
  );
};

export { YearPicker, YearPickerTrigger, YearPickerContent };

Dependencies

shadcn registry

buttonpopoveruse-controllable-state

npm

lucide-react