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-pickerNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import { DateRangePicker, type DateRange } from "@/components/ui/date-range-picker"
export default function DateRangePickerDemo() {
const [range, setRange] = React.useState<DateRange | undefined>({
from: new Date(2026, 7, 6),
to: new Date(2026, 8, 4),
})
return (
<div className="flex w-full max-w-sm flex-col gap-2">
<span className="text-sm font-medium">Reporting period</span>
<DateRangePicker aria-label="Reporting period" value={range} onValueChange={setRange} />
</div>
)
}Custom presets and one month
A shortened preset column, and a small picker with none at all.
"use client"
import * as React from "react"
import {
DateRangePicker,
dateRangePresets,
type DateRange,
} from "@/components/ui/date-range-picker"
// Only the windows a finance review actually uses, in the order it uses them.
const CLOSE_PRESETS = dateRangePresets.filter((preset) =>
["thisMonth", "lastMonth", "thisYear"].includes(preset.key)
)
export default function DateRangePickerPresets() {
const [range, setRange] = React.useState<DateRange | undefined>()
const [single, setSingle] = React.useState<DateRange | undefined>({
from: new Date(2026, 8, 1),
to: new Date(2026, 8, 4),
})
return (
<div className="flex w-full max-w-sm flex-col gap-4">
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">Close period</span>
<DateRangePicker
aria-label="Close period"
value={range}
onValueChange={setRange}
presets={CLOSE_PRESETS}
placeholder="All time"
/>
</div>
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">One month, no presets</span>
<DateRangePicker
aria-label="Comparison window"
value={single}
onValueChange={setSingle}
presets={false}
numberOfMonths={1}
size="sm"
/>
</div>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| 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 }[] | false | dateRangePresets | Keys getPresetRange knows; false drops the column entirely. |
| numberOfMonths | 1 | 2 | 2 | How 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. |
| placeholder | string | "Pick a date range" | Stands in for the label while no window is chosen. |
| disabled | boolean | false | Dims the trigger and stops it opening. |
| min | Date | — | Earliest selectable day; earlier months cannot be reached. |
| max | Date | — | Latest selectable day; later months cannot be reached. |
| aria-label | string | — | Names the trigger, e.g. Reporting period. |
| now | Date | — | 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. |
| calendar | PickerCalendarProps | — | 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. |
| defaultOpen | boolean | false | Opens 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. |
| className | string | — | Merged onto the trigger, which is the root; the remaining button props are spread onto it too. |
Dependencies
Source
"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 }