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
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.
| Region | Purpose |
|---|---|
| Trigger inputs | Show the current start and end date. Clicking either opens the calendar. |
| Month navigation | Prev/next arrows step one month at a time. Clicking the month/year label opens a month/year selector. |
| Calendar grid | 7-column grid. Days before the month pad the first row. Selected range is highlighted in a band. |
| Preset chips | One-click shortcuts for common ranges (Today, Last 7 days, etc.). Shown in a row above or beside the grid. |
| Apply button | Commits 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.
| State | Visual | Behaviour |
|---|---|---|
| Empty | No highlight | First click → sets start, enters 'awaiting end' state. |
| Awaiting end | Start dot, hover preview band | Hover shows tentative range. Click confirms end. |
| Range set | Filled band, start + end circles | Click anywhere restarts selection from scratch. |
| Same day | Single circle, no band | Allowed. start === end is a valid 1-day range. |
| Reverse click | Swaps start and end automatically | If 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
| Prop | Type | Default | Description |
|---|---|---|---|
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 }) => void | — | Called after each selection change — fires after start is picked, then after end is picked. |
presets | Array<{ label: string; range: () => { start: Date; end: Date } }> | — | Quick-select preset buttons displayed above or beside the calendar. |
minDate | Date | — | Earliest selectable date. Dates before this are rendered disabled. |
maxDate | Date | — | Latest selectable date. Defaults to no upper bound. |
numberOfMonths | 1 | 2 | 2 | How many calendar months to show side-by-side. Use 1 on narrow viewports. |
firstDayOfWeek | 0 | 1 | 0 | 0 = Sunday, 1 = Monday. Affects the grid column order. |
disabled | boolean | false | Disables 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.