Skip to main content
Back to Blog
Tutorials
5 min read
January 28, 2025

How to Build an Accessible Date Range Picker in React

Create a fully accessible date range picker with keyboard navigation, ARIA attributes, locale formatting, and preset ranges.

Ryel Banfield

Founder & Lead Developer

Date range pickers are used in booking systems, analytics dashboards, and reporting tools. Building one that is fully accessible ensures all users can interact with it.

Date Utilities

export function getDaysInMonth(year: number, month: number): number {
  return new Date(year, month + 1, 0).getDate();
}

export function getFirstDayOfMonth(year: number, month: number): number {
  return new Date(year, month, 1).getDay();
}

export function isSameDay(a: Date, b: Date): boolean {
  return (
    a.getFullYear() === b.getFullYear() &&
    a.getMonth() === b.getMonth() &&
    a.getDate() === b.getDate()
  );
}

export function isInRange(date: Date, start: Date | null, end: Date | null): boolean {
  if (!start || !end) return false;
  const time = date.getTime();
  return time >= start.getTime() && time <= end.getTime();
}

export function formatDate(date: Date, locale = "en-US"): string {
  return date.toLocaleDateString(locale, {
    year: "numeric",
    month: "short",
    day: "numeric",
  });
}

export interface DateRange {
  start: Date | null;
  end: Date | null;
}

export interface PresetRange {
  label: string;
  range: DateRange;
}

export function getPresets(): PresetRange[] {
  const today = new Date();
  today.setHours(0, 0, 0, 0);

  const days = (n: number) => {
    const d = new Date(today);
    d.setDate(d.getDate() - n);
    return d;
  };

  return [
    { label: "Last 7 days", range: { start: days(7), end: today } },
    { label: "Last 30 days", range: { start: days(30), end: today } },
    { label: "Last 90 days", range: { start: days(90), end: today } },
    { label: "This month", range: { start: new Date(today.getFullYear(), today.getMonth(), 1), end: today } },
    { label: "Last month", range: { start: new Date(today.getFullYear(), today.getMonth() - 1, 1), end: new Date(today.getFullYear(), today.getMonth(), 0) } },
  ];
}

Calendar Grid Component

"use client";

import { useRef, useCallback } from "react";
import { getDaysInMonth, getFirstDayOfMonth, isSameDay, isInRange } from "./date-utils";
import type { DateRange } from "./date-utils";

interface CalendarGridProps {
  year: number;
  month: number;
  range: DateRange;
  onSelectDate: (date: Date) => void;
  minDate?: Date;
  maxDate?: Date;
}

const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];

export function CalendarGrid({
  year,
  month,
  range,
  onSelectDate,
  minDate,
  maxDate,
}: CalendarGridProps) {
  const gridRef = useRef<HTMLDivElement>(null);
  const daysInMonth = getDaysInMonth(year, month);
  const firstDay = getFirstDayOfMonth(year, month);

  const isDisabled = useCallback(
    (date: Date): boolean => {
      if (minDate && date < minDate) return true;
      if (maxDate && date > maxDate) return true;
      return false;
    },
    [minDate, maxDate],
  );

  const handleKeyDown = useCallback(
    (e: React.KeyboardEvent, date: Date) => {
      let newDate: Date | null = null;

      switch (e.key) {
        case "ArrowLeft":
          newDate = new Date(date);
          newDate.setDate(date.getDate() - 1);
          break;
        case "ArrowRight":
          newDate = new Date(date);
          newDate.setDate(date.getDate() + 1);
          break;
        case "ArrowUp":
          newDate = new Date(date);
          newDate.setDate(date.getDate() - 7);
          break;
        case "ArrowDown":
          newDate = new Date(date);
          newDate.setDate(date.getDate() + 7);
          break;
        case "Enter":
        case " ":
          e.preventDefault();
          if (!isDisabled(date)) {
            onSelectDate(date);
          }
          return;
        default:
          return;
      }

      e.preventDefault();
      if (newDate && !isDisabled(newDate)) {
        // Focus the button for the new date
        const dayNum = newDate.getDate();
        const buttons = gridRef.current?.querySelectorAll("[data-day]");
        const target = Array.from(buttons || []).find(
          (btn) => btn.getAttribute("data-day") === String(dayNum),
        ) as HTMLElement | undefined;
        target?.focus();
      }
    },
    [isDisabled, onSelectDate],
  );

  const days: (number | null)[] = [];
  for (let i = 0; i < firstDay; i++) {
    days.push(null);
  }
  for (let i = 1; i <= daysInMonth; i++) {
    days.push(i);
  }

  const monthLabel = new Date(year, month).toLocaleDateString("en-US", {
    month: "long",
    year: "numeric",
  });

  return (
    <div role="grid" aria-label={monthLabel} ref={gridRef}>
      {/* Header row */}
      <div role="row" className="grid grid-cols-7 mb-1">
        {WEEKDAYS.map((day) => (
          <div
            key={day}
            role="columnheader"
            className="text-center text-xs font-medium text-muted-foreground py-1"
            aria-label={day}
          >
            {day}
          </div>
        ))}
      </div>

      {/* Day cells */}
      <div role="rowgroup" className="grid grid-cols-7">
        {days.map((day, idx) => {
          if (!day) {
            return <div key={`empty-${idx}`} role="gridcell" />;
          }

          const date = new Date(year, month, day);
          const disabled = isDisabled(date);
          const isStart = range.start ? isSameDay(date, range.start) : false;
          const isEnd = range.end ? isSameDay(date, range.end) : false;
          const inRange = isInRange(date, range.start, range.end);

          return (
            <div key={day} role="gridcell">
              <button
                type="button"
                data-day={day}
                disabled={disabled}
                tabIndex={day === 1 ? 0 : -1}
                aria-selected={isStart || isEnd}
                aria-label={`${date.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric" })}${isStart ? ", range start" : ""}${isEnd ? ", range end" : ""}`}
                onClick={() => onSelectDate(date)}
                onKeyDown={(e) => handleKeyDown(e, date)}
                className={`
                  w-full aspect-square flex items-center justify-center text-sm rounded-md
                  focus:outline-none focus:ring-2 focus:ring-primary
                  disabled:opacity-25 disabled:cursor-not-allowed
                  ${isStart || isEnd ? "bg-primary text-primary-foreground" : ""}
                  ${inRange && !isStart && !isEnd ? "bg-primary/10" : ""}
                  ${!inRange && !isStart && !isEnd ? "hover:bg-muted" : ""}
                `}
              >
                {day}
              </button>
            </div>
          );
        })}
      </div>
    </div>
  );
}

Date Range Picker

"use client";

import { useState, useCallback, useRef, useEffect } from "react";
import { CalendarGrid } from "./CalendarGrid";
import { formatDate, getPresets } from "./date-utils";
import type { DateRange } from "./date-utils";

interface DateRangePickerProps {
  value: DateRange;
  onChange: (range: DateRange) => void;
  minDate?: Date;
  maxDate?: Date;
}

export function DateRangePicker({ value, onChange, minDate, maxDate }: DateRangePickerProps) {
  const [open, setOpen] = useState(false);
  const [viewDate, setViewDate] = useState(() => value.start || new Date());
  const [selecting, setSelecting] = useState<"start" | "end">("start");
  const containerRef = useRef<HTMLDivElement>(null);
  const presets = getPresets();

  // Close on click outside
  useEffect(() => {
    function handleClickOutside(e: MouseEvent) {
      if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
        setOpen(false);
      }
    }
    if (open) {
      document.addEventListener("mousedown", handleClickOutside);
      return () => document.removeEventListener("mousedown", handleClickOutside);
    }
  }, [open]);

  // Close on Escape
  useEffect(() => {
    function handleKeyDown(e: KeyboardEvent) {
      if (e.key === "Escape") setOpen(false);
    }
    if (open) {
      document.addEventListener("keydown", handleKeyDown);
      return () => document.removeEventListener("keydown", handleKeyDown);
    }
  }, [open]);

  const handleSelectDate = useCallback(
    (date: Date) => {
      if (selecting === "start") {
        onChange({ start: date, end: null });
        setSelecting("end");
      } else {
        if (value.start && date < value.start) {
          onChange({ start: date, end: value.start });
        } else {
          onChange({ start: value.start, end: date });
        }
        setSelecting("start");
        setOpen(false);
      }
    },
    [selecting, value, onChange],
  );

  const prevMonth = () => {
    setViewDate((d) => new Date(d.getFullYear(), d.getMonth() - 1, 1));
  };

  const nextMonth = () => {
    setViewDate((d) => new Date(d.getFullYear(), d.getMonth() + 1, 1));
  };

  const displayText =
    value.start && value.end
      ? `${formatDate(value.start)} - ${formatDate(value.end)}`
      : value.start
        ? `${formatDate(value.start)} - Select end date`
        : "Select date range";

  const nextMonthDate = new Date(viewDate.getFullYear(), viewDate.getMonth() + 1, 1);

  return (
    <div ref={containerRef} className="relative inline-block">
      <button
        type="button"
        onClick={() => setOpen(!open)}
        aria-haspopup="dialog"
        aria-expanded={open}
        className="flex items-center gap-2 px-3 py-2 border rounded-md text-sm hover:bg-muted"
      >
        <span aria-hidden="true">&#128197;</span>
        <span>{displayText}</span>
      </button>

      {open && (
        <div
          role="dialog"
          aria-label="Select date range"
          className="absolute top-full mt-1 left-0 z-50 bg-background border rounded-lg shadow-lg p-4 flex gap-4"
        >
          {/* Presets */}
          <div className="flex flex-col gap-1 border-r pr-4 min-w-[140px]">
            <p className="text-xs font-medium text-muted-foreground mb-1">Presets</p>
            {presets.map((preset) => (
              <button
                key={preset.label}
                type="button"
                onClick={() => {
                  onChange(preset.range);
                  setOpen(false);
                }}
                className="text-left text-sm px-2 py-1 rounded hover:bg-muted"
              >
                {preset.label}
              </button>
            ))}
          </div>

          {/* Calendars */}
          <div className="flex gap-4">
            <div>
              <div className="flex items-center justify-between mb-2">
                <button
                  type="button"
                  onClick={prevMonth}
                  aria-label="Previous month"
                  className="p-1 rounded hover:bg-muted"
                >
                  &#8249;
                </button>
                <span className="text-sm font-medium">
                  {viewDate.toLocaleDateString("en-US", { month: "long", year: "numeric" })}
                </span>
                <span className="w-6" />
              </div>
              <CalendarGrid
                year={viewDate.getFullYear()}
                month={viewDate.getMonth()}
                range={value}
                onSelectDate={handleSelectDate}
                minDate={minDate}
                maxDate={maxDate}
              />
            </div>

            <div>
              <div className="flex items-center justify-between mb-2">
                <span className="w-6" />
                <span className="text-sm font-medium">
                  {nextMonthDate.toLocaleDateString("en-US", { month: "long", year: "numeric" })}
                </span>
                <button
                  type="button"
                  onClick={nextMonth}
                  aria-label="Next month"
                  className="p-1 rounded hover:bg-muted"
                >
                  &#8250;
                </button>
              </div>
              <CalendarGrid
                year={nextMonthDate.getFullYear()}
                month={nextMonthDate.getMonth()}
                range={value}
                onSelectDate={handleSelectDate}
                minDate={minDate}
                maxDate={maxDate}
              />
            </div>
          </div>
        </div>
      )}
    </div>
  );
}

Usage

"use client";

import { useState } from "react";
import { DateRangePicker } from "./DateRangePicker";
import type { DateRange } from "./date-utils";

export default function ReportsPage() {
  const [range, setRange] = useState<DateRange>({
    start: null,
    end: null,
  });

  return (
    <div className="p-8">
      <h1 className="text-2xl font-bold mb-4">Reports</h1>
      <DateRangePicker
        value={range}
        onChange={setRange}
        maxDate={new Date()}
      />
    </div>
  );
}

Need Polished UI Components?

We build accessible, production-ready interfaces. Contact us to learn more.

date pickeraccessibilityReacta11ycalendartutorial

Ready to Start Your Project?

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

Get in Touch

Related Articles