Date Range Picker

Calendar-based picker for selecting a start and end date. Supports presets, min/max bounds, and single or dual-month display.

Preview

May 2025SMTWTFS12345678910111213141516171819202122232425262728293031DATE RANGE INPUTSFromMay 14ToMay 22PRESETSToday7 days30 daysQuarter8 days selectedApply Range

Anatomy

The picker consists of a trigger (two date input fields), a floating calendar panel, optional preset chips, and an Apply button for confirmed selection.

RegionPurpose
Trigger inputsShow the current start and end date. Clicking either opens the calendar.
Month navigationPrev/next arrows step one month at a time. Clicking the month/year label opens a month/year selector.
Calendar grid7-column grid. Days before the month pad the first row. Selected range is highlighted in a band.
Preset chipsOne-click shortcuts for common ranges (Today, Last 7 days, etc.). Shown in a row above or beside the grid.
Apply buttonCommits the selection. Until clicked, changes are in a pending state visible only in the calendar.

Interaction Model

Selection follows a two-click pattern: the first click sets the start date, the second click on any later date sets the end date. Hovering between clicks previews the range.

StateVisualBehaviour
EmptyNo highlightFirst click → sets start, enters 'awaiting end' state.
Awaiting endStart dot, hover preview bandHover shows tentative range. Click confirms end.
Range setFilled band, start + end circlesClick anywhere restarts selection from scratch.
Same daySingle circle, no bandAllowed. start === end is a valid 1-day range.
Reverse clickSwaps start and end automaticallyIf user clicks a date before the current start, it becomes the new start.

Implementation

DateRangePicker.tsx
tsx
import { DateRangePicker } from "@/components/ui/DateRangePicker";
import { startOfDay, subDays, endOfDay } from "date-fns";

const [range, setRange] = useState<{
  start: Date | null;
  end: Date | null;
}>({ start: null, end: null });

// Basic
<DateRangePicker value={range} onChange={setRange} />

// With presets
const PRESETS = [
  {
    label: "Today",
    range: () => ({ start: startOfDay(new Date()), end: endOfDay(new Date()) }),
  },
  {
    label: "Last 7 days",
    range: () => ({ start: subDays(new Date(), 6), end: new Date() }),
  },
  {
    label: "Last 30 days",
    range: () => ({ start: subDays(new Date(), 29), end: new Date() }),
  },
  {
    label: "This quarter",
    range: () => {
      const now = new Date();
      const q = Math.floor(now.getMonth() / 3);
      return {
        start: new Date(now.getFullYear(), q * 3, 1),
        end:   new Date(now.getFullYear(), q * 3 + 3, 0),
      };
    },
  },
];

<DateRangePicker
  value={range}
  onChange={setRange}
  presets={PRESETS}
  minDate={new Date("2020-01-01")}
  maxDate={new Date()}
/>

// Single-month on mobile
<DateRangePicker
  value={range}
  onChange={setRange}
  numberOfMonths={1}
/>

Props

PropTypeDefaultDescription
value{ start: Date | null; end: Date | null }Controlled date range. Pass null for unset start or end.
onChange(range: { start: Date | null; end: Date | null }) => voidCalled after each selection change — fires after start is picked, then after end is picked.
presetsArray<{ label: string; range: () => { start: Date; end: Date } }>Quick-select preset buttons displayed above or beside the calendar.
minDateDateEarliest selectable date. Dates before this are rendered disabled.
maxDateDateLatest selectable date. Defaults to no upper bound.
numberOfMonths1 | 22How many calendar months to show side-by-side. Use 1 on narrow viewports.
firstDayOfWeek0 | 100 = Sunday, 1 = Monday. Affects the grid column order.
disabledbooleanfalseDisables the trigger and calendar.

Accessibility

  • The calendar grid uses role="grid", each row is role="row", and each day cell is role="gridcell" with aria-selected and aria-disabled as appropriate.
  • Selected range days have aria-pressed="true" and a visually-hidden label: "14 May, selected range start" / "22 May, selected range end" / "15 May, within range".
  • The trigger inputs accept direct text entry in ISO or locale format as a fallback — do not make the calendar the only input mechanism.
  • Month navigation arrows must be keyboard-focusable with clear aria-labels: 'Previous month' and 'Next month'.
  • The floating panel is trapped for keyboard navigation. Tab cycles through: prev-month, grid cells, next-month, presets, Apply, then back.