DevPrep
  • Interview Prep
  • Projects
  • Resources
  • Pricing
  • About Us
Submit Question
DevPrep
  • Pricing
  • About Us
Submit Question
  1. Home
  2. Articles
  3. Frontend Engineering
  4. System Design #7: Design a Calendar / Date Picker
XLinkedInReddit
MediumFrontend Engineering

System Design #7: Design a Calendar / Date Picker

D
DevPrep Team
7 min readยท0
Table of Contents
  • Hey folks, Rahul here ๐Ÿ‘‹
  • R โ€” Requirements
  • Functional Requirements
  • Non-Functional Requirements
  • A โ€” Architecture
  • Why Date Math Is Cursed
  • Component Architecture
  • State Machine Approach
  • D โ€” Data Model
  • Calendar Grid Generation
  • Why 42 Cells Always?
  • I โ€” Interface Definition
  • Component API
  • ARIA Grid Pattern
  • O โ€” Optimizations
  • 1. Roving TabIndex for Keyboard Nav
  • 2. Memoized Grid Computation
  • 3. Transition Animation
  • 4. Lightweight Date Utils (No Libraries)
  • Production Gotchas Rahul Has Debugged ๐Ÿ”ฅ

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 submission

State 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 ๐Ÿ”ฅ

  1. Timezone Traps: new Date("2024-03-15") is parsed as UTC, which in negative-offset timezones becomes March 14 local. Always append T00:00:00 or use the new Date(year, month, day) constructor.
  2. Month Overflow: new Date(2024, 0, 32) silently becomes Feb 1. When adding months, always clamp to the last day of the target month.
  3. 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.
  4. 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.
  5. Locale Week Start: The US starts weeks on Sunday, most of Europe on Monday, and some Middle Eastern countries on Saturday. Intl.Locale can detect this.

Next up: #8: Design a Nested Comments System โ€” recursive data structures, optimistic threading, collapse/expand state, and real-time comment streaming. ๐Ÿงต

Related Articles

MediumFrontend Engineering

System Design #12: Design a Multi-Step Form Wizard

7 min read
MediumFrontend Engineering

Mastering Senior-Level JavaScript Interview Concepts

2 min read
MediumFrontend Engineering

System Design #9: Design a Collaborative Text Editor

9 min read

Comments (0)

Sign in to leave a comment.

No comments yet. Be the first to comment.

Table of Contents

  • Hey folks, Rahul here ๐Ÿ‘‹
  • R โ€” Requirements
  • Functional Requirements
  • Non-Functional Requirements
  • A โ€” Architecture
  • Why Date Math Is Cursed
  • Component Architecture
  • State Machine Approach
  • D โ€” Data Model
  • Calendar Grid Generation
  • Why 42 Cells Always?
  • I โ€” Interface Definition
  • Component API
  • ARIA Grid Pattern
  • O โ€” Optimizations
  • 1. Roving TabIndex for Keyboard Nav
  • 2. Memoized Grid Computation
  • 3. Transition Animation
  • 4. Lightweight Date Utils (No Libraries)
  • Production Gotchas Rahul Has Debugged ๐Ÿ”ฅ

Series

View all Frontend Engineering articles โ†’

Practice

  • JavaScript
  • DSA
  • Machine Coding
  • System Design

Resources

  • Learning Tracks
  • Articles
  • Roadmaps
  • Compare Concepts
  • Glossary
  • Developer Tools
  • All Questions

Company

  • About
  • Pricing

Legal

  • Privacy Policy
  • Terms of Service
DevPrep

ยฉ 2026 DevPrep. All rights reserved.