Forms
Date range picker
Range filter with presets beside a two-month calendar; the trigger names the preset in use.
pnpm dlx shadcn@latest add @paragon/date-range-pickerBrings in@paragon/button@paragon/calendar@paragon/popover@paragon/use-mobilelucide-reactcn
Arabic
Pass presets and labels in Arabic; dates, digits and the day count follow the locale.
registry/paragon/ui/date-range-picker.tsx
"use client"
import * as React from "react"
import { useDirection } from "@base-ui/react/direction-provider"
import { CalendarDays } from "lucide-react"
import { cn } from "cn"
import { useIsMobile } from "@/registry/paragon/hooks/use-mobile"
import { Button } from "@/registry/paragon/ui/button"
import { Calendar, type DateRange } from "@/registry/paragon/ui/calendar"
import { Popover, PopoverContent, PopoverTrigger } from "@/registry/paragon/ui/popover"
type CompleteDateRange = { from: Date; to: Date }
type DateRangePreset = {
label: string
/** The range, relative to today. */
range: (today: Date) => CompleteDateRange
}
const shift = (date: Date, days: number) => new Date(date.getFullYear(), date.getMonth(), date.getDate() + days)
const dateRangePresets: DateRangePreset[] = [
{ label: "Today", range: (today) => ({ from: today, to: today }) },
{ label: "Yesterday", range: (today) => ({ from: shift(today, -1), to: shift(today, -1) }) },
{ label: "Last 7 days", range: (today) => ({ from: shift(today, -6), to: today }) },
{ label: "Last 30 days", range: (today) => ({ from: shift(today, -29), to: today }) },
{ label: "This month", range: (today) => ({ from: new Date(today.getFullYear(), today.getMonth(), 1), to: today }) },
{
label: "This quarter",
range: (today) => ({ from: new Date(today.getFullYear(), Math.floor(today.getMonth() / 3) * 3, 1), to: today }),
},
{ label: "Year to date", range: (today) => ({ from: new Date(today.getFullYear(), 0, 1), to: today }) },
]
const sameDay = (a: Date, b: Date) =>
a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
const sameRange = (a: CompleteDateRange, b: DateRange | null | undefined) =>
b?.to != null && sameDay(a.from, b.from) && sameDay(a.to, b.to)
const startOfToday = () => {
const now = new Date()
return new Date(now.getFullYear(), now.getMonth(), now.getDate())
}
// Presets are relative to the reader's today, which only the browser knows.
const subscribeToNothing = () => () => {}
function useTodayKey() {
return React.useSyncExternalStore(subscribeToNothing, () => startOfToday().getTime(), () => null)
}
/**
* A range filter for dashboards. The trigger reads as a sentence, naming the
* preset when the range is one. Presets apply at once; a range drawn on the
* calendar applies with Apply, so a filter isn't refetched on every click.
*/
function DateRangePicker({
value,
onValueChange,
presets = dateRangePresets,
locale = "en-GB",
hijri = false,
min,
max,
placeholder = "Any time",
labels,
align = "start",
className,
}: {
value?: CompleteDateRange | null
onValueChange?: (value: CompleteDateRange) => void
presets?: DateRangePreset[]
locale?: string
hijri?: boolean
min?: Date
max?: Date
placeholder?: React.ReactNode
labels?: Partial<Record<"apply" | "cancel" | "pickEnd" | "presets" | "dialog" | "previousMonth" | "nextMonth", string>>
align?: "start" | "center" | "end"
className?: string
}) {
const [open, setOpen] = React.useState(false)
const [draft, setDraft] = React.useState<DateRange | null>(value ?? null)
const isMobile = useIsMobile()
const todayKey = useTodayKey()
const direction = useDirection()
const text = {
apply: "Apply",
cancel: "Cancel",
pickEnd: "Pick an end date",
presets: "Presets",
dialog: "Choose dates",
...labels,
}
const formats = React.useMemo(
() => ({
range: new Intl.DateTimeFormat(locale, { calendar: "gregory", day: "numeric", month: "short", year: "numeric" }),
days: new Intl.NumberFormat(locale, { style: "unit", unit: "day", unitDisplay: "long" }),
}),
[locale]
)
const formatRange = (range: CompleteDateRange) => formats.range.formatRange(range.from, range.to)
const preset = value && todayKey != null ? presets.find((item) => sameRange(item.range(new Date(todayKey)), value)) : undefined
function apply(range: CompleteDateRange) {
onValueChange?.(range)
setOpen(false)
}
const today = startOfToday()
const complete = draft?.to ? { from: draft.from, to: draft.to } : null
const months = isMobile ? 1 : 2
// Two months end on the month in question, so recent days sit on the right, where the eye ends.
const anchor = value?.to ?? today
const defaultMonth = new Date(anchor.getFullYear(), anchor.getMonth() - (months - 1), 1)
return (
<Popover
open={open}
onOpenChange={(next) => {
setOpen(next)
if (next) setDraft(value ?? null)
}}
>
<PopoverTrigger
render={
<Button
variant="outline"
data-slot="date-range-picker"
className={cn("justify-start gap-2 font-normal", className)}
/>
}
>
<CalendarDays data-icon="inline-start" className="text-muted-foreground" />
{value ? (
<span className="flex min-w-0 items-baseline gap-2">
{preset ? <span className="font-medium">{preset.label}</span> : null}
{/* Server and browser ICU can space a range's dash differently; React keeps the server's until the next render. */}
<span className={cn("truncate tabular-nums", preset && "text-muted-foreground")} suppressHydrationWarning>
{formatRange(value)}
</span>
</span>
) : (
<span className="text-muted-foreground">{placeholder}</span>
)}
</PopoverTrigger>
{/* The popup is portalled out of the page, so it takes the direction from context rather than the DOM. */}
<PopoverContent
align={align}
dir={direction}
aria-label={text.dialog}
className="w-auto max-w-[calc(100vw-2rem)] gap-0 p-0"
>
<div className="flex flex-col sm:flex-row">
<div
role="group"
aria-label={text.presets}
className="flex shrink-0 gap-1 overflow-x-auto border-b p-2 sm:w-44 sm:flex-col sm:overflow-visible sm:border-e sm:border-b-0"
>
{presets.map((item) => {
const range = item.range(today)
const active = sameRange(range, draft)
return (
<button
key={item.label}
type="button"
aria-pressed={active}
data-active={active || undefined}
onClick={() => apply(range)}
className="shrink-0 rounded-[calc(var(--radius-button)-4px)] px-3 py-2 text-start text-sm whitespace-nowrap text-foreground/70 transition-colors duration-(--duration-fast) ease-paragon outline-none hover:bg-foreground/5 hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ring focus-visible:outline-solid data-active:bg-secondary data-active:font-medium data-active:text-foreground"
>
{item.label}
</button>
)
})}
</div>
<div className="flex min-w-0 flex-col">
<Calendar
mode="range"
value={draft}
onValueChange={setDraft}
numberOfMonths={months}
defaultMonth={defaultMonth}
locale={locale}
hijri={hijri}
min={min}
max={max}
previousLabel={labels?.previousMonth}
nextLabel={labels?.nextMonth}
className="p-4"
/>
<div className="flex flex-wrap items-center gap-2 border-t px-4 py-3">
<p aria-live="polite" className="me-auto min-h-5 basis-full text-sm text-muted-foreground tabular-nums sm:basis-auto sm:truncate">
{complete ? (
<>
<span className="text-foreground">{formatRange(complete)}</span>
{" · "}
{formats.days.format(Math.round((complete.to.getTime() - complete.from.getTime()) / 86_400_000) + 1)}
</>
) : draft ? (
text.pickEnd
) : null}
</p>
<Button variant="ghost" size="sm" className="ms-auto" onClick={() => setOpen(false)}>
{text.cancel}
</Button>
<Button size="sm" disabled={!complete} onClick={() => complete && apply(complete)}>
{text.apply}
</Button>
</div>
</div>
</div>
</PopoverContent>
</Popover>
)
}
export { DateRangePicker, dateRangePresets, type CompleteDateRange, type DateRangePreset }