← Dashboard
Zubeyr··Componentsinputsdate

Date Range Picker

An analytics-style range picker: presets, typed start and end dates, two months side by side, hover preview, keyboard navigation and Apply/Cancel.

Live demo ↗

Installation

Run the shadcn CLI in a project that already has components.json. Using the @tomei tab? Register the namespace once first.

pnpm dlx shadcn@latest add https://tomei.zubeyr.dev/ui/r/date-range-picker.json
  • →Files land in components/dashboard/date-range-picker.

Usage

example.tsx
'use client'

import { useState } from 'react'

import { DateRangePicker, type DateRange } from '@/components/dashboard/date-range-picker/date-range-picker'

const format = new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' })

export default function Demo() {
  const [range, setRange] = useState<{ value: DateRange; preset?: string } | null>(null)

  return (
    <div className="flex min-h-[34rem] w-full max-w-3xl flex-col items-start gap-4">
      <DateRangePicker disableFuture onChange={(value, preset) => setRange({ value, preset })} />
      <p className="text-sm text-muted-foreground">
        {range
          ? `${range.preset ?? 'Custom'}: ${format.format(range.value.start)} → ${format.format(range.value.end)}`
          : 'Pick a range and hit Apply.'}
      </p>
    </div>
  )
}

Source code

date-range-picker.tsx
'use client'

import { useEffect, useRef, useState } from 'react'

import { cn } from '@/lib/utils'

import { CalendarMonth, monthTitle } from './calendar-month'
import {
  addDays,
  defaultPresets,
  formatRange,
  fromInputValue,
  sameMonth,
  sameRange,
  startOfDay,
  startOfMonth,
  toInputValue,
  type DatePreset,
  type DateRange,
} from './dates'

export type { DatePreset, DateRange }
export { addDays, defaultPresets, startOfDay }

type DateRangePickerProps = {
  value?: DateRange
  defaultValue?: DateRange
  onChange?: (range: DateRange, preset?: string) => void
  presets?: DatePreset[]
  maxDate?: Date
  disableFuture?: boolean
  align?: 'start' | 'end'
  className?: string
}

const keyOffsets: Record<string, number> = {
  ArrowLeft: -1,
  ArrowRight: 1,
  ArrowUp: -7,
  ArrowDown: 7,
}

export function DateRangePicker({
  value,
  defaultValue,
  onChange,
  presets = defaultPresets,
  maxDate: maxDateProp,
  disableFuture = false,
  align = 'start',
  className,
}: DateRangePickerProps) {
  const [today] = useState(() => startOfDay(new Date()))
  const maxDate = disableFuture ? today : maxDateProp
  const [internal, setInternal] = useState<DateRange>(
    () => defaultValue ?? presets[3]?.range(today) ?? { start: today, end: today },
  )
  const committed = value ?? internal

  const [open, setOpen] = useState(false)
  const [start, setStart] = useState<Date | null>(committed.start)
  const [end, setEnd] = useState<Date | null>(committed.end)
  const [preview, setPreview] = useState<Date | null>(null)
  const [view, setView] = useState(() => startOfMonth(committed.end, -1))
  const [focused, setFocused] = useState(committed.end)
  const [inputs, setInputs] = useState({ start: '', end: '' })

  const root = useRef<HTMLDivElement>(null)
  const trigger = useRef<HTMLButtonElement>(null)
  const grid = useRef<HTMLDivElement>(null)
  const keyboard = useRef(false)

  const presetFor = (range: DateRange) => presets.find((p) => sameRange(p.range(today), range))
  const draft = start && end ? { start, end } : null
  const activePreset = draft ? presetFor(draft)?.label : undefined
  const committedPreset = presetFor(committed)?.label

  const show = (range: { start: Date | null; end: Date | null }) => {
    setStart(range.start)
    setEnd(range.end)
    setInputs({
      start: range.start ? toInputValue(range.start) : '',
      end: range.end ? toInputValue(range.end) : '',
    })
  }

  const reveal = (date: Date) => {
    if (!sameMonth(date, view) && !sameMonth(date, startOfMonth(view, 1)))
      setView(startOfMonth(date, -1))
  }

  const openPicker = () => {
    show(committed)
    setView(startOfMonth(committed.end, -1))
    setFocused(committed.end)
    setOpen(true)
  }

  const close = () => {
    setOpen(false)
    setPreview(null)
    trigger.current?.focus()
  }

  const apply = () => {
    if (!start) return
    const range = { start, end: end ?? start }
    if (value === undefined) setInternal(range)
    onChange?.(range, presetFor(range)?.label)
    close()
  }

  const select = (date: Date) => {
    setFocused(date)
    if (!start || end) return show({ start: date, end: null })
    show(date < start ? { start: date, end: start } : { start, end: date })
  }

  const choosePreset = (preset: DatePreset) => {
    const range = preset.range(today)
    show(range)
    setView(startOfMonth(range.end, -1))
    setFocused(range.end)
  }

  const commitInput = (key: 'start' | 'end') => {
    const date = fromInputValue(inputs[key])
    if (!date || (maxDate && date > maxDate)) return show({ start, end })
    const next =
      key === 'start' ? { start: date, end: end ?? date } : { start: start ?? date, end: date }
    show(next.start <= next.end ? next : { start: next.end, end: next.start })
    reveal(date)
    setFocused(date)
  }

  const onDayKeyDown = (event: React.KeyboardEvent, date: Date) => {
    const offset = keyOffsets[event.key]
    if (offset === undefined) return
    event.preventDefault()
    const next = addDays(date, offset)
    keyboard.current = true
    setFocused(next)
    reveal(next)
    if (start && !end) setPreview(next)
  }

  useEffect(() => {
    if (!keyboard.current) return
    keyboard.current = false
    grid.current?.querySelector<HTMLButtonElement>(`[data-date="${focused.getTime()}"]`)?.focus()
  }, [focused, view])

  useEffect(() => {
    if (!open) return
    const onPointer = (event: PointerEvent) => {
      if (!root.current?.contains(event.target as Node)) setOpen(false)
    }
    const onKey = (event: KeyboardEvent) => event.key === 'Escape' && close()
    document.addEventListener('pointerdown', onPointer)
    document.addEventListener('keydown', onKey)
    return () => {
      document.removeEventListener('pointerdown', onPointer)
      document.removeEventListener('keydown', onKey)
    }
  })

  const month = (offset: number, header: React.ReactNode) => (
    <CalendarMonth
      month={startOfMonth(view, offset)}
      start={start}
      end={end}
      preview={start && !end ? preview : null}
      focused={focused}
      today={today}
      maxDate={maxDate}
      onSelect={select}
      onHover={setPreview}
      onKeyDown={onDayKeyDown}
      header={header}
    />
  )

  const nav = (direction: -1 | 1, label: string) => (
    <button
      type="button"
      aria-label={label}
      onClick={() => setView(startOfMonth(view, direction))}
      className="flex size-8 items-center justify-center rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground"
    >
      <svg viewBox="0 0 16 16" fill="none" className="size-4" aria-hidden>
        <path
          d={direction < 0 ? 'M10 3 5 8l5 5' : 'm6 3 5 5-5 5'}
          stroke="currentColor"
          strokeWidth="1.6"
          strokeLinecap="round"
          strokeLinejoin="round"
        />
      </svg>
    </button>
  )

  const title = (offset: number) => (
    <span className="flex-1 text-center text-sm font-semibold">
      {monthTitle(startOfMonth(view, offset))}
    </span>
  )

  return (
    <div ref={root} className={cn('relative inline-flex', className)}>
      <button
        ref={trigger}
        type="button"
        aria-haspopup="dialog"
        aria-expanded={open}
        onClick={() => (open ? close() : openPicker())}
        className="inline-flex h-8 items-center gap-1.5 rounded-lg bg-card px-3 text-sm font-medium text-foreground shadow-(--shadow-button) transition-colors outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring/60"
      >
        <svg viewBox="0 0 16 16" fill="none" className="size-3.5" aria-hidden>
          <rect x="2" y="3" width="12" height="11" rx="2" stroke="currentColor" strokeWidth="1.4" />
          <path
            d="M2 6.5h12M5.5 1.5v3M10.5 1.5v3"
            stroke="currentColor"
            strokeWidth="1.4"
            strokeLinecap="round"
          />
        </svg>
        {committedPreset ?? formatRange(committed)}
      </button>

      {open && (
        <div
          role="dialog"
          aria-label="Choose a date range"
          className={cn(
            'absolute top-full z-50 mt-2 flex max-w-[calc(100vw-2rem)] overflow-hidden rounded-xl bg-popover text-popover-foreground shadow-(--shadow-overlay)',
            align === 'end' ? 'right-0' : 'left-0',
          )}
        >
          <ul className="hidden w-44 shrink-0 flex-col gap-px overflow-y-auto border-r p-2 md:flex">
            {presets.map((preset) => (
              <li key={preset.label}>
                <button
                  type="button"
                  onClick={() => choosePreset(preset)}
                  aria-pressed={activePreset === preset.label}
                  className={cn(
                    'flex h-8 w-full items-center rounded-lg px-2.5 text-left text-sm hover:bg-muted',
                    activePreset === preset.label && 'bg-muted font-medium',
                  )}
                >
                  {preset.label}
                </button>
              </li>
            ))}
          </ul>

          <div className="flex flex-col gap-4 p-4">
            <div className="flex flex-wrap gap-1.5 md:hidden">
              {presets.map((preset) => (
                <button
                  key={preset.label}
                  type="button"
                  aria-pressed={activePreset === preset.label}
                  onClick={() => choosePreset(preset)}
                  className={cn(
                    'h-7 rounded-full border px-3 text-xs font-medium',
                    activePreset === preset.label
                      ? 'border-transparent bg-primary text-primary-foreground'
                      : 'hover:bg-muted',
                  )}
                >
                  {preset.label}
                </button>
              ))}
            </div>

            <div className="flex items-center gap-2">
              {(['start', 'end'] as const).map((key, i) => (
                <div key={key} className="contents">
                  {i === 1 && <span className="text-muted-foreground">→</span>}
                  <input
                    aria-label={key === 'start' ? 'Start date' : 'End date'}
                    placeholder="YYYY-MM-DD"
                    value={inputs[key]}
                    onChange={(e) => setInputs({ ...inputs, [key]: e.target.value })}
                    onBlur={() => commitInput(key)}
                    onKeyDown={(e) => e.key === 'Enter' && commitInput(key)}
                    className="h-8 w-full min-w-0 rounded-lg border border-input bg-card px-2.5 text-sm tabular-nums outline-none focus:border-ring"
                  />
                </div>
              ))}
            </div>

            <div ref={grid} className="flex gap-6">
              {month(
                0,
                <>
                  {nav(-1, 'Previous month')}
                  {title(0)}
                  <span className="size-8 max-md:hidden" />
                  <span className="md:hidden">{nav(1, 'Next month')}</span>
                </>,
              )}
              <div className="max-md:hidden">
                {month(
                  1,
                  <>
                    <span className="size-8" />
                    {title(1)}
                    {nav(1, 'Next month')}
                  </>,
                )}
              </div>
            </div>

            <div className="flex items-center justify-between gap-2 border-t pt-4">
              <span className="text-xs text-muted-foreground">
                {draft ? formatRange(draft) : 'Select an end date'}
              </span>
              <div className="flex gap-2">
                <button
                  type="button"
                  onClick={close}
                  className="h-8 rounded-lg bg-card px-3 text-sm font-medium shadow-(--shadow-button) hover:bg-muted"
                >
                  Cancel
                </button>
                <button
                  type="button"
                  onClick={apply}
                  disabled={!draft}
                  className="h-8 rounded-lg bg-primary px-3 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50"
                >
                  Apply
                </button>
              </div>
            </div>
          </div>
        </div>
      )}
    </div>
  )
}
calendar-month.tsx
import { cn } from '@/lib/utils'

import { addDays, sameDay } from './dates'

const formats = new Map<
  string,
  { weekdays: string[]; month: Intl.DateTimeFormat; label: Intl.DateTimeFormat }
>()
function formatsFor(locale = 'en-US') {
  let f = formats.get(locale)
  if (!f) {
    const day = new Intl.DateTimeFormat(locale, { weekday: 'short' })
    f = {
      weekdays: Array.from({ length: 7 }, (_, i) =>
        day.format(new Date(2024, 0, 7 + i)).slice(0, 2),
      ),
      month: new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' }),
      label: new Intl.DateTimeFormat(locale, { dateStyle: 'full' }),
    }
    formats.set(locale, f)
  }
  return f
}

type CalendarMonthProps = {
  month: Date
  start: Date | null
  end: Date | null
  preview: Date | null
  focused: Date
  today: Date
  minDate?: Date
  maxDate?: Date
  onSelect: (date: Date) => void
  onHover: (date: Date | null) => void
  onKeyDown: (event: React.KeyboardEvent, date: Date) => void
  header?: React.ReactNode
  locale?: string
}

export function CalendarMonth({
  month,
  start,
  end,
  preview,
  focused,
  today,
  minDate,
  maxDate,
  onSelect,
  onHover,
  onKeyDown,
  header,
  locale,
}: CalendarMonthProps) {
  const { weekdays, month: monthFormat, label: labelFormat } = formatsFor(locale)
  const first = new Date(month.getFullYear(), month.getMonth(), 1)
  const length = new Date(month.getFullYear(), month.getMonth() + 1, 0).getDate()
  const days = Array.from({ length }, (_, i) => addDays(first, i))
  const rangeEnd = end ?? preview
  const [from, to] =
    start && rangeEnd ? (start <= rangeEnd ? [start, rangeEnd] : [rangeEnd, start]) : [start, start]

  return (
    <div className="flex w-[15.75rem] flex-col gap-3">
      <div className="flex h-8 items-center justify-between">{header}</div>
      <p className="sr-only">{monthFormat.format(month)}</p>
      <div role="grid" className="grid grid-cols-7 gap-y-1" onMouseLeave={() => onHover(null)}>
        {weekdays.map((d) => (
          <span key={d} className="py-1 text-center text-xs text-muted-foreground">
            {d}
          </span>
        ))}
        {Array.from({ length: first.getDay() }, (_, i) => (
          <span key={`blank-${i}`} />
        ))}
        {days.map((day) => {
          const disabled = (!!maxDate && day > maxDate) || (!!minDate && day < minDate)
          const isStart = sameDay(day, from)
          const isEnd = sameDay(day, to)
          const inRange = !!from && !!to && day >= from && day <= to && !sameDay(from, to)

          return (
            <div
              key={day.getDate()}
              className={cn(
                inRange && 'bg-muted',
                inRange && isStart && 'rounded-l-full',
                inRange && isEnd && 'rounded-r-full',
              )}
            >
              <button
                type="button"
                data-date={day.getTime()}
                tabIndex={sameDay(day, focused) ? 0 : -1}
                disabled={disabled}
                aria-label={labelFormat.format(day)}
                aria-pressed={isStart || isEnd}
                onClick={() => onSelect(day)}
                onMouseEnter={() => onHover(day)}
                onKeyDown={(event) => onKeyDown(event, day)}
                className={cn(
                  'mx-auto flex size-9 items-center justify-center rounded-full text-sm tabular-nums outline-none focus-visible:ring-2 focus-visible:ring-ring/60 disabled:cursor-not-allowed disabled:opacity-30',
                  isStart || isEnd
                    ? 'bg-primary text-primary-foreground'
                    : 'hover:bg-muted hover:ring-1 hover:ring-border',
                  sameDay(day, today) && 'font-bold',
                )}
              >
                {day.getDate()}
              </button>
            </div>
          )
        })}
      </div>
    </div>
  )
}

export const monthTitle = (month: Date, locale?: string) => formatsFor(locale).month.format(month)
dates.ts
export type DateRange = { start: Date; end: Date }
export type DatePreset = { label: string; range: (today: Date) => DateRange }

export const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate())
export const addDays = (d: Date, n: number) =>
  new Date(d.getFullYear(), d.getMonth(), d.getDate() + n)
export const startOfMonth = (d: Date, offset = 0) =>
  new Date(d.getFullYear(), d.getMonth() + offset, 1)
export const sameDay = (a?: Date | null, b?: Date | null) =>
  !!a && !!b && a.getTime() === b.getTime()
export const sameMonth = (a: Date, b: Date) =>
  a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth()
export const sameRange = (a: DateRange, b: DateRange) =>
  sameDay(a.start, b.start) && sameDay(a.end, b.end)

const pad = (n: number) => String(n).padStart(2, '0')

export const toInputValue = (d: Date) =>
  `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`

export function fromInputValue(value: string) {
  const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value.trim())
  if (!match) return null
  const [y, m, d] = match.slice(1).map(Number)
  const date = new Date(y, m - 1, d)
  return date.getMonth() === m - 1 ? date : null
}

const dayFormat = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric' })
const fullFormat = new Intl.DateTimeFormat('en-US', {
  month: 'short',
  day: 'numeric',
  year: 'numeric',
})

export function formatRange({ start, end }: DateRange) {
  if (sameDay(start, end)) return fullFormat.format(start)
  if (sameMonth(start, end))
    return `${dayFormat.format(start)}–${fullFormat.format(end).replace(/^\w+ /, '')}`
  return start.getFullYear() === end.getFullYear()
    ? `${dayFormat.format(start)}–${fullFormat.format(end)}`
    : `${fullFormat.format(start)}–${fullFormat.format(end)}`
}

export const defaultPresets: DatePreset[] = [
  { label: 'Today', range: (t) => ({ start: t, end: t }) },
  { label: 'Yesterday', range: (t) => ({ start: addDays(t, -1), end: addDays(t, -1) }) },
  { label: 'Last 7 days', range: (t) => ({ start: addDays(t, -6), end: t }) },
  { label: 'Last 30 days', range: (t) => ({ start: addDays(t, -29), end: t }) },
  { label: 'Last 90 days', range: (t) => ({ start: addDays(t, -89), end: t }) },
  { label: 'Last 365 days', range: (t) => ({ start: addDays(t, -364), end: t }) },
  {
    label: 'Last month',
    range: (t) => ({ start: startOfMonth(t, -1), end: addDays(startOfMonth(t), -1) }),
  },
  {
    label: 'Last 12 months',
    range: (t) => ({ start: startOfMonth(t, -12), end: addDays(startOfMonth(t), -1) }),
  },
  {
    label: 'Last year',
    range: (t) => ({
      start: new Date(t.getFullYear() - 1, 0, 1),
      end: new Date(t.getFullYear() - 1, 11, 31),
    }),
  },
  { label: 'Week to date', range: (t) => ({ start: addDays(t, -t.getDay()), end: t }) },
  { label: 'Month to date', range: (t) => ({ start: startOfMonth(t), end: t }) },
  {
    label: 'Quarter to date',
    range: (t) => ({
      start: new Date(t.getFullYear(), t.getMonth() - (t.getMonth() % 3), 1),
      end: t,
    }),
  },
  { label: 'Year to date', range: (t) => ({ start: new Date(t.getFullYear(), 0, 1), end: t }) },
]