# Date Range Picker

> A React date range picker for reports and bookings: two months with presets on desktop, a one-month bottom sheet on phones.

- Type: React component (Inputs)
- Access: Pro (the source needs a Wingo UI Pro plan, see https://wingo-ui.com/pricing)
- Version: 1.0.0
- Install: `npx wingo-ui@latest add date-range-picker`
- Docs: https://wingo-ui.com/components/date-range-picker

## Interaction

Click the field to open a panel under it: presets on the left (Last 7 days, This month, Last quarter...), two months on the right and a summary with Cancel and Apply at the bottom. The first click on a day sets the start, the band follows the pointer as a preview, and the second click sets the end (a day before the start swaps them); days that would break minDays, maxDays or cross a disabled day are unavailable while you pick the end. A preset fills both ends and moves the calendar to it. The summary under the calendar updates as you go (the dates, then "· 7 days"), and Apply commits; Cancel, Escape or a click outside throw the draft away, so a half-picked range never reaches the value. With confirm={false} the range commits as soon as both ends are picked, or on a preset. The clear button in the field empties it. Keyboard: Enter, Space or ArrowDown on the field open the panel and move focus into the calendar (the start of the range, else today); arrows move a day or a week, Home / End jump to the week's ends, PageUp / PageDown move a month (Shift: a year), Enter picks the start and then the end; Tab reaches Cancel and Apply, Shift+Tab the presets (arrow keys move between presets); Ctrl+Enter (Cmd+Enter on a Mac) applies from anywhere in the panel; Escape cancels and returns focus to the field. On a phone the field opens a bottom sheet with the presets as a row of chips, two Start / End cells (the end being picked is outlined), one month you can swipe, and Apply / Cancel pinned above the home indicator; dragging the sheet down cancels.

## Installation

```bash
npx wingo-ui@latest add date-range-picker
```

Pro item: run `npx wingo-ui@latest login` once with a Pro account. The CLI adds the files, their dependencies and a version record for updates.

With shadcn: `npx shadcn@latest add @wingo-ui/date-range-picker` (needs the @wingo-ui registry with your API key in components.json)

Files:

- `components/ui/date-range-picker.tsx`

npm dependencies: `npm i @radix-ui/react-popover date-fns motion lucide-react`

Wingo UI dependencies (installed automatically):

- [Button](https://wingo-ui.com/components/button) ([Markdown](https://wingo-ui.com/components/button.md))
- [Calendar](https://wingo-ui.com/components/calendar) ([Markdown](https://wingo-ui.com/components/calendar.md))
- [Drawer](https://wingo-ui.com/components/drawer) ([Markdown](https://wingo-ui.com/components/drawer.md))
- [Field](https://wingo-ui.com/components/field) ([Markdown](https://wingo-ui.com/components/field.md))
- [Date utils](https://wingo-ui.com/components/date-utils) ([Markdown](https://wingo-ui.com/components/date-utils.md))
- [useControllableState](https://wingo-ui.com/components/use-controllable-state) ([Markdown](https://wingo-ui.com/components/use-controllable-state.md))
- [useLocale](https://wingo-ui.com/components/use-locale) ([Markdown](https://wingo-ui.com/components/use-locale.md))
- [useMediaQuery](https://wingo-ui.com/components/use-media-query) ([Markdown](https://wingo-ui.com/components/use-media-query.md))
- [Motion presets](https://wingo-ui.com/components/motion) ([Markdown](https://wingo-ui.com/components/motion.md))
- [Tones](https://wingo-ui.com/components/tones) ([Markdown](https://wingo-ui.com/components/tones.md))
- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))

## Usage

```tsx
import { useState } from "react"
import { DateRangePicker, type DateRange } from "@/components/ui/date-range-picker"

export function SalesReport() {
  const [range, setRange] = useState<DateRange>({ from: null, to: null })

  return (
    <DateRangePicker
      label="Period"
      name="period"
      locale="en-US"
      placeholder="Pick a period"
      max={new Date()}
      value={range}
      onValueChange={setRange}
      presetLabels={{ lastQuarter: "Previous quarter" }}
      labels={{ duration: (days) => (days === 1 ? "1 day" : `${days} days`) }}
    />
  )
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `label?` | `ReactNode` | `""` | Renders the field anatomy around the trigger. A string label also titles the phone sheet and names the panel. A click on the label focuses the trigger without opening it. |
| `description?` | `ReactNode` | `""` | Helper text under the field, linked with aria-describedby. The error takes its place while it shows. |
| `error?` | `ReactNode` | `""` | Replaces the description with the animated error and marks the field invalid (red ring, aria-invalid on the trigger). |
| `required?` | `boolean` | `false` | The mark after the label and aria-required on the trigger. |
| `invalid?` | `boolean` | `false` | The invalid look and aria-invalid without a message. |
| `disabled?` | `boolean or DateMatcher or DateMatcher[] (DateMatcher: Date, Date[], { from, to }, { before }, { after }, { dayOfWeek } or (date) => boolean)` | `false` | true dims the field, blocks it and leaves the hidden inputs out of the submit. A DateMatcher instead disables those days in the calendar: a Date, a list, { from, to }, { before }, { after }, { dayOfWeek: [0, 6] } or a (date) => boolean test (booked nights, holidays). |
| `readOnly?` | `boolean` | `false` | The value stays visible and the trigger focusable, but it never opens and the clear button hides. |
| `size?` | `"sm" \| "md" \| "lg"` | `"md"` | The field box: 32 / 40 / 48px (md is 44px on touch screens), like every other field. |
| `variant?` | `"soft" \| "outline" \| "underline" \| "ghost"` | `"soft"` | The box look, shared with every field. |
| `radius?` | `"none" \| "sm" \| "md" \| "lg" \| "full"` | `"md"` | The box corners, per size like the button (8 / 10 / 12px at md). Ignored by underline. |
| `placeholder?` | `string` | `"Select dates"` | The trigger text while no range is set. |
| `locale?` | `string` | `""` | Month and weekday names, digits, the first day of the week and the trigger text. Empty: the nearest LocaleProvider, else the browser. |
| `displayFormat?` | `Intl.DateTimeFormatOptions` |  | How the trigger and the summary write the range, through Intl formatRange: { day: "numeric", month: "short", year: "numeric" } gives "1 to 30 sept. 2026" joined by an en dash; only a start gives the start followed by a dash. |
| `numberOfMonths?` | `1 \| 2 \| 3` | `2` | Months side by side on desktop. The panel drops months that do not fit the screen; phones always show one. |
| `presets?` | `boolean \| (RangePresetKey \| DateRangePreset)[]` | `true` | true: Last 7 days, Last 30 days, This month, Last month, This quarter, Last quarter, This year. Or a list mixing built-in keys ("today", "yesterday", "last90Days", "thisWeek", "lastWeek", "lastYear", "yearToDate"...) and your own { label, value: DateRange \| () => DateRange }. Presets that run past min / max are cut to them; fully outside, or longer than maxDays, they are disabled. |
| `presetLabels?` | `Partial<Record<RangePresetKey, string>>` |  | Labels for the built-in presets (English by default). |
| `min?` | `Date` |  | Earlier days are disabled and the calendar stops at this month. |
| `max?` | `Date` |  | Later days are disabled (reports pass today) and the calendar stops at this month. |
| `minDays?` | `number` | `0` | The shortest range in days, both ends counted (a 2 night stay is 3 days). Closer ends are unavailable while picking. |
| `maxDays?` | `number` | `0` | The longest range in days, both ends counted. Further ends are unavailable while picking, longer presets are disabled. |
| `excludeDisabled?` | `boolean` | `true` | A range can not span a disabled day: past the first one, the days are unavailable while picking the end (bookings). |
| `confirm?` | `boolean` | `true` | Apply / Cancel buttons; the value only changes on Apply. false commits and closes as soon as both ends are picked, or on a preset. |
| `clearable?` | `boolean` | `true` | A clear button in the field while a range is set. |
| `showDuration?` | `boolean` | `true` | The summary shows the length ("7 days", or nights with labels.nights). |
| `labels?` | `{ apply?; cancel?; clear?; start?; end?; pickStart?; pickEnd?; presets?; duration?: (days) => string; nights?: (nights) => string }` |  | Every string of the panel: { apply: "Apply", cancel: "Cancel", clear: "Clear", start: "Start", end: "End", pickStart: "Pick a start date", pickEnd: "Pick an end date", presets: "Presets", duration: (d) => d + " days" }. nights switches the summary to nights (a 4 day stay is 3 nights). |
| `weekStartsOn?` | `0 \| 1 \| 2 \| 3 \| 4 \| 5 \| 6 \| -1` | `-1` | 0 Sunday ... 6 Saturday; -1 follows the locale (Monday in ro-RO, Sunday in en-US). |
| `events?` | `CalendarEvent[]` |  | Dots under days in the calendar: { date, tone?, color?, label? }. |
| `calendarProps?` | `Partial<CalendarProps<"range">>` |  | Anything else for the calendar: size, radius, strikeDisabled, showWeekNumbers, renderDay, labels (Romanian month navigation), fixedWeeks (on by default so the panel never changes height). |
| `tone?` | `"neutral" \| "brand" \| "success" \| "warning" \| "danger" \| "info"` | `"neutral"` | The color of the picked ends, the band, the active preset check, the outline of the cell being picked and the Apply button. |
| `color?` | `string` | `""` | Any CSS color; replaces the tone. Text on the fill turns black or white by lightness. |
| `icon?` | `ReactNode` |  | The leading icon of the field (CalendarRange by default). |
| `defaultOpen?` | `boolean` | `false` | Starts open while uncontrolled. |
| `value?` | `DateRange` |  | The committed range, controlled: { from: Date \| null; to: Date \| null }. A value with only from shows the start followed by a dash; a backwards one is shown in order. |
| `defaultValue?` | `DateRange` |  | The starting range while uncontrolled; { from: null, to: null } by default. A form reset returns to it. |
| `onValueChange?` | `(range: DateRange) => void` |  | Called on Apply, on a clear, or as soon as both ends are picked with confirm={false}. Never with a half-picked draft. |
| `open?` | `boolean` |  | The panel or sheet, controlled. Pair it with onOpenChange. Every opening starts from the committed value. |
| `onOpenChange?` | `(open: boolean) => void` |  | Called when the panel opens or closes. |
| `responsive?` | `boolean` | `true` | A bottom sheet below 768px instead of the popover. |
| `sheetTitle?` | `string` | `"Select dates"` | The sheet title and the panel name when the label is not plain text. |
| `side?` | `"top" \| "right" \| "bottom" \| "left"` | `"bottom"` | Where the panel opens; it flips when there is no room. |
| `align?` | `"start" \| "center" \| "end"` | `"start"` | The panel's alignment against the field. |
| `container?` | `HTMLElement \| null` |  | Portal target for the panel and the sheet (document.body by default). |
| `name?` | `string` | `""` | Renders hidden inputs name.from and name.to with ISO dates ("2026-09-23", local, never UTC). |
| `id?` | `string` |  | The trigger's id (generated by default); the label points at it. |
| `fieldProps?` | `Partial<FieldProps>` |  | Anything else for the field wrapper: optional, labelAction, orientation, labelWidth, hideLabel, className. |
| `classNames?` | `Partial<Record<DateRangePickerSlot, string>>` |  | Classes for the parts: field, label, description, error, counter, box, trigger, icon, value, clear, chevron, content, presets, preset, calendar, footer, summary, cell (the phone Start / End cells), actions. className goes on the field root (or the box without a label). |
| `ref?` | `RefObject<HTMLButtonElement \| null> or (node) => void` |  | The trigger button; the rest of the props (onBlur, aria-*, data-*) land on it too, so react-hook-form can focus and touch it. |

## Parts

### DateRangePreset (type)

One preset button: { label, value: DateRange | (() => DateRange), key? }.

## Examples

- **Booking a stay**: Booked nights are disabled and struck through, minDays 3 (2 nights), labels.nights counts nights and the price follows.
- **Instant filter**: confirm={false}: the list filters as soon as both ends are picked, or on a preset tap. Mixed built-in preset keys.
- **Compact**: numberOfMonths 1 with a small outline field and small calendar cells, for a sidebar or a toolbar.
- **Custom presets**: Your own ranges, computed when the panel opens: this weekend, next week, the next 30 days. Brand tone for the selection.
- **Maximum length**: maxDays 31: ends further out are unavailable while picking, and presets longer than that are disabled.
- **Locales**: The same labels in two locales: ro-RO starts weeks on Monday and writes the day before the month; en-US starts on Sunday and writes the month first.
- **Sizes and variants**: sm soft, md outline, lg underline, a ghost field and a disabled one: the same box as every other field.

## See also

- [Date Picker](https://wingo-ui.com/components/date-picker) ([Markdown](https://wingo-ui.com/components/date-picker.md))
- [Calendar](https://wingo-ui.com/components/calendar) ([Markdown](https://wingo-ui.com/components/calendar.md))
- [Field](https://wingo-ui.com/components/field) ([Markdown](https://wingo-ui.com/components/field.md))
- [Drawer](https://wingo-ui.com/components/drawer) ([Markdown](https://wingo-ui.com/components/drawer.md))
- [Date utils](https://wingo-ui.com/components/date-utils) ([Markdown](https://wingo-ui.com/components/date-utils.md))
- [useLocale](https://wingo-ui.com/components/use-locale) ([Markdown](https://wingo-ui.com/components/use-locale.md))

---

Canonical: https://wingo-ui.com/components/date-range-picker
More components: https://wingo-ui.com/components · Index for agents: https://wingo-ui.com/llms.txt
