Search

Jump to any page.

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-picker
Brings 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 }