Skip to contentVibraUI
Inputs & filters

Date range picker

A from-and-to date field with the usual reporting windows down the side.

getPresetRange takes now as a parameter, and the preset buttons pass the picker's own now prop — or, without one, read the clock on the press, never in render; the trigger label is derived from value alone. The to-date windows (This month, This year) stop today rather than at the end of the period, since a range running into the future only adds empty days. The panel stays open while a range is being picked, because that takes two presses. As with DatePicker, the presence of the value key is what says the caller holds the window, so value={undefined} stays controlled; leave the key out for an uncontrolled picker. Presets are a wrapping row above the calendar on a phone and a column beside it from sm up.

Install

npx shadcn@latest add @vibra/date-range-picker

Needs the @vibra registry in your components.json — set it up once.

Examples

Custom presets and one month

A shortened preset column, and a small picker with none at all.

Props

PropTypeDefaultDescription
value{ from?: Date; to?: Date }—The chosen window; the label is derived from it alone. Passing the key at all — even as undefined — makes the picker controlled.
onValueChange(range: { from?: Date; to?: Date } | undefined) => void—Called on every press in the calendar, so a half-picked range is reported too.
presets{ key: string; label: string }[] | falsedateRangePresetsKeys getPresetRange knows; false drops the column entirely.
numberOfMonths1 | 22How many months the calendar shows; they stack below md.
align"start" | "end""start"Which edge the panel lines up with.
size"sm" | "default""default"sm drops the trigger to h-7 for dense toolbars.
placeholderstring"Pick a date range"Stands in for the label while no window is chosen.
disabledbooleanfalseDims the trigger and stops it opening.
minDate—Earliest selectable day; earlier months cannot be reached.
maxDate—Latest selectable day; later months cannot be reached.
aria-labelstring—Names the trigger, e.g. Reporting period.
nowDate—The instant the presets count from. Left off, a preset reads the clock when it is pressed; a page pinned to a date of its own passes that date.
calendarPickerCalendarProps—Handed to the calendar in the popover, as DatePicker's is: today, labels, modifiers, footer; a disabled given here is added to min and max.
defaultOpenbooleanfalseOpens the calendar on the first render, for a preview. The panel is a dialog named by the trigger's aria-label, or "Choose a date range".
dateRangePresets{ key: string; label: string }[]—today, yesterday, last7, last30, thisMonth, lastMonth, thisYear.
getPresetRange(key: string, now?: Date) => { from?: Date; to?: Date }now: new Date()Whole days around now for a preset key; an unknown key gives an empty range.
classNamestring—Merged onto the trigger, which is the root; the remaining button props are spread onto it too.

Dependencies

Source

components/ui/date-range-picker.tsx
"use client"

import * as React from "react"
import {
  endOfDay,
  endOfMonth,
  startOfDay,
  startOfMonth,
  startOfYear,
  subDays,
  subMonths,
} from "date-fns"
import { CalendarIcon } from "lucide-react"

import { cn } from "@/lib/utils"
import { formatDate } from "@/lib/format"
import { Button } from "@/components/ui/button"
import { Calendar, pickerDisabled, type PickerCalendarProps } from "@/components/ui/calendar"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"

export type DateRange = {
  from?: Date
  to?: Date
}

export type DateRangePreset = {
  key: string
  label: string
}

/** The ranges a dashboard reaches for first, in the order they are offered. */
export const dateRangePresets: DateRangePreset[] = [
  { key: "today", label: "Today" },
  { key: "yesterday", label: "Yesterday" },
  { key: "last7", label: "Last 7 days" },
  { key: "last30", label: "Last 30 days" },
  { key: "thisMonth", label: "This month" },
  { key: "lastMonth", label: "Last month" },
  { key: "thisYear", label: "This year" },
]

/**
 * Turns a preset key into whole days around `now`; an unknown key gives an empty
 * range. `now` is a parameter so a render never has to read the clock.
 */
export function getPresetRange(key: string, now: Date = new Date()): DateRange {
  switch (key) {
    case "today":
      return { from: startOfDay(now), to: endOfDay(now) }
    case "yesterday": {
      const yesterday = subDays(now, 1)
      return { from: startOfDay(yesterday), to: endOfDay(yesterday) }
    }
    case "last7":
      return { from: startOfDay(subDays(now, 6)), to: endOfDay(now) }
    case "last30":
      return { from: startOfDay(subDays(now, 29)), to: endOfDay(now) }
    // The to-date periods stop today rather than at the end of the period: a
    // range running into the future would only add empty days.
    case "thisMonth":
      return { from: startOfMonth(now), to: endOfDay(now) }
    case "lastMonth": {
      const previous = subMonths(now, 1)
      return { from: startOfMonth(previous), to: endOfMonth(previous) }
    }
    case "thisYear":
      return { from: startOfYear(now), to: endOfDay(now) }
    default:
      return {}
  }
}

// The trigger button is the root: className and the rest of the props land on
// it. The names below are re-declared because the item owns them — className is
// narrowed back to a string, and size is the item's density axis.
export type DateRangePickerProps = Omit<
  React.ComponentProps<typeof Button>,
  "value" | "className" | "size" | "variant" | "disabled" | "aria-label" | "children" | "render"
> & {
  value?: DateRange
  onValueChange?: (range: DateRange | undefined) => void
  /** Preset keys getPresetRange knows; false drops the column entirely. */
  presets?: DateRangePreset[] | false
  numberOfMonths?: 1 | 2
  align?: "start" | "end"
  size?: "sm" | "default"
  placeholder?: string
  disabled?: boolean
  className?: string
  min?: Date
  max?: Date
  /** Names the trigger, e.g. "Reporting period". */
  "aria-label"?: string
  /**
   * The instant the presets count from. Left off, a preset reads the clock
   * when it is pressed; a page pinned to a date of its own passes that date.
   */
  now?: Date
  /** Handed to the calendar in the popover, as DatePicker's is: `today`, `labels`, `modifiers`, `footer`… */
  calendar?: PickerCalendarProps
  /** Opens the calendar on the first render — for a preview; a reader opens it with the trigger. */
  defaultOpen?: boolean
}

/** A from-and-to date field with the usual reporting windows down the side. */
function DateRangePicker(props: DateRangePickerProps) {
  const {
    value,
    onValueChange,
    presets = dateRangePresets,
    numberOfMonths = 2,
    align = "start",
    size = "default",
    placeholder = "Pick a date range",
    disabled = false,
    className,
    min,
    max,
    "aria-label": ariaLabel,
    now,
    calendar,
    defaultOpen = false,
    ...rest
  } = props
  const { disabled: moreDisabled, ...calendarProps } = calendar ?? {}

  const panelRef = React.useRef<HTMLDivElement>(null)
  const [open, setOpen] = React.useState(defaultOpen)
  // The *key*, not its value, says who holds the window: an undefined range is a
  // real state — nothing chosen — so `value={undefined}` has to stay controlled
  // rather than silently handing the picker its own state.
  const controlled = "value" in props
  const [internal, setInternal] = React.useState<DateRange | undefined>(value)
  const selected = controlled ? value : internal

  function commit(next: DateRange | undefined, close: boolean) {
    if (!controlled) setInternal(next)
    onValueChange?.(next)
    if (close) setOpen(false)
  }

  // Derived from the value alone — never from today — so the server and the
  // client always write the same label.
  const label = selected?.from
    ? selected.to
      ? `${formatDate(selected.from)} – ${formatDate(selected.to)}`
      : formatDate(selected.from)
    : placeholder

  const outOfRange = pickerDisabled(min, max, moreDisabled)

  return (
    <Popover open={open} onOpenChange={setOpen}>
      <PopoverTrigger
        render={
          <Button
            type="button"
            data-slot="date-range-picker"
            data-size={size}
            data-align={align}
            variant="outline"
            size={size === "sm" ? "sm" : "default"}
            disabled={disabled}
            aria-label={ariaLabel}
            className={cn("w-full justify-start gap-2 font-normal", className)}
            {...rest}
          >
            <CalendarIcon className="text-muted-foreground" />
            <span className={cn("truncate", !selected?.from && "text-muted-foreground")}>
              {label}
            </span>
          </Button>
        }
      />

      {/* COUPLED TO registry/vibra/ui/popover.tsx: two calendars bring their own
          width and padding, so the panel's w-72 and p-2.5 come off. */}
      <PopoverContent
        ref={panelRef}
        align={align}
        // The panel is a dialog, and a dialog needs a name: the trigger's own
        // aria-label when it has one, else what the panel is for.
        aria-label={ariaLabel ?? "Choose a date range"}
        // Base UI would otherwise focus the first tabbable, which is the
        // previous-month arrow; the calendar's own tab stop is the day
        // react-day-picker marks as the focus target, and landing there is what
        // makes the arrow keys work straight away.
        initialFocus={() =>
          panelRef.current?.querySelector<HTMLElement>('button[data-day][tabindex="0"]') ?? true
        }
        // Never wider than the room the positioner found: on a phone the
        // preset row would otherwise size the panel to the whole viewport and
        // the collision padding would push it past the edge.
        className="w-auto max-w-(--available-width) flex-col gap-0 p-0 sm:flex-row"
      >
        {presets && presets.length > 0 ? (
          <div
            data-slot="date-range-picker-presets"
            // A wrapping row above the calendar on a phone, a column beside it
            // from sm up — the shortcuts are the fast path and stay reachable.
            className="flex shrink-0 flex-row flex-wrap gap-0.5 border-b border-border p-2 sm:w-32 sm:flex-col sm:border-e sm:border-b-0"
          >
            {presets.map((preset) => (
              <Button
                key={preset.key}
                type="button"
                variant="ghost"
                size="sm"
                className="justify-start font-normal"
                // The clock is read here, on the press, and never in render —
                // unless the caller counts from a date of its own.
                onClick={() => commit(getPresetRange(preset.key, now ?? new Date()), true)}
              >
                {preset.label}
              </Button>
            ))}
          </div>
        ) : null}

        <Calendar
          {...calendarProps}
          mode="range"
          autoFocus
          numberOfMonths={numberOfMonths}
          selected={selected?.from ? { from: selected.from, to: selected.to } : undefined}
          // A range takes two presses, so the panel stays open until the caller
          // closes it or the reader clicks away.
          onSelect={(range) => commit(range ? { from: range.from, to: range.to } : undefined, false)}
          defaultMonth={selected?.from}
          startMonth={min}
          endMonth={max}
          disabled={outOfRange}
        />
      </PopoverContent>
    </Popover>
  )
}

export { DateRangePicker }