mwui
GitHub

Date Range Picker

A date range picker with a self-contained calendar, quick presets, and keyboard-first navigation.

August 2026
September 2026
Mo
Tu
We
Th
Fr
Sa
Su
Mo
Tu
We
Th
Fr
Sa
Su

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

PropDefaultDescription
valueControlled range.
defaultValuenullUncontrolled initial range.
onValueChangeCalled with the next range, or null when cleared.
numberOfMonths2How many months to show side by side.
weekStartsOn1First day of the week, 0 Sunday through 6 Saturday.
localeBCP 47 tag for month, weekday, and label formatting.
min / maxSelectable bounds. Days outside them are disabled.
presetsPreset list, or false to hide the column.
disabledfalseDisables 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.

KeyAction
Previous / next day
Previous / next week
Home EndFirst / last day of the week
Page Up Page DownPrevious / next month
Shift + Page Up/DownPrevious / next year
Enter SpaceSelect 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.