Zubeyr··ComponentsinputsdateDate 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
'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
'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>
)
}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)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 }) },
]