Date Range Picker
A date range picker with a self-contained calendar, quick presets, and keyboard-first navigation.
No dates selected
No dates selected
Installation
Usage
A range calendar with the presets people actually reach for. The calendar grid is built on native
Date and Intl — there is no date library underneath, so installing this does not add a runtime
dependency or a second date API to your project.
import { DateRangePicker, type DateRange } from "@/components/ui/date-range-picker";
export function ReportFilters() {
const [range, setRange] = React.useState<DateRange | null>(null);
return <DateRangePicker value={range} onValueChange={setRange} />;
}Value
A range is { start: Date; end: Date | null }, or null when nothing is selected. end is null
while a range is half-selected — the user has clicked a start date and has not yet picked an end.
Treat that as "still choosing" rather than a complete value:
const isComplete = range?.end != null;Clicking always moves forward: the first click starts a range, the second completes it, and a third starts over. Clicking before the current start extends backwards instead of restarting, which is what people expect when they overshoot.
DateRangePicker is controlled with value / onValueChange and uncontrolled with defaultValue.
Presets
Presets render beside the calendar and are shown by default. Pass your own, or presets={false} to
hide them:
<DateRangePicker
presets={[
{ label: "This sprint", getValue: () => ({ start: sprintStart, end: sprintEnd }) },
{ label: "All time", getValue: () => ({ start: projectStart, end: new Date() }) },
]}
/>getValue runs when the preset is clicked, not when it is defined, so relative presets such as
"Last 7 days" stay correct in a long-lived tab. The built-in list is exported as defaultPresets if
you want to extend rather than replace it:
<DateRangePicker presets={[...defaultPresets, myPreset]} />A preset renders as pressed when the current range matches it exactly.
Props
| Prop | Default | Description |
|---|---|---|
value | — | Controlled range. |
defaultValue | null | Uncontrolled initial range. |
onValueChange | — | Called with the next range, or null when cleared. |
numberOfMonths | 2 | How many months to show side by side. |
weekStartsOn | 1 | First day of the week, 0 Sunday through 6 Saturday. |
locale | — | BCP 47 tag for month, weekday, and label formatting. |
min / max | — | Selectable bounds. Days outside them are disabled. |
presets | — | Preset list, or false to hide the column. |
disabled | false | Disables the whole picker. |
weekStartsOn is explicit rather than derived from locale, because the browser API that reports a
locale's first weekday is still not available everywhere. Set it once alongside your locale:
<DateRangePicker locale="en-US" weekStartsOn={0} />Popover trigger
The item ships the panel, not the trigger, so it composes with whatever overlay you already use:
<Popover>
<PopoverTrigger render={<Button variant="outline" />}>
{formatRangeLabel(range)}
</PopoverTrigger>
<PopoverContent className="w-auto p-2">
<DateRangePicker value={range} onValueChange={setRange} />
</PopoverContent>
</Popover>formatRangeLabel is exported for exactly this: it renders a complete range through
formatDateRange, a half-selected range as a prompt for the end date, and an empty range as
placeholder text.
Accessibility
Each month is a role="grid" with columnheader weekday labels and gridcell days. A roving
tabindex keeps one day in the tab order, so the calendar is a single tab stop and arrow keys move
within it. Every day button is labelled with its full date, and focus follows keyboard navigation
across month boundaries — paging to a month that is not visible scrolls it into view and moves focus
with it.
| Key | Action |
|---|---|
← → | Previous / next day |
↑ ↓ | Previous / next week |
Home End | First / last day of the week |
Page Up Page Down | Previous / next month |
Shift + Page Up/Down | Previous / next year |
Enter Space | Select the focused day |
Day arithmetic normalizes to midnight on every step, so ranges stay correct across daylight saving boundaries — a week that contains a clock change is still seven days.