Hey folks, Rahul here ๐
Calendars look simple. A grid of numbers, right? Then you hit timezones, locale formatting, range selections, disabled dates, and the absolutely cursed Date API โ and suddenly you're questioning your career choices.
I've built calendar components at scale, and the gap between "renders a month grid" and "production-ready date picker" is enormous. Let's bridge it.
R โ Requirements
Functional Requirements
- Single date selection with month/year navigation
- Date range selection (start โ end) with visual feedback
- Min/max date boundaries and arbitrary disabled dates
- Locale-aware formatting (week starts on Monday vs Sunday)
- Keyboard navigation (arrow keys, Enter, Escape)
- Time picker integration (optional datetime mode)
- Preset ranges: "Last 7 days", "This month", "Custom"
Non-Functional Requirements
- Performance: Month transitions must be <16ms (single frame)
- Accessibility: Full ARIA grid pattern with live region announcements
- i18n: Support RTL layouts, non-Gregorian calendars conceptually
- Size: <5KB gzipped for the core (no moment.js!)
A โ Architecture
Why Date Math Is Cursed
Before we architect, let's acknowledge the enemy:
// Pop quiz: what does this return?
new Date(2024, 1, 30) // โ March 1, 2024 (not Feb 30, it overflows!)
new Date("2024-01-15") // โ Jan 14 in UTC-5 timezones (midnight UTC = previous day local)
new Date("01/15/2024") // โ Jan 15 local time (different parsing!)Rule #1: Never store or compare dates as Date objects internally. Use date strings (YYYY-MM-DD) for calendar logic โ they're timezone-free and comparable.
Component Architecture
DatePicker
โโโ DatePickerTrigger // Button showing selected date(s)
โโโ DatePickerPopover // Floating panel (via floating-ui)
โ โโโ CalendarHeader // Month/Year nav + view switcher
โ โโโ CalendarGrid // The actual day grid
โ โ โโโ WeekdayHeaders
โ โ โโโ DayCell[] // Individual day buttons
โ โโโ PresetPanel // "Last 7 days", "This month"
โ โโโ TimePicker // Optional HH:MM selector
โโโ HiddenInput // For form submissionState Machine Approach
Range selection is a two-step state machine, and modeling it explicitly prevents the #1 range picker bug (inconsistent start/end states):
type RangeState =
| { phase: 'idle' }
| { phase: 'selecting-start'; hoveredDate: string | null }
| { phase: 'selecting-end'; start: string; hoveredDate: string | null }
| { phase: 'complete'; start: string; end: string };
function rangeReducer(state: RangeState, action: RangeAction): RangeState {
switch (action.type) {
case 'CLICK_DATE':
if (state.phase === 'idle' || state.phase === 'complete') {
return { phase: 'selecting-end', start: action.date, hoveredDate: null };
}
if (state.phase === 'selecting-end') {
const [start, end] = action.date < state.start
? [action.date, state.start] // Auto-swap if end < start
: [state.start, action.date];
return { phase: 'complete', start, end };
}
return state;
case 'HOVER_DATE':
if (state.phase === 'selecting-end') {
return { ...state, hoveredDate: action.date };
}
return state;
case 'RESET':
return { phase: 'idle' };
}
}D โ Data Model
Calendar Grid Generation
interface CalendarMonth {
year: number;
month: number; // 0-indexed to match JS Date API
weeks: CalendarWeek[];
}
interface CalendarWeek {
days: CalendarDay[];
}
interface CalendarDay {
date: string; // "2024-03-15" โ the source of truth
dayOfMonth: number; // 15
isCurrentMonth: boolean; // false for leading/trailing days
isToday: boolean;
isDisabled: boolean;
isSelected: boolean;
isInRange: boolean; // For range highlighting
isRangeStart: boolean;
isRangeEnd: boolean;
}
function generateMonth(year: number, month: number, weekStartsOn: 0 | 1 = 0): CalendarDay[][] {
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
// Calculate leading days from previous month
let startOffset = firstDay.getDay() - weekStartsOn;
if (startOffset < 0) startOffset += 7;
const days: CalendarDay[] = [];
const startDate = new Date(firstDay);
startDate.setDate(startDate.getDate() - startOffset);
// Always generate 42 cells (6 weeks) for consistent grid height
for (let i = 0; i < 42; i++) {
const current = new Date(startDate);
current.setDate(current.getDate() + i);
const dateStr = formatDateString(current);
days.push({
date: dateStr,
dayOfMonth: current.getDate(),
isCurrentMonth: current.getMonth() === month,
isToday: dateStr === getTodayString(),
isDisabled: false, // Applied later via props
isSelected: false, // Applied later via state
isInRange: false,
isRangeStart: false,
isRangeEnd: false,
});
}
// Chunk into weeks of 7
return Array.from({ length: 6 }, (_, i) => days.slice(i * 7, (i + 1) * 7));
}
function formatDateString(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}Why 42 Cells Always?
Some months need 4 weeks, some need 6. If you dynamically size the grid, the calendar jumps in height when navigating months. Always rendering 6 rows (42 cells) with leading/trailing days from adjacent months keeps the layout rock-solid.
I โ Interface Definition
Component API
interface DatePickerProps {
mode: 'single' | 'range';
value: string | null; // "YYYY-MM-DD" for single
range?: { start: string; end: string }; // For range mode
onChange: (date: string | null) => void;
onRangeChange?: (range: { start: string; end: string } | null) => void;
// Constraints
minDate?: string;
maxDate?: string;
disabledDates?: string[] | ((date: string) => boolean);
// Locale
locale?: string; // "en-US", "de-DE"
weekStartsOn?: 0 | 1; // 0 = Sunday, 1 = Monday
// Display
placeholder?: string;
format?: string; // "MM/dd/yyyy", "dd.MM.yyyy"
presets?: DatePreset[]; // Quick-select options
showTimePicker?: boolean;
// Form integration
name?: string;
required?: boolean;
disabled?: boolean;
}ARIA Grid Pattern
<div role="grid" aria-label={`${monthName} ${year}`}>
<div role="row">
{weekdays.map(day => (
<div key={day} role="columnheader" abbr={day.full}>{day.short}</div>
))}
</div>
{weeks.map((week, i) => (
<div key={i} role="row">
{week.map(day => (
<button
key={day.date}
role="gridcell"
tabIndex={day.date === focusedDate ? 0 : -1}
aria-selected={day.isSelected}
aria-disabled={day.isDisabled}
aria-current={day.isToday ? 'date' : undefined}
onClick={() => handleSelect(day.date)}
>
{day.dayOfMonth}
</button>
))}
</div>
))}
</div>
<div aria-live="polite" className="sr-only">
{announcement} {/* "March 2024" on month change */}
</div>O โ Optimizations
1. Roving TabIndex for Keyboard Nav
function handleGridKeyDown(e: KeyboardEvent, currentDate: string) {
const d = parseDate(currentDate);
let next: string | null = null;
switch (e.key) {
case 'ArrowRight': next = addDays(d, 1); break;
case 'ArrowLeft': next = addDays(d, -1); break;
case 'ArrowDown': next = addDays(d, 7); break; // Next week
case 'ArrowUp': next = addDays(d, -7); break; // Prev week
case 'Home': next = startOfWeek(d); break;
case 'End': next = endOfWeek(d); break;
case 'PageDown': next = addMonths(d, 1); break; // Next month
case 'PageUp': next = addMonths(d, -1); break;
case 'Enter':
case ' ':
e.preventDefault();
handleSelect(currentDate);
return;
}
if (next) {
e.preventDefault();
setFocusedDate(next);
// If next is outside current month view, navigate the month
if (getMonth(next) !== displayedMonth) {
setDisplayedMonth(getMonth(next));
}
}
}2. Memoized Grid Computation
const calendarGrid = useMemo(() => {
const weeks = generateMonth(displayYear, displayMonth, weekStartsOn);
// Apply selection state
return weeks.map(week =>
week.map(day => ({
...day,
isDisabled: isDateDisabled(day.date, minDate, maxDate, disabledDates),
isSelected: mode === 'single' ? day.date === value : false,
isInRange: mode === 'range' ? isInRange(day.date, rangeStart, rangeEndOrHovered) : false,
isRangeStart: day.date === rangeStart,
isRangeEnd: day.date === (rangeEnd || hoveredDate),
}))
);
}, [displayYear, displayMonth, weekStartsOn, value, rangeStart, rangeEnd, hoveredDate, minDate, maxDate]);3. Transition Animation
// Slide direction based on month navigation
const [direction, setDirection] = useState<'left' | 'right'>('left');
function navigateMonth(delta: number) {
setDirection(delta > 0 ? 'left' : 'right');
setDisplayedMonth(prev => {
const d = new Date(prev.year, prev.month + delta);
return { year: d.getFullYear(), month: d.getMonth() };
});
}
// In render โ CSS transition or framer-motion
<AnimatePresence mode="popLayout" initial={false}>
<motion.div
key={`${displayYear}-${displayMonth}`}
initial={{ x: direction === 'left' ? 50 : -50, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: direction === 'left' ? -50 : 50, opacity: 0 }}
transition={{ duration: 0.2 }}
>
<CalendarGrid weeks={calendarGrid} />
</motion.div>
</AnimatePresence>4. Lightweight Date Utils (No Libraries)
// Skip date-fns/moment โ these 5 functions cover 95% of calendar needs
function addDays(dateStr: string, days: number): string {
const [y, m, d] = dateStr.split('-').map(Number);
const date = new Date(y, m - 1, d + days);
return formatDateString(date);
}
function addMonths(dateStr: string, months: number): string {
const [y, m, d] = dateStr.split('-').map(Number);
const date = new Date(y, m - 1 + months, d);
// Clamp: if March 31 + 1 month โ April 30 (not May 1)
const targetMonth = (m - 1 + months) % 12;
if (date.getMonth() !== (targetMonth < 0 ? targetMonth + 12 : targetMonth)) {
date.setDate(0); // Go to last day of previous month
}
return formatDateString(date);
}
function diffDays(a: string, b: string): number {
const da = new Date(a + 'T00:00:00');
const db = new Date(b + 'T00:00:00');
return Math.round((db.getTime() - da.getTime()) / 86400000);
}
function isInRange(date: string, start: string | null, end: string | null): boolean {
if (!start || !end) return false;
return date >= start && date <= end;
}
function getTodayString(): string {
const now = new Date();
return formatDateString(now);
}Production Gotchas Rahul Has Debugged ๐ฅ
- Timezone Traps:
new Date("2024-03-15")is parsed as UTC, which in negative-offset timezones becomes March 14 local. Always appendT00:00:00or use thenew Date(year, month, day)constructor. - Month Overflow:
new Date(2024, 0, 32)silently becomes Feb 1. When adding months, always clamp to the last day of the target month. - Focus Management: When the popover opens, focus should move to the currently selected date (or today). When it closes, focus must return to the trigger button โ or forms break for keyboard users.
- Range Hover Preview: The visual range highlight on hover should swap start/end if the hovered date is before the start date โ users expect this, and it prevents confusion.
- Locale Week Start: The US starts weeks on Sunday, most of Europe on Monday, and some Middle Eastern countries on Saturday.
Intl.Localecan detect this.
Next up: #8: Design a Nested Comments System โ recursive data structures, optimistic threading, collapse/expand state, and real-time comment streaming. ๐งต