Skip to main content
Back to Blog
Tutorials
4 min read
December 14, 2024

How to Build a Calendar and Date Picker Component in React

Build a fully accessible calendar and date range picker component in React with keyboard navigation, locale support, and validation.

Ryel Banfield

Founder & Lead Developer

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.

calendardate pickerReactcomponenttutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles