Components

Date Range Picker

A two-month range picker with preset shortcuts, draft state committed on Apply, and a responsive single-month mobile layout.

input

Installation

npx cano-ui add date-range-picker

Installs button, calendar, popover, separator, date-fns, lucide-react, react-day-picker automatically if missing.

Source

"use client"

import * as React from "react"
import { endOfMonth, format, isSameDay, startOfMonth, subDays } from "date-fns"
import { Calendar as CalendarIcon } from "lucide-react"
import type { DateRange } from "react-day-picker"

import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Calendar } from "@/components/ui/calendar"
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover"
import { Separator } from "@/components/ui/separator"

export interface DateRangePickerProps {
  value?: DateRange
  onChange?: (range: DateRange | undefined) => void
  align?: "start" | "end"
  placeholder?: string
  className?: string
}

interface Preset {
  label: string
  getRange: () => DateRange
}

function getPresets(): Preset[] {
  const today = new Date()

  return [
    { label: "Today", getRange: () => ({ from: today, to: today }) },
    {
      label: "Yesterday",
      getRange: () => ({ from: subDays(today, 1), to: subDays(today, 1) }),
    },
    {
      label: "Last 7 days",
      getRange: () => ({ from: subDays(today, 6), to: today }),
    },
    {
      label: "Last 30 days",
      getRange: () => ({ from: subDays(today, 29), to: today }),
    },
    {
      label: "This month",
      getRange: () => ({ from: startOfMonth(today), to: endOfMonth(today) }),
    },
  ]
}

function rangesMatch(a: DateRange | undefined, b: DateRange): boolean {
  return (
    !!a?.from &&
    !!a.to &&
    !!b.from &&
    !!b.to &&
    isSameDay(a.from, b.from) &&
    isSameDay(a.to, b.to)
  )
}

function useIsMobile(): boolean {
  const [isMobile, setIsMobile] = React.useState(false)

  React.useEffect(() => {
    const query = window.matchMedia("(max-width: 767px)")
    const update = () => setIsMobile(query.matches)

    update()
    query.addEventListener("change", update)
    return () => query.removeEventListener("change", update)
  }, [])

  return isMobile
}

export function DateRangePicker({
  value,
  onChange,
  align = "start",
  placeholder = "Pick a date range",
  className,
}: DateRangePickerProps) {
  const [open, setOpen] = React.useState(false)
  const [draft, setDraft] = React.useState<DateRange | undefined>(value)
  const isMobile = useIsMobile()
  const presets = React.useMemo(getPresets, [open])

  const activePreset =
    presets.find((preset) => rangesMatch(draft, preset.getRange()))?.label ??
    (draft?.from ? "Custom" : undefined)

  const handleOpenChange = (next: boolean) => {
    if (next) {
      setDraft(value)
    }
    setOpen(next)
  }

  const handleApply = () => {
    onChange?.(draft)
    setOpen(false)
  }

  const presetButtons = (
    <>
      {presets.map((preset) => (
        <Button
          key={preset.label}
          variant="ghost"
          size="sm"
          className={cn(
            "shrink-0 justify-start font-normal",
            activePreset === preset.label && "bg-accent text-accent-foreground"
          )}
          onClick={() => setDraft(preset.getRange())}
        >
          {preset.label}
        </Button>
      ))}
      <Button
        variant="ghost"
        size="sm"
        className={cn(
          "shrink-0 justify-start font-normal",
          activePreset === "Custom" && "bg-accent text-accent-foreground"
        )}
        onClick={() => setDraft(undefined)}
      >
        Custom
      </Button>
    </>
  )

  return (
    <Popover open={open} onOpenChange={handleOpenChange}>
      <PopoverTrigger asChild>
        <Button
          variant="outline"
          className={cn(
            "justify-start text-left font-normal",
            !value?.from && "text-muted-foreground",
            className
          )}
        >
          <CalendarIcon className="size-4" />
          {value?.from ? (
            value.to ? (
              <>
                {format(value.from, "LLL d, y")} –{" "}
                {format(value.to, "LLL d, y")}
              </>
            ) : (
              format(value.from, "LLL d, y")
            )
          ) : (
            placeholder
          )}
        </Button>
      </PopoverTrigger>
      <PopoverContent className="w-auto p-0" align={align}>
        <div className="flex flex-col md:flex-row">
          {isMobile ? (
            <div className="flex gap-1 overflow-x-auto border-b p-2">
              {presetButtons}
            </div>
          ) : (
            <div className="flex flex-col gap-1 p-2">{presetButtons}</div>
          )}
          {!isMobile ? (
            <Separator orientation="vertical" className="h-auto" />
          ) : null}
          <Calendar
            mode="range"
            numberOfMonths={isMobile ? 1 : 2}
            selected={draft}
            onSelect={setDraft}
            defaultMonth={draft?.from ?? value?.from}
          />
        </div>
        <div className="flex items-center justify-end gap-2 border-t p-2">
          <Button
            variant="ghost"
            size="sm"
            onClick={() => setDraft(undefined)}
          >
            Clear
          </Button>
          <Button size="sm" onClick={handleApply}>
            Apply
          </Button>
        </div>
      </PopoverContent>
    </Popover>
  )
}

This exact file lands in your project at components/ui/date-range-picker.tsx. It is yours to edit.