Date and Time Pickers

Help users select dates and times accurately with calendar views and time lists. Supports date ranges, min/max boundaries, and keyboard navigation.

Date picker

Choose specific dates from a calendar interface. Supports single dates or date ranges with configurable boundaries.

📅

Selected: Thursday, July 9, 2026

Time picker

Select precise times from a scrollable list or dropdown. Supports 12-hour and 24-hour formats with configurable increments.

🕒

Use cases

Single date

Choose one date like appointment, birthday, or deadline

Date range

Select start and end dates for booking or reporting

Date picker anatomy

January 2024SMTWTFS12345678910111213141516171819202122232425262728293031January 15, 2024SELECT DATE

Calendar states

StateAppearanceNotes
DefaultNormal date, clickableToday may be highlighted with a border or bold text
HoverBackground highlight with pointer cursorIndicates interactivity
SelectedFilled with accent color, white textConfirms the chosen date
TodayBold text or accent borderVisual anchor for current date
DisabledGrayed out, non-clickableDates outside min/max boundaries
Other monthFaded textDays from adjacent months

Usage guidelines

✓ Do
  • ·Provide a clear label indicating what date is needed
  • ·Set sensible min/max boundaries to prevent invalid dates
  • ·Default to today or a meaningful date, not an arbitrary past date
  • ·Include a placeholder showing the expected format
✗ Don't
  • ·Don't make users type dates manually in most cases
  • ·Avoid date pickers for birth dates far in the past (use age input)
  • ·Don't rely on date pickers alone — always validate on the server
  • ·Avoid calendar widgets that require excessive scrolling

Date picker props

PropTypeDefaultDescription
valueDateCurrently selected date.
onChange(date: Date) => voidCallback fired when date changes.
minDateDateMinimum selectable date.
maxDateDateMaximum selectable date.
disabledbooleanfalsePrevents interaction.
labelstringLabel text for the input.
placeholderstringSelect datePlaceholder text when empty.

Time picker props

PropTypeDefaultDescription
valuestringCurrent time in HH:MM format.
onChange(time: string) => voidCallback fired when time changes.
minTimestringEarliest selectable time (HH:MM).
maxTimestringLatest selectable time (HH:MM).
stepnumber30Minute increment (5, 10, 15, 30, 60).
labelstringLabel text for the input.
format"12h" | "24h""24h"Time format to display.

Implementation

DatePicker.tsx
tsx
"use client";

import { useState, useRef, useEffect } from "react";
import { Calendar as CalendarIcon, ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib";

type DatePickerProps = {
  value?: Date;
  onChange?: (date: Date) => void;
  minDate?: Date;
  maxDate?: Date;
  label?: string;
  placeholder?: string;
};

const DAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];

export function DatePicker({
  value,
  onChange,
  minDate,
  maxDate,
  label,
  placeholder = "Select date",
}: DatePickerProps) {
  const [isOpen, setIsOpen] = useState(false);
  const [displayMonth, setDisplayMonth] = useState(value || new Date());
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const handleClick = (e: MouseEvent) => {
      if (ref.current && !ref.current.contains(e.target as Node)) {
        setIsOpen(false);
      }
    };
    document.addEventListener("mousedown", handleClick);
    return () => document.removeEventListener("mousedown", handleClick);
  }, []);

  const year = displayMonth.getFullYear();
  const month = displayMonth.getMonth();
  const firstDay = new Date(year, month, 1).getDay();
  const daysInMonth = new Date(year, month + 1, 0).getDate();
  const prevMonthDays = new Date(year, month, 0).getDate();

  const generateCalendar = () => {
    const days = [];
    // Previous month days
    for (let i = firstDay - 1; i >= 0; i--) {
      days.push({
        day: prevMonthDays - i,
        isOtherMonth: true,
        date: new Date(year, month - 1, prevMonthDays - i),
      });
    }
    // Current month days
    const today = new Date();
    today.setHours(0, 0, 0, 0);
    for (let day = 1; day <= daysInMonth; day++) {
      const date = new Date(year, month, day);
      const isDisabled = (minDate && date < minDate) || (maxDate && date > maxDate);
      const isSelected = value && date.toDateString() === value.toDateString();
      const isToday = date.toDateString() === today.toDateString();
      days.push({ day, isOtherMonth: false, date, isDisabled, isSelected, isToday });
    }
    // Next month days
    const totalCells = Math.ceil((days.length + firstDay) / 7) * 7;
    const remaining = totalCells - days.length;
    for (let day = 1; day <= remaining; day++) {
      days.push({
        day,
        isOtherMonth: true,
        date: new Date(year, month + 1, day),
      });
    }
    return days;
  };

  const handleDayClick = (date: Date, isDisabled: boolean) => {
    if (isDisabled) return;
    onChange?.(date);
    setIsOpen(false);
  };

  const prevMonth = () => setDisplayMonth(new Date(year, month - 1));
  const nextMonth = () => setDisplayMonth(new Date(year, month + 1));
  const calendarDays = generateCalendar();

  return (
    <div className="relative" ref={ref}>
      {label && (
        <label className="block text-[12px] font-medium text-[rgb(var(--text-secondary))] mb-2">
          {label}
        </label>
      )}
      <button
        type="button"
        onClick={() => setIsOpen(!isOpen)}
        className={cn(
          "w-full rounded-lg border px-4 py-2.5 text-left flex items-center gap-2",
          "bg-[rgb(var(--surface))] border-[rgb(var(--border))]",
          "text-[13px] text-[rgb(var(--text-primary))]",
          "hover:border-[rgb(var(--accent))] focus:border-[rgb(var(--accent))]",
          "focus:outline-none focus:ring-2 focus:ring-[rgb(var(--accent))]/20",
          "transition-colors"
        )}
      >
        <CalendarIcon className="w-4 h-4 text-[rgb(var(--text-tertiary))]" />
        <span className="flex-1 truncate">
          {value ? value.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" }) : placeholder}
        </span>
        <ChevronRight className={cn("w-4 h-4 text-[rgb(var(--text-tertiary))] transition-transform", isOpen && "rotate-90")} />
      </button>

      {isOpen && (
        <div className="absolute top-full left-0 mt-2 z-50">
          <div className="rounded-xl border border-[rgb(var(--border))] bg-[rgb(var(--surface-raised))] shadow-[0_8px_32px_rgba(0,0,0,0.2)] p-4">
            {/* Header */}
            <div className="flex items-center justify-between mb-4">
              <button
                onClick={prevMonth}
                className="p-1.5 rounded hover:bg-[rgb(var(--surface))] transition-colors"
                aria-label="Previous month"
              >
                <ChevronLeft className="w-4 h-4" />
              </button>
              <span className="text-[14px] font-medium text-[rgb(var(--text-primary))]">
                {displayMonth.toLocaleDateString(undefined, { month: "long", year: "numeric" })}
              </span>
              <button
                onClick={nextMonth}
                className="p-1.5 rounded hover:bg-[rgb(var(--surface))] transition-colors"
                aria-label="Next month"
              >
                <ChevronRight className="w-4 h-4" />
              </button>
            </div>
            {/* Weekday labels */}
            <div className="grid grid-cols-7 gap-1 mb-2">
              {DAYS.map((day) => (
                <div key={day} className="text-center text-[10px] font-semibold text-[rgb(var(--text-tertiary))] py-1">
                  {day}
                </div>
              ))}
            </div>
            {/* Calendar grid */}
            <div className="grid grid-cols-7 gap-1">
              {calendarDays.map(({ day, isOtherMonth, date, isDisabled, isSelected, isToday }, i) => (
                <button
                  key={i}
                  onClick={() => handleDayClick(date, !!isDisabled)}
                  disabled={isDisabled}
                  className={cn(
                    "w-8 h-8 rounded-full text-[12px] font-medium flex items-center justify-center",
                    "transition-colors",
                    isOtherMonth && "text-[rgb(var(--text-tertiary))] opacity-40",
                    isDisabled && "text-[rgb(var(--text-tertiary))] opacity-30 cursor-not-allowed",
                    isSelected && "bg-[rgb(var(--accent))] text-white",
                    !isSelected && !isDisabled && !isOtherMonth && "hover:bg-[rgb(var(--surface))] text-[rgb(var(--text-primary))]",
                    isToday && !isSelected && "font-bold border border-[rgb(var(--accent))]",
                  )}
                >
                  {day}
                </button>
              ))}
            </div>
          </div>
        </div>
      )}
    </div>
  );
}

export function TimePicker({
  value,
  onChange,
  minTime,
  maxTime,
  step = 30,
  label,
  format = "24h",
}: TimePickerProps) {
  const [isOpen, setIsOpen] = useState(false);

  const generateTimes = () => {
    const times = [];
    const [minHour, minMin] = minTime ? minTime.split(":").map(Number) : [0, 0];
    const [maxHour, maxMin] = maxTime ? maxTime.split(":").map(Number) : [23, 59];
    const minTotal = minHour * 60 + minMin;
    const maxTotal = maxHour * 60 + maxMin;

    for (let minutes = minTotal; minutes <= maxTotal; minutes += step) {
      const h = Math.floor(minutes / 60);
      const m = minutes % 60;
      const time24 = String(h).padStart(2, "0") + ":" + String(m).padStart(2, "0");
      const time12 = format === "12h"
        ? (h % 12 || 12) + ":" + String(m).padStart(2, "0") + " " + (h < 12 ? "AM" : "PM")
        : time24;
      times.push({ value: time24, label: time12 });
    }
    return times;
  };

  const times = generateTimes();

  return (
    <div className="relative">
      {label && (
        <label className="block text-[12px] font-medium text-[rgb(var(--text-secondary))] mb-2">
          {label}
        </label>
      )}
      <button
        type="button"
        onClick={() => setIsOpen(!isOpen)}
        className={cn(
          "w-full rounded-lg border px-4 py-2.5 text-left flex items-center gap-2",
          "bg-[rgb(var(--surface))] border-[rgb(var(--border))]",
          "text-[13px] text-[rgb(var(--text-primary))]",
          "hover:border-[rgb(var(--accent))] focus:border-[rgb(var(--accent))]",
          "focus:outline-none focus:ring-2 focus:ring-[rgb(var(--accent))]/20",
        )}
      >
        <span className="flex-1 truncate">{value || "Select time"}</span>
      </button>

      {isOpen && (
        <div className="absolute top-full left-0 mt-2 z-50 w-full">
          <div className="rounded-xl border border-[rgb(var(--border))] bg-[rgb(var(--surface-raised))] shadow-[0_8px_32px_rgba(0,0,0,0.2)] p-2 max-h-60 overflow-auto">
            {times.map(({ value: t, label }) => (
              <button
                key={t}
                onClick={() => {
                  onChange?.(t);
                  setIsOpen(false);
                }}
                className={cn(
                  "w-full text-left px-3 py-2 rounded-lg text-[13px]",
                  "hover:bg-[rgb(var(--surface))] transition-colors",
                  value === t && "bg-[rgb(var(--accent-subtle))] text-[rgb(var(--accent))] font-medium",
                )}
              >
                {label}
              </button>
            ))}
          </div>
        </div>
      )}
    </div>
  );
}

Accessibility

  • Date pickers must have associated labels via aria-label or aria-labelledby.
  • Keyboard: Arrow keys navigate days; PageUp/PageDown change months; Home/End go to start/end of week.
  • Time pickers must announce the currently focused time option.
  • ESC key should close open pickers; Enter should select the focused date/time.
  • Provide clear announcements when min/max boundaries prevent selection.
  • The calendar popup must have role='dialog' and aria-modal='true' when open.