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
Calendar states
| State | Appearance | Notes |
|---|---|---|
| Default | Normal date, clickable | Today may be highlighted with a border or bold text |
| Hover | Background highlight with pointer cursor | Indicates interactivity |
| Selected | Filled with accent color, white text | Confirms the chosen date |
| Today | Bold text or accent border | Visual anchor for current date |
| Disabled | Grayed out, non-clickable | Dates outside min/max boundaries |
| Other month | Faded text | Days 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
| Prop | Type | Default | Description |
|---|---|---|---|
value | Date | — | Currently selected date. |
onChange | (date: Date) => void | — | Callback fired when date changes. |
minDate | Date | — | Minimum selectable date. |
maxDate | Date | — | Maximum selectable date. |
disabled | boolean | false | Prevents interaction. |
label | string | — | Label text for the input. |
placeholder | string | Select date | Placeholder text when empty. |
Time picker props
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | Current time in HH:MM format. |
onChange | (time: string) => void | — | Callback fired when time changes. |
minTime | string | — | Earliest selectable time (HH:MM). |
maxTime | string | — | Latest selectable time (HH:MM). |
step | number | 30 | Minute increment (5, 10, 15, 30, 60). |
label | string | — | Label 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.