Date Picker
A popover calendar for single dates or ranges, with min/max, disabled days and full keyboard navigation.
npx shadcn@latest add https://sundaeui.dev/r/date-picker.json'use client'
import { useEffect, useId, useMemo, useRef, useState } from 'react'
export type DateRange = { from: Date; to: Date | null }
export interface DatePickerProps {
mode?: 'single' | 'range'
value?: Date | DateRange | null
onChange?: (v: Date | DateRange | null) => void
min?: Date
max?: Date
/** Return true to disable a day, e.g. weekends. */
isDisabled?: (d: Date) => boolean
label?: string
placeholder?: string
locale?: string
/** 0 = Sunday, 1 = Monday. */
weekStartsOn?: 0 | 1
/** Open on mount (useful for inline demos). */
defaultOpen?: boolean
}
const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate())
const same = (a?: Date | null, b?: Date | null) => !!a && !!b && a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
const addDays = (d: Date, n: number) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n)
const addMonths = (d: Date, n: number) => {
const t = new Date(d.getFullYear(), d.getMonth() + n, 1)
return new Date(t.getFullYear(), t.getMonth(), Math.min(d.getDate(), new Date(t.getFullYear(), t.getMonth() + 1, 0).getDate()))
}
/** Popover date picker with a fully keyboard-navigable calendar grid. */
export function DatePicker({ mode = 'single', value = null, onChange, min, max, isDisabled, label = 'Date', placeholder = 'Pick a date', locale = 'en-US', weekStartsOn = 1, defaultOpen = false }: DatePickerProps) {
const id = useId()
const [open, setOpen] = useState(defaultOpen)
const initial = value instanceof Date ? value : value?.from ?? new Date()
const [focus, setFocus] = useState(startOfDay(initial))
const [hover, setHover] = useState<Date | null>(null)
const root = useRef<HTMLDivElement>(null)
const grid = useRef<HTMLDivElement>(null)
const trigger = useRef<HTMLButtonElement>(null)
const blocked = (d: Date) => (min && d < startOfDay(min)) || (max && d > startOfDay(max)) || !!isDisabled?.(d)
useEffect(() => {
if (!open) return
const onDown = (e: PointerEvent) => !root.current?.contains(e.target as Node) && setOpen(false)
document.addEventListener('pointerdown', onDown)
return () => document.removeEventListener('pointerdown', onDown)
}, [open])
const opened = useRef(false)
useEffect(() => {
// Move focus into the grid only after a user-initiated open, never on mount.
if (open && opened.current) grid.current?.querySelector<HTMLButtonElement>('[data-focus="true"]')?.focus()
}, [open, focus])
const weeks = useMemo(() => {
const first = new Date(focus.getFullYear(), focus.getMonth(), 1)
const offset = (first.getDay() - weekStartsOn + 7) % 7
const start = addDays(first, -offset)
return Array.from({ length: 6 }, (_, w) => Array.from({ length: 7 }, (_, d) => addDays(start, w * 7 + d)))
}, [focus, weekStartsOn])
const weekdayNames = useMemo(() => Array.from({ length: 7 }, (_, i) => new Intl.DateTimeFormat(locale, { weekday: 'short' }).format(new Date(2024, 0, 7 + weekStartsOn + i))), [locale, weekStartsOn])
const fmt = (d: Date) => new Intl.DateTimeFormat(locale, { month: 'short', day: 'numeric', year: 'numeric' }).format(d)
const range = mode === 'range' && value && !(value instanceof Date) ? value : null
const single = mode === 'single' && value instanceof Date ? value : null
const inRange = (d: Date) => {
if (!range) return false
const end = range.to ?? hover
if (!end) return false
const [a, b] = range.from <= end ? [range.from, end] : [end, range.from]
return d >= startOfDay(a) && d <= startOfDay(b)
}
const pick = (d: Date) => {
if (blocked(d)) return
if (mode === 'single') {
onChange?.(d)
setOpen(false)
trigger.current?.focus()
return
}
if (!range || range.to) onChange?.({ from: d, to: null })
else {
const [from, to] = d < range.from ? [d, range.from] : [range.from, d]
onChange?.({ from, to })
setOpen(false)
trigger.current?.focus()
}
}
const onKey = (e: React.KeyboardEvent) => {
const map: Record<string, () => Date> = {
ArrowLeft: () => addDays(focus, -1),
ArrowRight: () => addDays(focus, 1),
ArrowUp: () => addDays(focus, -7),
ArrowDown: () => addDays(focus, 7),
PageUp: () => addMonths(focus, e.shiftKey ? -12 : -1),
PageDown: () => addMonths(focus, e.shiftKey ? 12 : 1),
Home: () => addDays(focus, -((focus.getDay() - weekStartsOn + 7) % 7)),
End: () => addDays(focus, 6 - ((focus.getDay() - weekStartsOn + 7) % 7)),
}
if (map[e.key]) {
e.preventDefault()
setFocus(map[e.key]())
} else if (e.key === 'Escape') {
setOpen(false)
trigger.current?.focus()
}
}
const display = single ? fmt(single) : range ? `${fmt(range.from)} – ${range.to ? fmt(range.to) : '…'}` : null
return (
<div ref={root} className="relative inline-block w-full max-w-xs text-zinc-900 dark:text-zinc-100">
<label htmlFor={`${id}-t`} className="mb-1.5 block text-[12.5px] font-medium text-zinc-600 dark:text-zinc-400">
{label}
</label>
<button
ref={trigger}
id={`${id}-t`}
type="button"
aria-haspopup="dialog"
aria-expanded={open}
onClick={() => {
opened.current = true
setOpen((o) => !o)
}}
className="flex h-10 w-full items-center gap-2.5 rounded-lg border border-zinc-200 bg-white px-3 text-left text-[14px] transition hover:border-zinc-400 focus-visible:border-zinc-900 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-zinc-900/5 dark:border-zinc-800 dark:bg-zinc-950"
>
<svg viewBox="0 0 16 16" className="size-4 shrink-0 text-zinc-400" fill="none" stroke="currentColor" strokeWidth="1.4" aria-hidden>
<rect x="2" y="3" width="12" height="11" rx="2" />
<path d="M2 6.5h12M5.5 1.5v3M10.5 1.5v3" strokeLinecap="round" />
</svg>
<span className={display ? '' : 'text-zinc-400'}>{display ?? placeholder}</span>
</button>
{open && (
<div role="dialog" aria-modal="false" aria-label={`Choose ${label.toLowerCase()}`} className="absolute left-0 top-full z-20 mt-2 w-[300px] rounded-xl border border-zinc-200 bg-white p-3 shadow-[0_20px_50px_-20px_rgb(0_0_0/0.3)] motion-safe:animate-[dp-in_180ms_cubic-bezier(.22,1,.36,1)] dark:border-zinc-800 dark:bg-zinc-950">
<div className="mb-2 flex items-center justify-between">
<button type="button" aria-label="Previous month" onClick={() => setFocus(addMonths(focus, -1))} className="grid size-8 place-items-center rounded-md text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900 dark:hover:bg-zinc-900 dark:hover:text-white">
‹
</button>
<p className="text-[13.5px] font-semibold" aria-live="polite">
{new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' }).format(focus)}
</p>
<button type="button" aria-label="Next month" onClick={() => setFocus(addMonths(focus, 1))} className="grid size-8 place-items-center rounded-md text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900 dark:hover:bg-zinc-900 dark:hover:text-white">
›
</button>
</div>
<div ref={grid} role="grid" aria-label="Calendar" onKeyDown={onKey} onPointerLeave={() => setHover(null)}>
<div role="row" className="grid grid-cols-7">
{weekdayNames.map((w) => (
<span role="columnheader" key={w} className="py-1 text-center text-[11px] font-medium text-zinc-400">
{w.slice(0, 2)}
</span>
))}
</div>
{weeks.map((week, wi) => (
<div role="row" key={wi} className="grid grid-cols-7">
{week.map((d) => {
const outside = d.getMonth() !== focus.getMonth()
const isSel = same(d, single) || same(d, range?.from) || same(d, range?.to)
const mid = inRange(d) && !isSel
const today = same(d, new Date())
const off = blocked(d)
return (
<span role="gridcell" key={d.toISOString()} aria-selected={isSel || mid || undefined} className={`p-px ${mid ? 'bg-zinc-100 dark:bg-zinc-900' : ''}`}>
<button
type="button"
tabIndex={same(d, focus) ? 0 : -1}
data-focus={same(d, focus)}
disabled={off}
aria-label={new Intl.DateTimeFormat(locale, { dateStyle: 'full' }).format(d)}
aria-current={today ? 'date' : undefined}
onClick={() => {
setFocus(d)
pick(d)
}}
onPointerEnter={() => setHover(d)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
pick(d)
}
}}
className={[
'relative grid h-9 w-full place-items-center rounded-md text-[13px] tabular-nums transition-colors focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-zinc-900 dark:focus-visible:outline-white',
isSel ? 'bg-zinc-900 font-semibold text-white dark:bg-white dark:text-zinc-900' : off ? 'cursor-not-allowed text-zinc-300 line-through dark:text-zinc-700' : outside ? 'text-zinc-300 hover:bg-zinc-100 dark:text-zinc-600 dark:hover:bg-zinc-900' : 'hover:bg-zinc-100 dark:hover:bg-zinc-900',
].join(' ')}
>
{d.getDate()}
{today && !isSel && <span aria-hidden className="absolute bottom-1 size-1 rounded-full bg-zinc-900 dark:bg-white" />}
</button>
</span>
)
})}
</div>
))}
</div>
<div className="mt-2 flex items-center justify-between border-t border-zinc-100 pt-2 text-[12.5px] dark:border-zinc-900">
<button type="button" onClick={() => setFocus(startOfDay(new Date()))} className="rounded px-1.5 py-1 font-medium hover:bg-zinc-100 dark:hover:bg-zinc-900">
Today
</button>
{value && (
<button type="button" onClick={() => onChange?.(null)} className="rounded px-1.5 py-1 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900 dark:hover:bg-zinc-900 dark:hover:text-white">
Clear
</button>
)}
</div>
</div>
)}
<style>{`@keyframes dp-in{from{opacity:0;transform:translateY(-4px) scale(.98)}}`}</style>
</div>
)
}Overview
A date picker that behaves the way people expect. The trigger opens a calendar popover. Arrow keys move by day and week, Page Up and Page Down move by month (Shift for a year), and Home and End jump to the edges of the week. Enter picks a day and Escape closes. Focus returns to the trigger afterwards.
Range mode previews the span as you hover before you choose the end date, and orders the dates if you pick them backwards. Days outside min and max, or matching isDisabled (weekends in the demo), are struck through and can’t be selected. Today has a dot, and dates are formatted with Intl for any locale.
Use it for bookings, delivery dates and report filters. For birth dates, where people type faster than they click, use three plain inputs instead.
When to use
- Collecting input where the control itself should guide people.
When not to use
- Cases a plain native input already serves well.
Installation
pnpm dlx shadcn@latest add https://sundaeui.dev/r/date-picker.jsonnpx shadcn@latest add https://sundaeui.dev/r/date-picker.jsonyarn dlx shadcn@latest add https://sundaeui.dev/r/date-picker.jsonbunx --bun shadcn@latest add https://sundaeui.dev/r/date-picker.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy date-picker.tsx into components/sundae/.
- Make sure Tailwind scans that folder (v4 detects it automatically; v3 needs it in `content`).
- Import the component and use it as shown below.
Usage
import { DatePicker, type DateRange } from '@/components/sundae/date-picker'
const [date, setDate] = useState<Date | null>(null)
<DatePicker label="Delivery date" value={date} onChange={(v) => setDate(v as Date | null)} min={new Date()} />
const [range, setRange] = useState<DateRange | null>(null)
<DatePicker mode="range" label="Report range" value={range} onChange={(v) => setRange(v as DateRange | null)} />Props
| Prop | Type | Default |
|---|---|---|
modePick one date or a range. | 'single' | 'range' | 'single' |
valueControlled value. | Date | DateRange | null | — |
onChangeCalled with a Date, a { from, to } range, or null when cleared. | (v) => void | — |
min / maxInclusive bounds. | Date | — |
isDisabledDisable specific days. | (d: Date) => boolean | — |
localeIntl locale for labels. | string | 'en-US' |
weekStartsOnSunday or Monday first. | 0 | 1 | 1 |
label / placeholderVisible label and empty text. | string | — |
defaultOpenOpen on mount without stealing focus. | boolean | — |
Accessibility
- Follows the WAI-ARIA date picker dialog pattern: role="grid", with a roving tabindex on days.
- Every day button has a full spoken date, today is marked aria-current="date", and disabled days are real disabled buttons.
- The month heading is a live region, so paging months is announced.
Customization
- Restyle with the zinc classes. The selected day uses bg-zinc-900.
- Set weekStartsOn={0} for US-style calendars.
FAQ
- Is Date Picker free?
- Yes. Date Picker is free for personal and commercial projects, with full source on this page.
- How do I install Date Picker?
- Run npx shadcn@latest add https://sundaeui.dev/r/date-picker.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Date Picker support keyboard and screen readers?
- Follows the WAI-ARIA date picker dialog pattern: role="grid", with a roving tabindex on days. Every day button has a full spoken date, today is marked aria-current="date", and disabled days are real disabled buttons.
- Does Date Picker respect reduced motion?
- Yes. With prefers-reduced-motion set, decorative motion is removed and state changes are instant.
More like this
All forms →Combobox
Forms · UnderstatedRange Slider
Forms · UnderstatedOTP Field
Forms · Refined- Pro
Onboarding Flow
Forms · Refined Tag Field
Forms · UnderstatedJelly Switch
Forms · Playful