# Date Range Picker

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

## Installation

```bash
npx shadcn@latest add https://mwui.vercel.app/r/date-range-picker.json
```

[Registry JSON](https://mwui.vercel.app/r/date-range-picker.json)

## Preview

```tsx
import * as React from "react";

import { DateRangePicker, formatRangeLabel, type DateRange } from "@/components/ui/date-range-picker";

export function Preview() {
  const [range, setRange] = React.useState<DateRange | null>(null);

  return (
    <div className="flex w-full flex-col items-center gap-4">
      <DateRangePicker
        value={range}
        onValueChange={setRange}
        className="rounded-xl border p-3"
        numberOfMonths={2}
      />

      <p className="text-center font-mono text-xs text-muted-foreground">
        {formatRangeLabel(range)}
      </p>
    </div>
  );
}
```


## Source

### ui/date-range-picker.tsx

```tsx
"use client";

import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react";
import * as React from "react";

import { Button } from "@/components/ui/button";
import { useControllableState } from "@/hooks/use-controllable-state";
import { formatDate, formatDateRange } from "@/lib/format";
import { cn } from "@/lib/utils";

type DateRange = {
  start: Date;
  end: Date | null;
};

type DateRangePreset = {
  label: string;
  getValue: () => DateRange;
};

const DAYS_PER_WEEK = 7;
const WEEKS_PER_GRID = 6;

function startOfDay(date: Date) {
  const next = new Date(date);
  next.setHours(0, 0, 0, 0);
  return next;
}

function addDays(date: Date, amount: number) {
  const next = new Date(date);
  next.setDate(next.getDate() + amount);
  return startOfDay(next);
}

function addMonths(date: Date, amount: number) {
  const next = new Date(date);
  const day = next.getDate();

  next.setDate(1);
  next.setMonth(next.getMonth() + amount);

  const lastDay = new Date(next.getFullYear(), next.getMonth() + 1, 0).getDate();
  next.setDate(Math.min(day, lastDay));

  return startOfDay(next);
}

function startOfMonth(date: Date) {
  const next = new Date(date);
  next.setDate(1);
  return startOfDay(next);
}

function endOfMonth(date: Date) {
  return startOfDay(new Date(date.getFullYear(), date.getMonth() + 1, 0));
}

function startOfYear(date: Date) {
  return startOfDay(new Date(date.getFullYear(), 0, 1));
}

function compareDays(a: Date, b: Date) {
  return startOfDay(a).getTime() - startOfDay(b).getTime();
}

function isSameDay(a: Date, b: Date) {
  return compareDays(a, b) === 0;
}

function isSameMonth(a: Date, b: Date) {
  return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth();
}

function clampDate(date: Date, min?: Date, max?: Date) {
  if (min && compareDays(date, min) < 0) return startOfDay(min);
  if (max && compareDays(date, max) > 0) return startOfDay(max);
  return startOfDay(date);
}

function isOutOfBounds(date: Date, min?: Date, max?: Date) {
  return Boolean((min && compareDays(date, min) < 0) || (max && compareDays(date, max) > 0));
}

function toDateKey(date: Date) {
  const month = String(date.getMonth() + 1).padStart(2, "0");
  const day = String(date.getDate()).padStart(2, "0");
  return `${date.getFullYear()}-${month}-${day}`;
}

type WeekStart = 0 | 1 | 2 | 3 | 4 | 5 | 6;

function getWeekdayLabels(locale: string | undefined, weekStartsOn: WeekStart) {
  const formatter = new Intl.DateTimeFormat(locale, { weekday: "short" });
  const sunday = new Date(2024, 0, 7);

  return Array.from({ length: DAYS_PER_WEEK }, (_, index) => {
    const day = (weekStartsOn + index) % DAYS_PER_WEEK;
    return { day, label: formatter.format(addDays(sunday, day)) };
  });
}

function getMonthGrid(month: Date, weekStartsOn: WeekStart) {
  const first = startOfMonth(month);
  const offset = (first.getDay() - weekStartsOn + DAYS_PER_WEEK) % DAYS_PER_WEEK;
  const gridStart = addDays(first, -offset);

  const days = Array.from({ length: WEEKS_PER_GRID * DAYS_PER_WEEK }, (_, index) =>
    addDays(gridStart, index),
  );

  return Array.from({ length: WEEKS_PER_GRID }, (_, week) =>
    days.slice(week * DAYS_PER_WEEK, (week + 1) * DAYS_PER_WEEK),
  );
}

function selectDate(current: DateRange | null, date: Date): DateRange {
  if (!current || current.end !== null) return { start: date, end: null };
  if (compareDays(date, current.start) < 0) return { start: date, end: current.start };
  return { start: current.start, end: date };
}

function getPaintedBounds(range: DateRange | null, preview: Date | null) {
  if (!range) return null;

  const end = range.end ?? preview;
  if (!end) return { start: range.start, end: range.start };

  return compareDays(end, range.start) < 0
    ? { start: end, end: range.start }
    : { start: range.start, end };
}

const defaultPresets: DateRangePreset[] = [
  {
    label: "Today",
    getValue: () => {
      const today = startOfDay(new Date());
      return { start: today, end: today };
    },
  },
  {
    label: "Yesterday",
    getValue: () => {
      const yesterday = addDays(new Date(), -1);
      return { start: yesterday, end: yesterday };
    },
  },
  {
    label: "Last 7 days",
    getValue: () => ({ start: addDays(new Date(), -6), end: startOfDay(new Date()) }),
  },
  {
    label: "Last 30 days",
    getValue: () => ({ start: addDays(new Date(), -29), end: startOfDay(new Date()) }),
  },
  {
    label: "This month",
    getValue: () => ({ start: startOfMonth(new Date()), end: endOfMonth(new Date()) }),
  },
  {
    label: "Last month",
    getValue: () => {
      const previous = addMonths(startOfMonth(new Date()), -1);
      return { start: previous, end: endOfMonth(previous) };
    },
  },
  {
    label: "Year to date",
    getValue: () => ({ start: startOfYear(new Date()), end: startOfDay(new Date()) }),
  },
];

function formatRangeLabel(range: DateRange | null, locale?: string) {
  if (!range) return "No dates selected";
  if (!range.end) return `${formatDate(range.start, { locale })} — select end date`;
  return formatDateRange(range.start, range.end, { locale });
}

type DateRangePickerProps = Omit<
  React.ComponentProps<"div">,
  "defaultValue" | "onChange" | "value"
> & {
  value?: DateRange | null;
  defaultValue?: DateRange | null;
  onValueChange?: (value: DateRange | null) => void;
  numberOfMonths?: number;
  locale?: string;
  weekStartsOn?: WeekStart;
  min?: Date;
  max?: Date;
  presets?: DateRangePreset[] | false;
  disabled?: boolean;
};

function DateRangePicker({
  value,
  defaultValue = null,
  onValueChange,
  numberOfMonths = 2,
  locale,
  weekStartsOn = 1,
  min,
  max,
  presets,
  disabled = false,
  className,
  ...props
}: DateRangePickerProps) {
  const [range, setRange] = useControllableState<DateRange | null>({
    value,
    defaultValue,
    onChange: onValueChange,
  });

  const [month, setMonth] = React.useState(() => startOfMonth(range?.start ?? new Date()));
  const [focusedDate, setFocusedDate] = React.useState(() =>
    clampDate(range?.start ?? new Date(), min, max),
  );
  const [previewDate, setPreviewDate] = React.useState<Date | null>(null);

  const gridRef = React.useRef<HTMLDivElement>(null);
  const shouldRestoreFocusRef = React.useRef(false);

  const weekdayLabels = React.useMemo(
    () => getWeekdayLabels(locale, weekStartsOn),
    [locale, weekStartsOn],
  );
  const visibleMonths = React.useMemo(
    () => Array.from({ length: numberOfMonths }, (_, index) => addMonths(month, index)),
    [month, numberOfMonths],
  );

  const resolvedPresets = presets === false ? [] : (presets ?? defaultPresets);
  const painted = getPaintedBounds(range, previewDate);

  React.useEffect(() => {
    if (!shouldRestoreFocusRef.current) return;
    shouldRestoreFocusRef.current = false;

    gridRef.current
      ?.querySelector<HTMLButtonElement>(`[data-date="${toDateKey(focusedDate)}"]`)
      ?.focus();
  }, [focusedDate, month]);

  const goToMonth = (next: Date) => {
    setMonth(startOfMonth(next));
    setPreviewDate(null);
  };

  const moveFocus = (next: Date) => {
    const clamped = clampDate(next, min, max);
    shouldRestoreFocusRef.current = true;
    setFocusedDate(clamped);

    if (compareDays(clamped, month) < 0) {
      setMonth(startOfMonth(clamped));
      return;
    }

    const lastVisibleDay = endOfMonth(visibleMonths[visibleMonths.length - 1]);
    if (compareDays(clamped, lastVisibleDay) > 0) {
      setMonth(startOfMonth(addMonths(clamped, -(numberOfMonths - 1))));
    }
  };

  const commitDate = (date: Date) => {
    if (disabled || isOutOfBounds(date, min, max)) return;
    setRange((previous) => selectDate(previous, date));
    setPreviewDate(null);
  };

  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
    if (disabled) return;

    const shiftDays = (amount: number) => {
      event.preventDefault();
      moveFocus(addDays(focusedDate, amount));
    };

    const shiftMonths = (amount: number) => {
      event.preventDefault();
      moveFocus(addMonths(focusedDate, amount));
    };

    const weekOffset = (focusedDate.getDay() - weekStartsOn + DAYS_PER_WEEK) % DAYS_PER_WEEK;

    switch (event.key) {
      case "ArrowLeft":
        return shiftDays(-1);
      case "ArrowRight":
        return shiftDays(1);
      case "ArrowUp":
        return shiftDays(-DAYS_PER_WEEK);
      case "ArrowDown":
        return shiftDays(DAYS_PER_WEEK);
      case "Home":
        return shiftDays(-weekOffset);
      case "End":
        return shiftDays(DAYS_PER_WEEK - 1 - weekOffset);
      case "PageUp":
        return shiftMonths(event.shiftKey ? -12 : -1);
      case "PageDown":
        return shiftMonths(event.shiftKey ? 12 : 1);
      default:
    }
  };

  const isPresetSelected = (preset: DateRangePreset) => {
    if (!range?.end) return false;
    const candidate = preset.getValue();
    return (
      candidate.end !== null &&
      isSameDay(candidate.start, range.start) &&
      isSameDay(candidate.end, range.end)
    );
  };

  return (
    <div
      data-slot="date-range-picker"
      data-disabled={disabled || undefined}
      className={cn(
        "flex w-fit flex-col gap-3 sm:flex-row sm:items-start",
        "data-disabled:pointer-events-none data-disabled:opacity-50",
        className,
      )}
      {...props}
    >
      {resolvedPresets.length > 0 ? (
        <div
          data-slot="date-range-picker-presets"
          role="group"
          aria-label="Date range presets"
          className="flex shrink-0 flex-wrap gap-1 sm:w-32 sm:flex-col"
        >
          {resolvedPresets.map((preset) => (
            <Button
              key={preset.label}
              type="button"
              size="xs"
              variant="ghost"
              disabled={disabled}
              aria-pressed={isPresetSelected(preset)}
              className="justify-start aria-pressed:bg-muted aria-pressed:text-foreground"
              onClick={() => {
                const next = preset.getValue();
                setRange(next);
                goToMonth(next.start);
                setFocusedDate(clampDate(next.start, min, max));
              }}
            >
              {preset.label}
            </Button>
          ))}
        </div>
      ) : null}

      <div className="flex flex-col gap-2">
        <div className="flex items-center justify-between gap-2">
          <Button
            type="button"
            size="icon-xs"
            variant="outline"
            aria-label="Previous month"
            disabled={disabled || Boolean(min && compareDays(startOfMonth(month), min) <= 0)}
            onClick={() => goToMonth(addMonths(month, -1))}
          >
            <IconChevronLeft />
          </Button>

          <div aria-live="polite" className="flex flex-1 justify-around gap-4">
            {visibleMonths.map((visibleMonth) => (
              <div key={toDateKey(visibleMonth)} className="text-sm font-medium">
                {formatDate(visibleMonth, { locale, month: "long", year: "numeric" })}
              </div>
            ))}
          </div>

          <Button
            type="button"
            size="icon-xs"
            variant="outline"
            aria-label="Next month"
            disabled={
              disabled ||
              Boolean(max && compareDays(endOfMonth(visibleMonths.at(-1) ?? month), max) >= 0)
            }
            onClick={() => goToMonth(addMonths(month, 1))}
          >
            <IconChevronRight />
          </Button>
        </div>

        <div ref={gridRef} className="flex gap-4" onPointerLeave={() => setPreviewDate(null)}>
          {visibleMonths.map((visibleMonth) => (
            <div
              key={toDateKey(visibleMonth)}
              role="grid"
              aria-label={formatDate(visibleMonth, { locale, month: "long", year: "numeric" })}
              className="flex flex-col gap-1"
              onKeyDown={handleKeyDown}
            >
              <div role="row" className="flex">
                {weekdayLabels.map((weekday) => (
                  <div
                    key={weekday.day}
                    role="columnheader"
                    aria-label={weekday.label}
                    className="flex size-8 items-center justify-center text-xs text-muted-foreground"
                  >
                    {weekday.label.slice(0, 2)}
                  </div>
                ))}
              </div>

              {getMonthGrid(visibleMonth, weekStartsOn).map((week) => (
                <div key={toDateKey(week[0])} role="row" className="flex">
                  {week.map((day) => {
                    const outsideMonth = !isSameMonth(day, visibleMonth);
                    const unavailable = isOutOfBounds(day, min, max);
                    const isStart = Boolean(painted && isSameDay(day, painted.start));
                    const isEnd = Boolean(painted && isSameDay(day, painted.end));
                    const inRange = Boolean(
                      painted &&
                      compareDays(day, painted.start) >= 0 &&
                      compareDays(day, painted.end) <= 0,
                    );

                    return (
                      <div
                        key={toDateKey(day)}
                        role="gridcell"
                        aria-selected={inRange}
                        className={cn(
                          "p-0",
                          inRange && !outsideMonth && "bg-muted",
                          inRange && isStart && "rounded-l-md",
                          inRange && isEnd && "rounded-r-md",
                        )}
                      >
                        <button
                          type="button"
                          data-date={toDateKey(day)}
                          data-outside-month={outsideMonth || undefined}
                          data-selected={isStart || isEnd || undefined}
                          data-in-range={inRange || undefined}
                          disabled={disabled || unavailable}
                          tabIndex={isSameDay(day, focusedDate) ? 0 : -1}
                          aria-label={formatDate(day, { locale, dateStyle: "full" })}
                          className={cn(
                            "flex size-8 items-center justify-center rounded-md text-sm tabular-nums outline-none",
                            "not-data-selected:hover:bg-foreground/10 not-data-selected:hover:text-foreground",
                            "focus-visible:ring-3 focus-visible:ring-ring/50",
                            "disabled:pointer-events-none disabled:opacity-40",
                            outsideMonth && "text-muted-foreground/50",
                            "data-selected:bg-primary data-selected:text-primary-foreground",
                          )}
                          onClick={() => commitDate(day)}
                          onFocus={() => setFocusedDate(day)}
                          onPointerEnter={() => {
                            if (range && !range.end) setPreviewDate(day);
                          }}
                        >
                          {day.getDate()}
                        </button>
                      </div>
                    );
                  })}
                </div>
              ))}
            </div>
          ))}
        </div>

        <div className="flex items-center justify-between gap-2 border-t pt-2">
          <p aria-live="polite" className="text-xs text-muted-foreground">
            {formatRangeLabel(range, locale)}
          </p>
          <Button
            type="button"
            size="xs"
            variant="ghost"
            disabled={disabled || range === null}
            onClick={() => {
              setRange(null);
              setPreviewDate(null);
            }}
          >
            Clear
          </Button>
        </div>
      </div>
    </div>
  );
}

export {
  DateRangePicker,
  defaultPresets,
  formatRangeLabel,
  type DateRange,
  type DateRangePickerProps,
  type DateRangePreset,
  type WeekStart,
};
```



## 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.

```tsx
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:

```tsx
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:

```tsx
<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:

```tsx
<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:

```tsx
<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:

```tsx
<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.

