# Date Range Picker

> Two-month range calendar popover with hover preview, presets, min/max bounds and full keyboard navigation.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/date-range-picker
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/date-range-picker.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/date-range-picker-demo.tsx"
"use client"

import * as React from "react"

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

export default function DateRangePickerDemo() {
  const [range, setRange] = React.useState<DateRange>({ from: "2026-11-02", to: "2026-11-08" })

  return (
    <div className="grid w-full max-w-md grid-cols-[minmax(0,1fr)] gap-6">
      <div className="grid min-w-0 gap-2">
        <span className="text-sm font-medium">Report period</span>
        <DateRangePicker value={range} onValueChange={setRange} today="2026-11-07" min="2026-01-01" max="2026-12-31" />
        <p className="text-xs text-muted-foreground">
          {range.from && range.to ? `Showing revenue from ${range.from} to ${range.to}.` : "Choose a period to load the report."}
        </p>
      </div>
    </div>
  )
}
```

## Source

```tsx title="components/date-range-picker.tsx"
"use client"

import * as React from "react"
import { CalendarDays, ChevronLeft, ChevronRight } from "lucide-react"

import { cn } from "@/lib/utils"

/** ISO dates, e.g. "2026-11-07". */
export type DateRange = { from?: string; to?: string }

/** Fixed default so server and client render the same "today". Pass the `today` prop in real apps. */
export const DEFAULT_TODAY = "2026-11-07"

/* ---------- dependency-free calendar math (days since 1970-01-01) ---------- */

type Day = number

const MONTHS = [
  "January", "February", "March", "April", "May", "June",
  "July", "August", "September", "October", "November", "December",
]
const WEEKDAYS = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"]

function toDay(y: number, m: number, d: number): Day {
  const yy = m <= 2 ? y - 1 : y
  const era = Math.floor(yy / 400)
  const yoe = yy - era * 400
  const doy = Math.floor((153 * (m + (m > 2 ? -3 : 9)) + 2) / 5) + d - 1
  const doe = yoe * 365 + Math.floor(yoe / 4) - Math.floor(yoe / 100) + doy
  return era * 146097 + doe - 719468
}

function fromDay(n: Day): [number, number, number] {
  const z = n + 719468
  const era = Math.floor(z / 146097)
  const doe = z - era * 146097
  const yoe = Math.floor((doe - Math.floor(doe / 1460) + Math.floor(doe / 36524) - Math.floor(doe / 146096)) / 365)
  const doy = doe - (365 * yoe + Math.floor(yoe / 4) - Math.floor(yoe / 100))
  const mp = Math.floor((5 * doy + 2) / 153)
  const d = doy - Math.floor((153 * mp + 2) / 5) + 1
  const m = mp < 10 ? mp + 3 : mp - 9
  return [yoe + era * 400 + (m <= 2 ? 1 : 0), m, d]
}

function parseISO(value?: string): Day | null {
  const match = value ? /^(\d{4})-(\d{2})-(\d{2})$/.exec(value) : null
  return match ? toDay(Number(match[1]), Number(match[2]), Number(match[3])) : null
}

function toISO(n: Day) {
  const [y, m, d] = fromDay(n)
  return `${String(y).padStart(4, "0")}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`
}

function formatDay(n: Day, withYear = true) {
  const [y, m, d] = fromDay(n)
  return `${MONTHS[m - 1].slice(0, 3)} ${d}${withYear ? `, ${y}` : ""}`
}

function longLabel(n: Day) {
  const [y, m, d] = fromDay(n)
  return `${MONTHS[m - 1]} ${d}, ${y}`
}

/** Monday = 0 … Sunday = 6 */
function weekday(n: Day) {
  return (((n + 3) % 7) + 7) % 7
}

function monthIndex(n: Day) {
  const [y, m] = fromDay(n)
  return y * 12 + (m - 1)
}

function monthStart(idx: number): Day {
  return toDay(Math.floor(idx / 12), (((idx % 12) + 12) % 12) + 1, 1)
}

function shiftMonth(n: Day, delta: number): Day {
  const idx = monthIndex(n) + delta
  const [, , d] = fromDay(n)
  return monthStart(idx) + Math.min(d, monthStart(idx + 1) - monthStart(idx)) - 1
}

/* ---------- component ---------- */

const presetList: { label: string; get: (today: Day) => [Day, Day] }[] = [
  { label: "Today", get: (t) => [t, t] },
  { label: "Yesterday", get: (t) => [t - 1, t - 1] },
  { label: "Last 7 days", get: (t) => [t - 6, t] },
  { label: "Last 30 days", get: (t) => [t - 29, t] },
  { label: "This month", get: (t) => [monthStart(monthIndex(t)), monthStart(monthIndex(t) + 1) - 1] },
  { label: "Last month", get: (t) => [monthStart(monthIndex(t) - 1), monthStart(monthIndex(t)) - 1] },
]

function subscribeWide(callback: () => void) {
  const query = window.matchMedia("(min-width: 640px)")
  query.addEventListener("change", callback)
  return () => query.removeEventListener("change", callback)
}
const getWide = () => window.matchMedia("(min-width: 640px)").matches
const getWideServer = () => true

export interface DateRangePickerProps
  extends Omit<React.ComponentProps<"div">, "onChange" | "defaultValue"> {
  value?: DateRange
  defaultValue?: DateRange
  onValueChange?: (range: DateRange) => void
  /** ISO date treated as "today" (highlighted and used by presets). */
  today?: string
  /** Earliest selectable ISO date */
  min?: string
  /** Latest selectable ISO date */
  max?: string
  /** Show the quick-range presets */
  presets?: boolean
  placeholder?: string
  /** Which edge of the trigger the popover aligns to */
  align?: "start" | "end"
  disabled?: boolean
  /** Opens on first render without moving focus, handy for static previews */
  defaultOpen?: boolean
}

export function DateRangePicker({
  value,
  defaultValue,
  onValueChange,
  today = DEFAULT_TODAY,
  min,
  max,
  presets = true,
  placeholder = "Pick a date range",
  align = "start",
  disabled = false,
  defaultOpen = false,
  className,
  ...props
}: DateRangePickerProps) {
  const baseId = React.useId()
  const root = React.useRef<HTMLDivElement>(null)
  const trigger = React.useRef<HTMLButtonElement>(null)
  const needsFocus = React.useRef(false)

  const todayDay = parseISO(today) ?? (parseISO(DEFAULT_TODAY) as Day)
  const minDay = parseISO(min)
  const maxDay = parseISO(max)
  const clampDay = (d: Day) => Math.min(Math.max(d, minDay ?? -Infinity), maxDay ?? Infinity)

  const [inner, setInner] = React.useState<DateRange>(defaultValue ?? {})
  const range = value ?? inner
  const committedFrom = parseISO(range.from)
  const committedTo = parseISO(range.to) ?? committedFrom

  const wide = React.useSyncExternalStore(subscribeWide, getWide, getWideServer)
  const months = wide ? 2 : 1

  const [open, setOpen] = React.useState(defaultOpen)
  const [pending, setPending] = React.useState<Day | null>(null)
  const [hover, setHover] = React.useState<Day | null>(null)
  const [focusDay, setFocusDay] = React.useState<Day>(() => clampDay(committedFrom ?? todayDay))
  const [viewMonth, setViewMonth] = React.useState(() => monthIndex(clampDay(committedFrom ?? todayDay)))

  function commit(next: DateRange) {
    if (value === undefined) setInner(next)
    onValueChange?.(next)
  }

  function openPicker() {
    const start = clampDay(committedFrom ?? todayDay)
    setFocusDay(start)
    setViewMonth(monthIndex(start))
    setPending(null)
    setHover(null)
    needsFocus.current = true
    setOpen(true)
  }

  function close(refocus: boolean) {
    setOpen(false)
    setPending(null)
    setHover(null)
    if (refocus) trigger.current?.focus()
  }

  React.useEffect(() => {
    if (!open) return
    function onPointerDown(event: PointerEvent) {
      if (root.current && !root.current.contains(event.target as Node)) {
        setOpen(false)
        setPending(null)
        setHover(null)
      }
    }
    document.addEventListener("pointerdown", onPointerDown)
    return () => document.removeEventListener("pointerdown", onPointerDown)
  }, [open])

  // Moves DOM focus after keyboard navigation / opening. Reads a ref only inside an effect.
  React.useEffect(() => {
    if (!open || !needsFocus.current) return
    needsFocus.current = false
    document.getElementById(`${baseId}-d${focusDay}`)?.focus()
  })

  function moveFocus(next: Day) {
    const target = clampDay(next)
    setFocusDay(target)
    const idx = monthIndex(target)
    if (idx < viewMonth) setViewMonth(idx)
    else if (idx > viewMonth + months - 1) setViewMonth(idx - months + 1)
    if (pending !== null) setHover(target)
    needsFocus.current = true
  }

  function onDayKeyDown(event: React.KeyboardEvent<HTMLButtonElement>, day: Day) {
    let next: Day | null = null
    switch (event.key) {
      case "ArrowLeft": next = day - 1; break
      case "ArrowRight": next = day + 1; break
      case "ArrowUp": next = day - 7; break
      case "ArrowDown": next = day + 7; break
      case "Home": next = day - weekday(day); break
      case "End": next = day + (6 - weekday(day)); break
      case "PageUp": next = shiftMonth(day, event.shiftKey ? -12 : -1); break
      case "PageDown": next = shiftMonth(day, event.shiftKey ? 12 : 1); break
    }
    if (next === null) return
    event.preventDefault()
    moveFocus(next)
  }

  function pick(day: Day) {
    if (pending === null) {
      setPending(day)
      setHover(day)
      setFocusDay(day)
      return
    }
    const lo = Math.min(pending, day)
    const hi = Math.max(pending, day)
    commit({ from: toISO(lo), to: toISO(hi) })
    close(true)
  }

  function step(delta: number) {
    setViewMonth(viewMonth + delta)
    setFocusDay(clampDay(shiftMonth(focusDay, delta)))
  }

  // Range currently shown (live preview while picking the second date).
  let lo: Day | null = null
  let hi: Day | null = null
  if (pending !== null) {
    const other = hover ?? pending
    lo = Math.min(pending, other)
    hi = Math.max(pending, other)
  } else if (committedFrom !== null && committedTo !== null) {
    lo = Math.min(committedFrom, committedTo)
    hi = Math.max(committedFrom, committedTo)
  }

  const triggerLabel =
    committedFrom !== null && committedTo !== null && range.to
      ? `${formatDay(committedFrom)} – ${formatDay(committedTo)}`
      : committedFrom !== null
        ? `${formatDay(committedFrom)} – …`
        : null

  const summary =
    pending !== null
      ? `${formatDay(pending)} – select an end date`
      : lo !== null && hi !== null
        ? `${formatDay(lo, false)} – ${formatDay(hi, false)} · ${hi - lo + 1} day${hi - lo ? "s" : ""}`
        : "No range selected"

  function renderMonth(idx: number, position: number) {
    const first = monthStart(idx)
    const count = monthStart(idx + 1) - first
    const blanks = weekday(first)
    const cells: (Day | null)[] = [
      ...Array.from({ length: blanks }, () => null),
      ...Array.from({ length: count }, (_, i) => first + i),
    ]
    while (cells.length % 7 !== 0) cells.push(null)
    const rows: (Day | null)[][] = []
    for (let i = 0; i < cells.length; i += 7) rows.push(cells.slice(i, i + 7))
    const [year, month] = fromDay(first)
    const titleId = `${baseId}-title-${position}`
    const canPrev = minDay === null || monthStart(viewMonth) > minDay
    const canNext = maxDay === null || monthStart(viewMonth + months) <= maxDay
    const tabDay =
      focusDay >= monthStart(viewMonth) && focusDay < monthStart(viewMonth + months)
        ? focusDay
        : clampDay(monthStart(viewMonth))

    return (
      <div key={idx} className="w-[252px] shrink-0">
        <div className="mb-2 grid grid-cols-[2rem_1fr_2rem] items-center">
          {position === 0 ? (
            <button
              type="button"
              aria-label="Previous month"
              disabled={!canPrev}
              onClick={() => step(-1)}
              className="inline-flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-30"
            >
              <ChevronLeft className="size-4" aria-hidden />
            </button>
          ) : (
            <span />
          )}
          <h3 id={titleId} aria-live="polite" className="text-center text-sm font-semibold">
            {MONTHS[month - 1]} {year}
          </h3>
          {position === months - 1 ? (
            <button
              type="button"
              aria-label="Next month"
              disabled={!canNext}
              onClick={() => step(1)}
              className="inline-flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-30"
            >
              <ChevronRight className="size-4" aria-hidden />
            </button>
          ) : (
            <span />
          )}
        </div>
        <div role="grid" aria-labelledby={titleId}>
          <div role="row" className="grid grid-cols-7">
            {WEEKDAYS.map((w) => (
              <div key={w} role="columnheader" className="flex h-8 items-center justify-center text-xs font-medium text-muted-foreground">
                {w}
              </div>
            ))}
          </div>
          {rows.map((row, r) => (
            <div key={r} role="row" className="grid grid-cols-7">
              {row.map((day, c) => {
                if (day === null) return <div key={c} role="gridcell" className="size-9" />
                const unavailable = (minDay !== null && day < minDay) || (maxDay !== null && day > maxDay)
                const isStart = day === lo
                const isEnd = day === hi
                const inside = lo !== null && hi !== null && day > lo && day < hi
                const edge = isStart || isEnd
                return (
                  <div key={c} role="gridcell" aria-selected={edge || inside} className="relative">
                    <button
                      id={`${baseId}-d${day}`}
                      type="button"
                      tabIndex={day === tabDay ? 0 : -1}
                      disabled={unavailable}
                      aria-label={longLabel(day)}
                      aria-current={day === todayDay ? "date" : undefined}
                      aria-pressed={edge || inside}
                      onClick={() => pick(day)}
                      onKeyDown={(event) => onDayKeyDown(event, day)}
                      onMouseEnter={() => pending !== null && setHover(day)}
                      onFocus={() => {
                        setFocusDay(day)
                        if (pending !== null) setHover(day)
                      }}
                      className={cn(
                        "flex size-9 items-center justify-center text-sm tabular-nums transition-colors focus-visible:z-10 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-30",
                        edge
                          ? "bg-primary font-medium text-primary-foreground"
                          : inside
                            ? "bg-accent text-accent-foreground"
                            : "hover:bg-muted",
                        !inside && !edge && "rounded-lg",
                        isStart && isEnd && "rounded-lg",
                        isStart && !isEnd && "rounded-l-lg",
                        isEnd && !isStart && "rounded-r-lg",
                        inside && c === 0 && "rounded-l-lg",
                        inside && c === 6 && "rounded-r-lg",
                        day === todayDay && !edge && "font-semibold ring-1 ring-inset ring-primary/50"
                      )}
                    >
                      {fromDay(day)[2]}
                    </button>
                  </div>
                )
              })}
            </div>
          ))}
        </div>
      </div>
    )
  }

  const panelId = `${baseId}-panel`

  return (
    <div ref={root} className={cn("relative w-full", className)} {...props}>
      <button
        ref={trigger}
        type="button"
        disabled={disabled}
        aria-haspopup="dialog"
        aria-expanded={open}
        aria-controls={open ? panelId : undefined}
        onClick={() => (open ? close(false) : openPicker())}
        className="flex h-10 w-full min-w-0 items-center gap-2 rounded-lg border bg-background px-3 text-left text-sm shadow-xs transition-[box-shadow,border-color] hover:bg-muted/50 focus-visible:border-ring focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-50"
      >
        <CalendarDays className="size-4 shrink-0 text-muted-foreground" aria-hidden />
        <span className={cn("min-w-0 flex-1 truncate", !triggerLabel && "text-muted-foreground")}>
          {triggerLabel ?? placeholder}
        </span>
      </button>

      {open && (
        <div
          id={panelId}
          role="dialog"
          aria-label="Choose date range"
          onKeyDown={(event) => {
            if (event.key === "Escape") {
              event.preventDefault()
              event.stopPropagation()
              close(true)
            }
          }}
          className={cn(
            "absolute top-full z-50 mt-2 flex w-max max-w-[calc(100vw-2rem)] flex-col gap-3 rounded-xl border bg-popover p-3 text-popover-foreground shadow-lg sm:flex-row",
            align === "end" ? "right-0" : "left-0"
          )}
        >
          {presets && (
            <div
              role="group"
              aria-label="Quick ranges"
              className="flex min-w-0 gap-1 overflow-x-auto pb-1 sm:flex-col sm:overflow-visible sm:border-r sm:pr-3 sm:pb-0"
            >
              {presetList.map((preset) => {
                const [a, b] = preset.get(todayDay)
                const out = (maxDay !== null && a > maxDay) || (minDay !== null && b < minDay)
                return (
                  <button
                    key={preset.label}
                    type="button"
                    disabled={out}
                    onClick={() => {
                      const from = clampDay(a)
                      const to = clampDay(b)
                      commit({ from: toISO(from), to: toISO(to) })
                      close(true)
                    }}
                    className="shrink-0 rounded-lg px-2.5 py-1.5 text-left text-sm whitespace-nowrap text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-30"
                  >
                    {preset.label}
                  </button>
                )
              })}
            </div>
          )}
          <div className="min-w-0">
            <div className="flex max-w-full gap-4 overflow-x-auto">
              {Array.from({ length: months }, (_, i) => renderMonth(viewMonth + i, i))}
            </div>
            <div className="mt-3 flex items-center justify-between gap-2 border-t pt-3 text-xs text-muted-foreground">
              <span aria-live="polite" className="min-w-0 truncate">
                {summary}
              </span>
              <button
                type="button"
                onClick={() => {
                  commit({})
                  setPending(null)
                  setHover(null)
                }}
                disabled={!committedFrom && pending === null}
                className="shrink-0 rounded-md px-2 py-1 font-medium text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-40"
              >
                Clear
              </button>
            </div>
          </div>
        </div>
      )}
    </div>
  )
}
```
