Date pickers are one of the most common yet tricky UI components. Here is how to build one from scratch with full accessibility.
Calendar Hook
"use client";
import { useMemo, useState, useCallback } from "react";
interface CalendarDay {
date: Date;
isCurrentMonth: boolean;
isToday: boolean;
isSelected: boolean;
isDisabled: boolean;
isInRange: boolean;
isRangeStart: boolean;
isRangeEnd: boolean;
}
interface UseCalendarOptions {
selected?: Date | null;
rangeStart?: Date | null;
rangeEnd?: Date | null;
minDate?: Date;
maxDate?: Date;
disabledDates?: Date[];
disabledDaysOfWeek?: number[]; // 0 = Sunday
locale?: string;
}
export function useCalendar(options: UseCalendarOptions = {}) {
const {
selected = null,
rangeStart = null,
rangeEnd = null,
minDate,
maxDate,
disabledDates = [],
disabledDaysOfWeek = [],
locale = "en-US",
} = options;
const [viewDate, setViewDate] = useState(
selected ?? rangeStart ?? new Date()
);
const year = viewDate.getFullYear();
const month = viewDate.getMonth();
const weekdays = useMemo(() => {
const formatter = new Intl.DateTimeFormat(locale, { weekday: "short" });
return Array.from({ length: 7 }, (_, i) => {
const day = new Date(2024, 0, i); // Jan 2024 starts on Monday
return formatter.format(day);
});
}, [locale]);
const monthLabel = useMemo(() => {
return new Intl.DateTimeFormat(locale, {
month: "long",
year: "numeric",
}).format(viewDate);
}, [viewDate, locale]);
const days = useMemo((): CalendarDay[] => {
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const startOffset = firstDay.getDay(); // 0-6
const totalDays = lastDay.getDate();
const today = new Date();
today.setHours(0, 0, 0, 0);
const result: CalendarDay[] = [];
// Previous month filler days
for (let i = startOffset - 1; i >= 0; i--) {
const date = new Date(year, month, -i);
result.push(buildDay(date, false));
}
// Current month days
for (let d = 1; d <= totalDays; d++) {
const date = new Date(year, month, d);
result.push(buildDay(date, true));
}
// Next month filler days
const remaining = 42 - result.length; // 6 rows of 7
for (let d = 1; d <= remaining; d++) {
const date = new Date(year, month + 1, d);
result.push(buildDay(date, false));
}
return result;
function buildDay(date: Date, isCurrentMonth: boolean): CalendarDay {
const normalized = new Date(date);
normalized.setHours(0, 0, 0, 0);
const isDisabled =
(minDate && normalized < minDate) ||
(maxDate && normalized > maxDate) ||
disabledDaysOfWeek.includes(normalized.getDay()) ||
disabledDates.some(
(d) => d.toDateString() === normalized.toDateString()
) ||
false;
const isSelected =
selected?.toDateString() === normalized.toDateString() || false;
let isInRange = false;
let isRangeStart = false;
let isRangeEnd = false;
if (rangeStart && rangeEnd) {
isRangeStart =
normalized.toDateString() === rangeStart.toDateString();
isRangeEnd = normalized.toDateString() === rangeEnd.toDateString();
isInRange = normalized >= rangeStart && normalized <= rangeEnd;
}
return {
date: normalized,
isCurrentMonth,
isToday: normalized.toDateString() === today.toDateString(),
isSelected,
isDisabled,
isInRange,
isRangeStart,
isRangeEnd,
};
}
}, [
year,
month,
selected,
rangeStart,
rangeEnd,
minDate,
maxDate,
disabledDates,
disabledDaysOfWeek,
]);
const goToPreviousMonth = useCallback(
() => setViewDate(new Date(year, month - 1, 1)),
[year, month]
);
const goToNextMonth = useCallback(
() => setViewDate(new Date(year, month + 1, 1)),
[year, month]
);
const goToToday = useCallback(() => setViewDate(new Date()), []);
return {
days,
weekdays,
monthLabel,
year,
month,
viewDate,
goToPreviousMonth,
goToNextMonth,
goToToday,
setViewDate,
};
}
Calendar Component
"use client";
import { useCalendar } from "./useCalendar";
import { useCallback, useRef } from "react";
interface CalendarProps {
selected?: Date | null;
onSelect?: (date: Date) => void;
minDate?: Date;
maxDate?: Date;
disabledDaysOfWeek?: number[];
}
export function Calendar({
selected,
onSelect,
minDate,
maxDate,
disabledDaysOfWeek,
}: CalendarProps) {
const calendar = useCalendar({
selected,
minDate,
maxDate,
disabledDaysOfWeek,
});
const gridRef = useRef<HTMLDivElement>(null);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent, date: Date, index: number) => {
let targetIndex = index;
switch (e.key) {
case "ArrowLeft":
targetIndex = index - 1;
break;
case "ArrowRight":
targetIndex = index + 1;
break;
case "ArrowUp":
targetIndex = index - 7;
break;
case "ArrowDown":
targetIndex = index + 7;
break;
case "Enter":
case " ":
e.preventDefault();
onSelect?.(date);
return;
default:
return;
}
e.preventDefault();
const buttons = gridRef.current?.querySelectorAll("button");
const target = buttons?.[targetIndex] as HTMLButtonElement | undefined;
target?.focus();
},
[onSelect]
);
return (
<div className="w-[300px] bg-card border rounded-lg p-3" role="application" aria-label="Calendar">
{/* Header */}
<div className="flex items-center justify-between mb-3">
<button
onClick={calendar.goToPreviousMonth}
className="p-1.5 rounded hover:bg-muted"
aria-label="Previous month"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<span className="text-sm font-medium">{calendar.monthLabel}</span>
<button
onClick={calendar.goToNextMonth}
className="p-1.5 rounded hover:bg-muted"
aria-label="Next month"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
{/* Weekday headers */}
<div className="grid grid-cols-7 mb-1">
{calendar.weekdays.map((day) => (
<div
key={day}
className="text-center text-xs text-muted-foreground py-1"
aria-hidden
>
{day}
</div>
))}
</div>
{/* Days grid */}
<div ref={gridRef} className="grid grid-cols-7" role="grid">
{calendar.days.map((day, index) => (
<button
key={day.date.toISOString()}
onClick={() => !day.isDisabled && onSelect?.(day.date)}
onKeyDown={(e) => handleKeyDown(e, day.date, index)}
disabled={day.isDisabled}
tabIndex={day.isSelected || (index === 0 && !selected) ? 0 : -1}
aria-label={day.date.toLocaleDateString("en-US", {
weekday: "long",
month: "long",
day: "numeric",
})}
aria-selected={day.isSelected}
className={`
h-9 w-full text-sm rounded flex items-center justify-center
outline-none focus-visible:ring-2 focus-visible:ring-primary
${!day.isCurrentMonth ? "text-muted-foreground/40" : ""}
${day.isToday ? "font-bold" : ""}
${day.isSelected ? "bg-primary text-primary-foreground" : "hover:bg-muted"}
${day.isDisabled ? "opacity-30 cursor-not-allowed" : "cursor-pointer"}
`}
>
{day.date.getDate()}
</button>
))}
</div>
{/* Today button */}
<button
onClick={calendar.goToToday}
className="w-full mt-2 text-xs text-muted-foreground hover:text-foreground py-1"
>
Today
</button>
</div>
);
}
Date Picker with Input
"use client";
import { useState, useRef, useEffect } from "react";
import { Calendar } from "./Calendar";
interface DatePickerProps {
value?: Date | null;
onChange?: (date: Date | null) => void;
placeholder?: string;
minDate?: Date;
maxDate?: Date;
}
export function DatePicker({
value,
onChange,
placeholder = "Select date",
minDate,
maxDate,
}: DatePickerProps) {
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
setOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const handleSelect = (date: Date) => {
onChange?.(date);
setOpen(false);
};
return (
<div ref={containerRef} className="relative inline-block">
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-2 border rounded px-3 py-2 text-sm min-w-[200px] text-left hover:bg-muted"
>
<svg className="w-4 h-4 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span className={value ? "" : "text-muted-foreground"}>
{value
? value.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})
: placeholder}
</span>
</button>
{open && (
<div className="absolute top-full left-0 mt-1 z-50 shadow-lg">
<Calendar
selected={value}
onSelect={handleSelect}
minDate={minDate}
maxDate={maxDate}
/>
</div>
)}
</div>
);
}
Want Custom UI Components?
We design and build accessible, polished UI components for any platform. Contact us to start your project.