For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to any URL or sending an Accept: text/markdown header. An agent skill is available at /.well-known/agent-skills/site-skill.md.
0

Date Range Picker

A date range made by name, drawn on two months, or typed: one draft behind all three, a shortcut commits in one press, and OK commits the rest.

"use client";

import { startOfToday } from "date-fns";

Installation

$ pnpm dlx shadcn@latest add https://motif.sweetretry.com/r/date-range-picker.json

It brings the shadcn Calendar with it, and with that react-day-picker. The picker does not edit the calendar: everything that makes it a range calendar is passed in from outside, so the file you get at components/ui/calendar.tsx is the one shadcn publishes. That includes the calendar's root: the shadcn file writes it inline, which remounts the whole grid on every render, and the picker re-renders on every day the pointer crosses, so it passes a root of its own.

Usage

import {
  DateRangePicker,
  DateRangePickerCalendar,
  DateRangePickerConfirm,
  DateRangePickerContent,
  DateRangePickerFooter,
  DateRangePickerShortcuts,
  DateRangePickerTrigger,
} from "@/components/ui/date-range-picker";
 
export function ReportRange() {
  const [range, setRange] = useState<DateRangeValue>();
 
  return (
    <DateRangePicker max={new Date()} onValueChange={setRange} value={range}>
      <DateRangePickerTrigger />
      <DateRangePickerContent>
        <DateRangePickerCalendar />
        <DateRangePickerFooter>
          <DateRangePickerShortcuts />
          <DateRangePickerConfirm />
        </DateRangePickerFooter>
      </DateRangePickerContent>
    </DateRangePicker>
  );
}

value and defaultValue are a finished range, { from, to }, and onValueChange is called with one. A range with only a start is the picker's own business and never leaves it.

Three ways in, one draft

A range is made by name, drawn on the calendar, or typed into the field. They do not compete: it is one range, edited from three places, so there is one draft behind them. A shortcut moves the highlight, a day moves the field's text, a typed range moves the calendar.

Two of them are done the moment they are pressed, and one is not:

  • A shortcut commits and closes in one press. A name is already a whole range.
  • Drawing is complete on the second day, but not done: the draft is a range and OK lights up. A person who clicked the wrong day looks at it before it becomes the value.
  • Typing is the same. Enter in the field commits, and so does OK.

Pressing Escape, or clicking away, throws the draft away.

Between the two presses

After the first day, the calendar previews the range to wherever the pointer or the arrow keys are. The end that is only a question is drawn as an outline, and the first day stays solid: one more press turns the outline into an answer, and nothing in the picture says the range is decided before it is. While it waits, the field reads 2026-10-05 ~ and OK is disabled.

The band

A range is a filled square at each end and a band between them. The band has no corners of its own: where a row wraps, or one month ends and the range carries on in the other, it is cut flat, because a rounded edge there would say the range stops. Only the two ends are round, and they are the squares.

The field

The trigger is a text field: 2026-10-05 ~ 2026-10-11. It is the one place that always says what is chosen, so nothing else repeats it. The shortcut that equals the draft is highlighted in the panel, but the field never names it — "Last 7 days" and the dates it stands for are the same fact, and a label that changes length with every choice moves the whole trigger.

Focusing the field opens the panel, and the icon opens it for a person who is not going to type. className on DateRangePickerTrigger sizes the field — it is w-64 until you say otherwise — and every other prop goes to the input. A handler you pass, like onBlur, runs before the picker's own; call preventDefault in it to skip the picker's. Text is accepted in the shape it is shown in, and the panel follows it as soon as it is a whole range. A text that is not one — the wrong shape, a day that is not real like 2026-02-31, a range backwards, days outside min or max — is left on screen and marked aria-invalid when the person leaves the field or presses Enter. Fixing it silently would be a guess about what they meant.

The format is ISO order and not the locale's. It means the same thing in every language, and the server and the client write the same string.

Shortcuts

import { DateRangePicker, lastDays } from "@/components/ui/date-range-picker";
 
const PRESETS = [
  lastDays("today", "Today", 1),
  lastDays("7d", "Last 7 days", 7),
  lastDays("30d", "Last 30 days", 30),
  {
    id: "this-month",
    label: "This month",
    range: (today) => ({
      from: new Date(today.getFullYear(), today.getMonth(), 1),
      to: today,
    }),
  },
];
 
<DateRangePicker presets={PRESETS} />;

range is handed today's date each time, so a shortcut written as "the last 7 days" is still right the next morning, in a tab that has been open overnight. lastDays(id, label, days) counts today as one of the days.

A shortcut whose range falls outside min or max is disabled, because pressing it would choose days the calendar refuses.

presets={[]}, or leaving DateRangePickerShortcuts out, gives a bare range calendar:

"use client";

import { startOfToday } from "date-fns";

The footer, the shortcuts and OK are three parts so that any of them can go. The footer is the row and its rule; it is the caller's to leave out when there is nothing to put in it.

Bounds

min and max are the first and last day that can be picked. Days past them are drawn, faint, rather than left out, so a month is always a month; the arrows stop at the last month that holds a pickable day.

<DateRangePicker max={new Date()} min={new Date("2025-01-01")} />

The calendar draws six weeks for each month, so the panel keeps one height from month to month instead of moving the footer under the pointer.

Language

locale is a react-day-picker locale. It sets the month and weekday names and the first day of the week. The labels of the shortcuts, OK and the icon button are words the component does not own, so they are props or children:

"use client";

import { startOfToday } from "date-fns";
import { zhCN } from "react-day-picker/locale";
 
<DateRangePicker locale={zhCN} presets={PRESETS}>
  <DateRangePickerTrigger calendarLabel="打开日历" />
  <DateRangePickerContent>
    <DateRangePickerCalendar />
    <DateRangePickerFooter>
      <DateRangePickerShortcuts aria-label="快捷选择" />
      <DateRangePickerConfirm>确定</DateRangePickerConfirm>
    </DateRangePickerFooter>
  </DateRangePickerContent>
</DateRangePicker>;

Controlled and not

value, open, and their default and on…Change partners work as they do everywhere else here: pass one and the picker is yours, leave it out and the picker keeps its own.

The initial range is the caller's to pick. A range written as "the last 7 days" depends on the reader's day, so computing it while rendering makes the server and the client disagree near midnight; set it after mount, as the demo does.

The parts

PartDraws
DateRangePickerNothing but the popover; owns the draft and the pending first day
DateRangePickerTriggerThe field and the icon button
DateRangePickerContentThe panel, as wide as its calendar
DateRangePickerCalendarThe shadcn calendar as a range, two months
DateRangePickerFooterThe row under the calendar, and the rule above it
DateRangePickerShortcutsThe presets; nothing when there are none
DateRangePickerConfirmOK; disabled until the draft is a whole range

useDateRangePicker hands over the same state the parts read, for a part of your own.

Small screens

Below sm the calendar is rendered as one month, the later of the two, and the arrows walk back from it. Two 16rem months do not fit a phone, and stacking them would put the footer a scroll away. It is one month rendered, not two with one hidden: the grid decides which day the Tab key lands on from the days it holds, and a day in a month nobody can see is a place focus can never arrive.

The days of the neighbouring month

Each month's grid is padded with the days of the months beside it, so with two months on screen those days are on screen twice. They are drawn as a faint number and nothing else — no band, no filled end, no hover, and a press does nothing — so a range is never drawn twice and the start is never in two places.

Accessibility

react-day-picker supplies the grid: one tab stop, the arrow keys, Home and End for the week, Page Up and Page Down for the month. Opened from the icon, focus lands on the day those keys will move from; opened by focusing the field, it stays in the field so typing is not interrupted. Moving with the keyboard previews the range just as the pointer does.

Each day is a button named by its date, and the days in the range are aria-pressed. The shortcuts are aria-pressed too, in a labelled group.

Component source

index.tsx
"use client";

import { isSameDay, startOfDay, startOfMonth, subMonths } from "date-fns";
import { useRef, useState } from "react";

import { Popover } from "@/components/ui/popover";

import { DateRangePickerContext } from "./context";
import type { DateRangePickerState } from "./context";
import { formatDay, formatRange, parseRange } from "./format";
import { defaultDateRangePresets } from "./presets";
import type {
  DateRangePickerProps,
  DateRangePreset,
  DateRangeValue,
} from "./types";

export {
  DateRangePickerConfirm,
  DateRangePickerContent,
  DateRangePickerFooter,
  DateRangePickerShortcuts,
  DateRangePickerTrigger,
} from "./parts";
export type {
  DateRangePickerConfirmProps,
  DateRangePickerContentProps,
  DateRangePickerFooterProps,
  DateRangePickerShortcutsProps,
  DateRangePickerTriggerProps,
} from "./parts";
export { DateRangePickerCalendar } from "./range-calendar";
export type { DateRangePickerCalendarProps } from "./range-calendar";
export { useDateRangePicker } from "./context";
export type { DateRangePickerState } from "./context";
export { defaultDateRangePresets, lastDays } from "./presets";
export type {
  DateRangePickerProps,
  DateRangePreset,
  DateRangeValue,
} from "./types";

const orderRange = (a: Date, b: Date): DateRangeValue =>
  a <= b ? { from: a, to: b } : { from: b, to: a };

const sameRange = (a: DateRangeValue, b: DateRangeValue) =>
  isSameDay(a.from, b.from) && isSameDay(a.to, b.to);

/** The shortcut the draft equals — and none while a first day is waiting, because then
 *  there is no draft yet, only the start of one. */
const activePreset = (
  presets: DateRangePreset[],
  draft: DateRangeValue | null,
  anchor: Date | null
) => {
  if (!draft || anchor) {
    return;
  }

  const today = new Date();
  return presets.find((preset) => sameRange(preset.range(today), draft))?.id;
};

/** Closed, the field says the value. Open, it says the draft, and a first day waits for
 *  its partner with a dangling separator. */
const fieldText = ({
  anchor,
  current,
  draft,
  isOpen,
}: {
  anchor: Date | null;
  current: DateRangeValue | undefined;
  draft: DateRangeValue | null;
  isOpen: boolean;
}) => {
  if (!isOpen) {
    return formatRange(current);
  }

  return anchor ? `${formatDay(anchor)} ~ ` : formatRange(draft);
};

/** The calendar draws two months, and the range ends in the second: the end is the part
 *  a person is changing most, and the month it sits in is the one they need to see. */
const monthFor = (end?: Date) => startOfMonth(subMonths(end ?? new Date(), 1));

/* -- The picker ----------------------------------------------------------------
 * A date range is made one of three ways: by name ("the last 7 days"), by drawing it on
 * a calendar, or by typing it. They do not compete — they are one range, edited from
 * three places — so they share one draft, and the field, the calendar and the shortcuts
 * all read it.
 *
 * Two of them end the moment they are done. A shortcut commits in one press, because a
 * name is already a whole range. Drawing and typing are different: the second day and
 * the last typed digit are not "done", only complete, so those two end with OK.
 *
 * The root owns the state and draws nothing but the popover. The field, the panel, the
 * calendar, the shortcuts and OK are parts, and a caller assembles them. Leaving out the
 * shortcuts is a bare range calendar; leaving out OK is a picker that commits only
 * through a shortcut or Enter.
 *
 * `react-day-picker` owns the grid, the arrow keys and the labels, because a calendar is
 * a solved problem. What is this component's idea is the shared draft, and the order of
 * the two presses with a preview between them.
 * --------------------------------------------------------------------------- */

export const DateRangePicker = ({
  children,
  defaultOpen = false,
  defaultValue,
  locale,
  max,
  min,
  onOpenChange,
  onValueChange,
  open,
  presets = defaultDateRangePresets,
  value,
}: DateRangePickerProps) => {
  const [ownValue, setOwnValue] = useState(defaultValue);
  const [ownOpen, setOwnOpen] = useState(defaultOpen);
  /** The range being made. It starts as the value, and only OK or a shortcut turns it
   *  back into one. */
  const [draft, setDraft] = useState<DateRangeValue | null>(null);
  const [anchor, setAnchor] = useState<Date | null>(null);
  const [hovered, setHovered] = useState<Date | null>(null);
  const [typed, setTyped] = useState<string | null>(null);
  const [invalid, setInvalid] = useState(false);
  const fieldRef = useRef<HTMLDivElement>(null);
  const current = value === undefined ? ownValue : value;
  const isOpen = open ?? ownOpen;
  const [month, setMonth] = useState(() => monthFor(current?.to));

  // Each time the panel opens or closes, the draft starts over from the value. Held in
  // state rather than an effect, so the first frame of an open panel is already right.
  const [wasOpen, setWasOpen] = useState(isOpen);
  if (wasOpen !== isOpen) {
    setWasOpen(isOpen);
    setDraft(isOpen ? (current ?? null) : null);
    setAnchor(null);
    setHovered(null);
    setTyped(null);
    setInvalid(false);
    setMonth(monthFor(current?.to));
  }

  const setOpen = (next: boolean) => {
    if (open === undefined) {
      setOwnOpen(next);
    }
    onOpenChange?.(next);
  };

  const choose = (range: DateRangeValue) => {
    const next = { from: startOfDay(range.from), to: startOfDay(range.to) };
    if (value === undefined) {
      setOwnValue(next);
    }
    onValueChange?.(next);
    setOpen(false);
  };

  const typedRange = typed === null ? null : parseRange(typed, { max, min });
  // Empty text is a cleared field, not a mistake: it just has no range to confirm.
  const typedWrong = typed !== null && typed.trim() !== "" && !typedRange;
  const canConfirm = Boolean(draft) && !anchor && !typedWrong;

  const confirm = () => {
    if (draft && canConfirm) {
      choose(draft);
    }
  };

  const pick = (day: Date) => {
    const picked = startOfDay(day);
    setTyped(null);
    setInvalid(false);
    if (anchor) {
      setDraft(orderRange(anchor, picked));
      setAnchor(null);
      setHovered(null);
      return;
    }

    setAnchor(picked);
    setHovered(picked);
  };

  const type = (text: string) => {
    setTyped(text);
    setInvalid(false);
    const range = parseRange(text, { max, min });
    if (range) {
      setDraft(range);
      setAnchor(null);
      setHovered(null);
      setMonth(monthFor(range.to));
    } else if (text.trim() === "") {
      setDraft(null);
      setAnchor(null);
      setHovered(null);
    }
  };

  const settle = () => {
    if (typedWrong) {
      setInvalid(true);
    }
  };

  const submit = () => {
    if (typedWrong) {
      setInvalid(true);
      return;
    }

    confirm();
  };

  // Same day, same state: `onDayFocus` and `onDayMouseEnter` both fire for a day that is
  // already hovered, and a fresh `Date` for it would re-render the calendar every time.
  const hover = (day: Date | null) =>
    setHovered((previous) => {
      const next = day && startOfDay(day);
      return previous && next && isSameDay(previous, next) ? previous : next;
    });

  const shown = anchor
    ? orderRange(anchor, hovered ?? anchor)
    : (draft ?? undefined);

  const state: DateRangePickerState = {
    activePresetId: activePreset(presets, draft, anchor),
    anchor,
    canConfirm,
    choose,
    confirm,
    fieldRef,
    hover,
    invalid,
    locale,
    max,
    min,
    month,
    open: isOpen,
    pick,
    presets,
    selected: shown,
    setMonth,
    setOpen,
    settle,
    submit,
    text: typed ?? fieldText({ anchor, current, draft, isOpen }),
    type,
    value: current,
  };

  return (
    <DateRangePickerContext.Provider value={state}>
      <Popover onOpenChange={setOpen} open={isOpen}>
        {children}
      </Popover>
    </DateRangePickerContext.Provider>
  );
};
parts.tsx
"use client";

import { CalendarIcon } from "lucide-react";
import { useRef } from "react";
import type { ComponentProps } from "react";

import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
  PopoverAnchor,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";

import { withinBounds } from "./bounds";
import { useDateRangePicker } from "./context";

/** The caller's handler runs first, and `preventDefault` in it keeps the picker's own from
 *  running — the rule Radix uses. A part spreads the caller's props last, so a handler
 *  passed in would otherwise replace the one the picker depends on. */
const compose =
  <E extends { defaultPrevented: boolean }>(
    theirs: ((event: E) => void) | undefined,
    ours: (event: E) => void
  ) =>
  (event: E) => {
    theirs?.(event);
    if (!event.defaultPrevented) {
      ours(event);
    }
  };

/* -- The field -----------------------------------------------------------------
 * The range, written out and typeable: `2026-10-05 ~ 2026-10-11`. It is the one place
 * that always says what is chosen, so nothing else repeats it — not the panel, not a
 * label naming the shortcut. Focusing it opens the panel; the icon opens it too, for a
 * person who is not going to type.
 * --------------------------------------------------------------------------- */

export type DateRangePickerTriggerProps = Omit<
  ComponentProps<typeof Input>,
  "onChange" | "value"
> & {
  /** The name of the icon button, for assistive tech. */
  calendarLabel?: string;
};

/** `className` sizes the field's box, the input and the icon together; it is `w-64` until
 *  a caller says otherwise. Every other prop is the input's. */
export const DateRangePickerTrigger = ({
  calendarLabel = "Open calendar",
  className,
  onBlur,
  onFocus,
  onKeyDown,
  placeholder = "yyyy-MM-dd ~ yyyy-MM-dd",
  ...props
}: DateRangePickerTriggerProps) => {
  const { fieldRef, invalid, open, setOpen, settle, submit, text, type } =
    useDateRangePicker();

  return (
    <PopoverAnchor asChild>
      <div
        className={cn("relative w-64", className)}
        data-slot="date-range-picker-trigger"
        ref={fieldRef}
      >
        <Input
          aria-invalid={invalid || undefined}
          autoComplete="off"
          className={cn(
            "pr-10 tabular-nums",
            open && "border-ring ring-[3px] ring-ring/50"
          )}
          data-slot="date-range-picker-input"
          inputMode="numeric"
          onBlur={compose(onBlur, settle)}
          onChange={(event) => type(event.target.value)}
          onFocus={compose(onFocus, () => setOpen(true))}
          onKeyDown={compose(onKeyDown, (event) => {
            if (event.key === "Enter") {
              event.preventDefault();
              submit();
            }
          })}
          placeholder={placeholder}
          value={text}
          {...props}
        />
        <PopoverTrigger asChild>
          <Button
            aria-label={calendarLabel}
            className="absolute top-1/2 right-1 -translate-y-1/2 text-muted-foreground"
            size="icon-sm"
            variant="ghost"
          >
            <CalendarIcon />
          </Button>
        </PopoverTrigger>
      </div>
    </PopoverAnchor>
  );
};

/* -- The panel -----------------------------------------------------------------
 * As wide as its calendar: two 16rem months and the line between them. On a phone it
 * shows the second month alone, and the arrows walk back from it.
 * --------------------------------------------------------------------------- */

export type DateRangePickerContentProps = ComponentProps<typeof PopoverContent>;

export const DateRangePickerContent = ({
  className,
  onInteractOutside,
  onOpenAutoFocus,
  ...props
}: DateRangePickerContentProps) => {
  const { fieldRef } = useDateRangePicker();
  const ref = useRef<HTMLDivElement>(null);

  return (
    <PopoverContent
      align="start"
      className={cn(
        "max-h-(--radix-popover-content-available-height) w-fit gap-0 overflow-y-auto p-0",
        className
      )}
      onInteractOutside={compose(onInteractOutside, (event) => {
        // The field is outside the panel but it is the picker's own: pressing in it to
        // place the caret must not close what focusing it just opened.
        if (
          event.target instanceof Node &&
          fieldRef.current?.contains(event.target)
        ) {
          event.preventDefault();
        }
      })}
      onOpenAutoFocus={compose(onOpenAutoFocus, (event) => {
        const active = document.activeElement;
        if (
          active instanceof HTMLInputElement &&
          fieldRef.current?.contains(active)
        ) {
          // Opened by focusing the field: leave the caret where it is.
          event.preventDefault();
          return;
        }

        // Otherwise land on the day the arrow keys will move from, not on the first
        // button in the panel. With no calendar inside, the default focus is kept.
        const day = ref.current?.querySelector<HTMLElement>(
          "button[data-day][tabindex='0']"
        );
        if (day) {
          event.preventDefault();
          day.focus();
        }
      })}
      ref={ref}
      {...props}
    />
  );
};

/* -- The footer ----------------------------------------------------------------
 * A row under the calendar: names on the left, OK on the right. It is a part of its own
 * so that the shortcuts and OK can be dropped separately, and so that the rule above it
 * is drawn once, whichever of them stays.
 * --------------------------------------------------------------------------- */

export type DateRangePickerFooterProps = ComponentProps<"div">;

export const DateRangePickerFooter = ({
  className,
  ...props
}: DateRangePickerFooterProps) => (
  <div
    className={cn(
      "flex items-center justify-between gap-2 border-t px-3 py-2",
      className
    )}
    data-slot="date-range-picker-footer"
    {...props}
  />
);

export type DateRangePickerShortcutsProps = ComponentProps<"div">;

export const DateRangePickerShortcuts = ({
  className,
  ...props
}: DateRangePickerShortcutsProps) => {
  const { activePresetId, choose, max, min, presets } = useDateRangePicker();
  // With no presets there is nothing to draw: an empty slot draws nothing.
  if (presets.length === 0) {
    return null;
  }

  const today = new Date();

  return (
    <div
      aria-label="Shortcuts"
      className={cn("flex gap-1", className)}
      data-slot="date-range-picker-shortcuts"
      role="group"
      {...props}
    >
      {presets.map((preset) => {
        const range = preset.range(today);
        // A shortcut that would choose days the calendar refuses is not offered.
        const active = activePresetId === preset.id;

        return (
          <Button
            aria-pressed={active}
            className={cn(
              "px-2 text-[13px] font-normal",
              active && "bg-accent font-medium"
            )}
            disabled={!withinBounds(range, { max, min })}
            key={preset.id}
            onClick={() => choose(range)}
            size="sm"
            variant="ghost"
          >
            {preset.label}
          </Button>
        );
      })}
    </div>
  );
};

export type DateRangePickerConfirmProps = Omit<
  ComponentProps<typeof Button>,
  "disabled" | "onClick"
>;

/** Commits the draft. Disabled until there is a whole range to commit, which is the
 *  one thing it says about the state of the picker. */
export const DateRangePickerConfirm = ({
  children = "OK",
  ...props
}: DateRangePickerConfirmProps) => {
  const { canConfirm, confirm } = useDateRangePicker();

  return (
    <Button disabled={!canConfirm} onClick={confirm} size="sm" {...props}>
      {children}
    </Button>
  );
};
range-calendar.tsx
"use client";

import { addMonths, isSameDay, subMonths } from "date-fns";
import { useSyncExternalStore } from "react";
import type { ComponentProps } from "react";
import type { DayProps, RootProps } from "react-day-picker";

import { Calendar, CalendarDayButton } from "@/components/ui/calendar";
import { cn } from "@/lib/utils";

import { disabledDays } from "./bounds";
import { useDateRangePicker } from "./context";

/* -- The calendar --------------------------------------------------------------
 * The shadcn calendar, dressed as a range: two months with a line between, the days with
 * no gap between rows, and a band that runs behind them. The band is drawn by the day,
 * not the cell, because it has to start at the middle of the first day and stop at the
 * middle of the last — the filled square sits on the band, not at the end of it.
 *
 * The band has no corners of its own. Its two ends are the filled squares, and anywhere
 * else it is a continuation: the row wraps, or the month ends and the range carries on in
 * the other grid. Rounding a wrap would draw a stack of separate pills, each saying the
 * range stops at the edge of the row, with a notch where two of them meet.
 * --------------------------------------------------------------------------- */

/** The day's own look is `RangeDayButton`; the cell only has to hold it. The arrows sit
 *  at the two outer edges of the grid, one pair for both months. */
const CLASS_NAMES = {
  button_next: "size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
  button_previous:
    "size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
  caption_label: "text-sm font-medium select-none",
  day: "group/day relative h-(--cell-size) w-full p-0 text-center select-none",
  disabled: "text-muted-foreground/40",
  month: "flex w-64 flex-col gap-1 p-3 not-first:border-s",
  month_caption: "flex h-(--cell-size) w-full items-center justify-center",
  months: "relative flex",
  nav: "absolute inset-x-3 top-3 z-10 flex items-center justify-between",
  outside: "text-muted-foreground/60",
  range_end: "",
  range_middle: "",
  range_start: "",
  today: "",
  week: "flex w-full",
  weekday:
    "grid h-7 flex-1 place-items-center text-xs font-normal text-muted-foreground/70 select-none",
};

/** The band behind a day. It starts at the middle of the first day and stops at the middle
 *  of the last, because the filled square sits on the band, not at the end of it. */
const RangeBand = ({ end, start }: { end: boolean; start: boolean }) => (
  <span
    aria-hidden="true"
    className={cn(
      "absolute inset-y-0 bg-accent",
      start ? "left-1/2" : "left-0",
      end ? "right-1/2" : "right-0",
      // An end at the start of a row (or a start at the end of one) has no neighbour
      // for its half to reach. What is left of it past the square is a sliver of
      // band, an ear on the square.
      end && "group-first/day:hidden",
      start && "group-last/day:hidden"
    )}
  />
);

/** The cell around a day. The grid marks a cell as a selected gridcell by the day it holds,
 *  and a neighbouring month's day is not a day of this grid — its real cell is in the other
 *  month — so that cell is only a place where a number sits. */
const RangeDay = ({ day: _day, modifiers, ...props }: DayProps) =>
  modifiers.outside ? (
    <td
      className={props.className}
      data-slot="date-range-picker-outside-day"
      role="presentation"
    >
      {props.children}
    </td>
  ) : (
    <td {...props} />
  );

const RangeDayButton = ({
  children,
  className,
  day,
  modifiers,
  ...props
}: ComponentProps<typeof CalendarDayButton>) => {
  const { anchor } = useDateRangePicker();
  // With two months on screen, the grid pads each month with the days of its neighbours,
  // and those days are on screen twice. Drawn as days they would be selected twice, hovered
  // twice, and a press on the copy would focus the copy. They are only a number, faint:
  // the real day is in its own month.
  if (modifiers.outside) {
    return (
      <span
        aria-hidden="true"
        className="mx-auto grid size-(--cell-size) place-items-center text-sm tabular-nums"
      >
        {children}
      </span>
    );
  }

  const start = Boolean(modifiers.range_start);
  const end = Boolean(modifiers.range_end);
  const edge = start || end;
  // While a range waits for its other end, the end under the pointer is a question, not
  // an answer: drawn as an outline, it says one more press makes it a range.
  const question =
    anchor !== null && edge && !(start && end) && !isSameDay(day.date, anchor);

  return (
    <>
      {modifiers.selected && !(start && end) ? (
        <RangeBand end={end} start={start} />
      ) : null}
      <CalendarDayButton
        className={cn(
          // A day changes its look under the pointer many times a second as the preview
          // follows it, so it changes at once: the button's own `transition-all` would
          // draw every one of those changes as a fade.
          "relative mx-auto aspect-auto size-(--cell-size) w-(--cell-size) min-w-0 text-sm tabular-nums transition-none data-[range-middle=true]:rounded-md data-[range-middle=true]:hover:bg-foreground/10",
          // The grid's own focus ring is a 3px halo that also stays after a press; the
          // ring a key press draws is the button's `focus-visible` one, thinner.
          "group-data-[focused=true]/day:ring-0 focus-visible:ring-2 focus-visible:ring-ring/70",
          modifiers.today && !edge && "ring-1 ring-ring ring-inset",
          // The ghost button's hover fill is translucent and outranks the range fill, so
          // a hovered start would turn grey with the band showing through its half.
          edge &&
            !question &&
            "hover:bg-primary hover:text-primary-foreground dark:hover:bg-primary dark:hover:text-primary-foreground",
          // The question end is always under the pointer, so it is always hovered: the
          // ghost button's translucent hover fill would let the band behind it show
          // through on one half, and every hover state is restated opaque.
          question &&
            "bg-popover text-foreground ring-1 ring-foreground ring-inset hover:bg-popover hover:text-foreground dark:hover:bg-popover",
          className
        )}
        day={day}
        modifiers={
          question
            ? {
                ...modifiers,
                range_end: false,
                range_start: false,
                selected: false,
              }
            : modifiers
        }
        {...props}
      >
        {children}
      </CalendarDayButton>
    </>
  );
};

/** A phone has room for one month, not two. It is rendered as one rather than hidden with
 *  CSS: the grid picks the day the Tab key lands on from the days it holds, and a day in a
 *  hidden month is a target nobody can reach. */
const NARROW = "(max-width: 639px)";
const subscribeNarrow = (notify: () => void) => {
  const query = window.matchMedia(NARROW);
  query.addEventListener("change", notify);
  return () => query.removeEventListener("change", notify);
};
const useNarrow = () =>
  useSyncExternalStore(
    subscribeNarrow,
    () => window.matchMedia(NARROW).matches,
    () => false
  );

/** The shadcn calendar writes its root inline in `components={{ … }}`, so it is a new
 *  component type on every render, and React remounts the whole grid under it. The picker
 *  re-renders on every day the pointer crosses, so it hands in a root of its own. */
const RangeRoot = ({ className, rootRef, ...props }: RootProps) => (
  <div
    className={cn(className)}
    data-slot="calendar"
    ref={rootRef}
    {...props}
  />
);

/** `react-day-picker` treats `selected` as controlled only when it is also given an
 *  `onSelect`. Without one it keeps a selection of its own and reads `selected` once, on
 *  the first render — so a range typed into the field, or drawn by a preview, never
 *  reached the grid. The draft is the selection and `onDayClick` is how it changes, so
 *  this is a receiver that does nothing. */
const ignoreSelect = () => null;

export interface DateRangePickerCalendarProps {
  className?: string;
}

export const DateRangePickerCalendar = ({
  className,
}: DateRangePickerCalendarProps) => {
  const { hover, locale, max, min, month, pick, selected, setMonth } =
    useDateRangePicker();
  const narrow = useNarrow();

  return (
    <Calendar
      className={cn("p-0", className)}
      classNames={CLASS_NAMES}
      components={{ Day: RangeDay, DayButton: RangeDayButton, Root: RangeRoot }}
      disabled={disabledDays({ max, min })}
      endMonth={max}
      fixedWeeks
      locale={locale}
      mode="range"
      // `month` is the first month on show: the one before the range's end. One month
      // on a phone shows the end's own.
      month={narrow ? addMonths(month, 1) : month}
      numberOfMonths={narrow ? 1 : 2}
      onDayClick={pick}
      onDayFocus={hover}
      onDayMouseEnter={hover}
      onDayMouseLeave={() => hover(null)}
      onSelect={ignoreSelect}
      onMonthChange={(next) => setMonth(narrow ? subMonths(next, 1) : next)}
      selected={selected}
      startMonth={min}
    />
  );
};
context.ts
"use client";

import { createContext, useContext } from "react";
import type { RefObject } from "react";
import type { DateRange, DayPickerLocale } from "react-day-picker";

import type { DateRangePreset, DateRangeValue } from "./types";

/**
 * What a part needs from the root. The calendar, the shortcuts and the typed field are
 * three ways of making one range, so none of them can hold its own half of it: the root
 * keeps the draft, the pending first day, the text, and the month the calendar shows.
 */
export interface DateRangePickerState {
  /** The shortcut the draft equals, if any. */
  activePresetId: string | undefined;
  /** The first day of a range that is still waiting for its other end. */
  anchor: Date | null;
  /** The draft is a finished range, and the text, if typed, is one too. */
  canConfirm: boolean;
  /** Commit a range and close. A shortcut does this in one press. */
  choose: (range: DateRangeValue) => void;
  /** Commit the draft and close. */
  confirm: () => void;
  /** This picker's field. The panel asks it, not the page, whether a press or the focus
   *  is the picker's own: another picker's field is outside this one. */
  fieldRef: RefObject<HTMLDivElement | null>;
  /** The text in the field: what was typed, or the draft written out. */
  text: string;
  /** Typed text that is not a range, once the person has left the field or pressed
   *  Enter. While they are still typing it is not a fault yet. */
  invalid: boolean;
  /** The day the pointer or the keyboard is on while a range is pending. */
  hover: (day: Date | null) => void;
  locale?: DayPickerLocale;
  max?: Date;
  min?: Date;
  month: Date;
  open: boolean;
  /** A day was pressed: the first press starts a range, the second finishes it. */
  pick: (day: Date) => void;
  presets: DateRangePreset[];
  /** What the calendar draws: the pending pick, or the draft. */
  selected: DateRange | undefined;
  setMonth: (month: Date) => void;
  setOpen: (open: boolean) => void;
  /** The person left the field, or pressed Enter in it: say so if it is not a range. */
  settle: () => void;
  /** Enter in the field: commit if the text is a range, flag it if not. */
  submit: () => void;
  /** The text changed. A whole range moves the calendar to it. */
  type: (text: string) => void;
  /** The committed range. */
  value?: DateRangeValue;
}

export const DateRangePickerContext =
  createContext<DateRangePickerState | null>(null);

export const useDateRangePicker = () => {
  const context = useContext(DateRangePickerContext);
  if (!context) {
    throw new Error(
      "DateRangePicker parts must be used within a DateRangePicker."
    );
  }

  return context;
};
format.ts
import { format, isValid, parse } from "date-fns";

import { withinBounds } from "./bounds";
import type { DateRangeValue } from "./types";

/** The one way a range is written, in the trigger and in what a person can type. It is
 *  ISO order and not the locale's, so the text means the same thing everywhere and the
 *  server and the client write the same string. */
const DAY = "yyyy-MM-dd";
const SEPARATOR = "~";
const RANGE = /^\s*(\d{4}-\d{2}-\d{2})\s*[~–—]\s*(\d{4}-\d{2}-\d{2})\s*$/;

export const formatDay = (day: Date) => format(day, DAY);

export const formatRange = (range: DateRangeValue | null | undefined) =>
  range ? `${formatDay(range.from)} ${SEPARATOR} ${formatDay(range.to)}` : "";

/** What a person typed, as a range — or `null` when it is not one the picker would
 *  accept: not that shape, not a real day (`2026-02-31`), backwards, or outside the
 *  bounds the calendar would refuse. */
export const parseRange = (
  text: string,
  { max, min }: { max?: Date; min?: Date }
): DateRangeValue | null => {
  const match = RANGE.exec(text);
  if (!match) {
    return null;
  }

  const from = parse(match[1], DAY, new Date());
  const to = parse(match[2], DAY, new Date());
  if (!(isValid(from) && isValid(to)) || from > to) {
    return null;
  }

  const range = { from, to };
  return withinBounds(range, { max, min }) ? range : null;
};
presets.ts
import { startOfDay, subDays } from "date-fns";

import type { DateRangePreset } from "./types";

/** The `days` days ending today, today included: `lastDays("7d", "Last 7 days", 7)` on a
 *  Sunday runs from the Monday before to that Sunday. */
export const lastDays = (
  id: string,
  label: string,
  days: number
): DateRangePreset => ({
  id,
  label,
  range: (today) => ({
    from: subDays(startOfDay(today), days - 1),
    to: startOfDay(today),
  }),
});

export const defaultDateRangePresets: DateRangePreset[] = [
  lastDays("today", "Today", 1),
  lastDays("7d", "Last 7 days", 7),
  lastDays("30d", "Last 30 days", 30),
];
types.ts
import type { ReactNode } from "react";
import type { DayPickerLocale } from "react-day-picker";

/** A finished range: both ends are days, and `from` is never after `to`. A half-picked
 *  range is the picker's own state and never reaches `onValueChange`. */
export interface DateRangeValue {
  from: Date;
  to: Date;
}

/** A shortcut: one press chooses a whole range. `range` is handed today's date, so a
 *  preset written as "the last 7 days" is still right the next morning. */
export interface DateRangePreset {
  id: string;
  label: string;
  range: (today: Date) => DateRangeValue;
}

export interface DateRangePickerProps {
  children?: ReactNode;
  defaultOpen?: boolean;
  defaultValue?: DateRangeValue;
  /** A `react-day-picker` locale, e.g. `zhCN` from `react-day-picker/locale`. It sets the
   *  month and weekday names and the first day of the week. The range itself is always
   *  written `yyyy-MM-dd`. Left out, everything is `en-US`. */
  locale?: DayPickerLocale;
  /** The last day that can be picked. Later days are drawn but cannot be chosen. */
  max?: Date;
  /** The first day that can be picked. */
  min?: Date;
  onOpenChange?: (open: boolean) => void;
  onValueChange?: (value: DateRangeValue) => void;
  open?: boolean;
  /** The shortcuts. Pass `[]` for a picker with none. */
  presets?: DateRangePreset[];
  value?: DateRangeValue;
}