Date picker
A single date, picked in a calendar popover with month and year dropdowns to jump around quickly.
'use client'
import { DatePicker } from '@/components/date-picker'
import { useState } from 'react'
export default function Example() {
let [date, setDate] = useState<string | null>(null)
return <DatePicker value={date} onChange={setDate} />
}<script setup lang="ts">
import DatePicker from '@/components/date-picker.vue'
import { ref } from 'vue'
const date = ref<string | null>(null)
</script>
<template>
<DatePicker v-model="date" />
</template>Dependencies
The calendar itself comes from react-day-picker:
npm install react-day-pickerpnpm add react-day-pickeryarn add react-day-pickerbun add react-day-pickernpm install reka-ui @internationalized/datepnpm add reka-ui @internationalized/dateyarn add reka-ui @internationalized/datebun add reka-ui @internationalized/dateIt normally reads its month and weekday names from a date-fns locale. The component formats them through Intl instead, from the plain locale tag you pass, so no date library gets pulled in.
Installation
Copy these files into the folder where you keep your components. They import each other with relative paths, so the folder can live anywhere. The packages they rely on are listed on the Installation page.
'use client'
import * as Headless from '@headlessui/react'
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react'
import { CalendarIcon, ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/16/solid'
import clsx from 'clsx'
import type React from 'react'
import { forwardRef, useId, useMemo } from 'react'
import { DayPicker } from 'react-day-picker'
// Not Date.parse: it reads a bare yyyy-mm-dd as UTC and shifts the day west of Greenwich.
export function fromISO(value: string) {
let match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value)
if (!match) return undefined
let [year, month, day] = match.slice(1).map(Number) as [number, number, number]
let date = new Date(year, month - 1, day)
return date.getMonth() === month - 1 ? date : undefined
}
export function toISO(date: Date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
export function useCalendarLocale(locale: string) {
return useMemo(() => {
let month = new Intl.DateTimeFormat(locale, { month: 'long' })
let weekday = new Intl.DateTimeFormat(locale, { weekday: 'short' })
let caption = new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' })
let info = (Intl as unknown as { Locale: new (tag: string) => { weekInfo?: { firstDay?: number } } }).Locale
? new Intl.Locale(locale)
: undefined
let firstDay = (info as { weekInfo?: { firstDay?: number } } | undefined)?.weekInfo?.firstDay ?? 7
return {
weekStartsOn: (firstDay % 7) as 0 | 1 | 2 | 3 | 4 | 5 | 6,
formatters: {
formatCaption: (date: Date) => caption.format(date),
formatMonthDropdown: (date: Date) => month.format(date),
formatWeekdayName: (date: Date) => weekday.format(date),
},
}
}, [locale])
}
const calendarClassNames = {
root: 'relative',
months: 'relative flex flex-col gap-4 md:flex-row',
month: 'flex flex-col',
nav: 'pointer-events-none absolute inset-x-0 top-0 z-10 flex h-9 items-center justify-between px-1',
month_caption: 'flex h-9 items-center justify-center px-8',
dropdowns: 'flex min-w-0 items-center gap-1',
caption_label:
'flex items-center gap-0.5 px-2 py-1 text-sm/6 font-medium text-zinc-950 select-none dark:text-white [&>svg]:size-3.5 [&>svg]:text-zinc-500 dark:[&>svg]:text-zinc-400',
button_previous:
'pointer-events-auto inline-flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-md text-zinc-500 hover:bg-zinc-950/5 hover:text-zinc-950 focus:outline-hidden focus-visible:outline-2 focus-visible:outline-accent-500 disabled:opacity-30 dark:text-zinc-400 dark:hover:bg-white/5 dark:hover:text-white',
button_next:
'pointer-events-auto inline-flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-md text-zinc-500 hover:bg-zinc-950/5 hover:text-zinc-950 focus:outline-hidden focus-visible:outline-2 focus-visible:outline-accent-500 disabled:opacity-30 dark:text-zinc-400 dark:hover:bg-white/5 dark:hover:text-white',
month_grid: 'mt-1 w-max border-collapse select-none',
weekdays: 'flex',
weekday: 'flex size-9 items-center justify-center text-xs/6 font-normal text-zinc-500 dark:text-zinc-400',
week: 'mt-1 flex',
day: 'relative size-9 p-0 text-center',
day_button:
'inline-flex size-9 cursor-pointer items-center justify-center rounded-md text-sm/6 font-normal text-zinc-950 hover:bg-zinc-950/5 focus:outline-hidden focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-accent-500 dark:text-white dark:hover:bg-white/10',
selected: '*:bg-accent-600! *:text-white! dark:*:bg-accent-500! dark:*:text-white!',
today: '*:font-semibold!',
outside: '*:text-zinc-950/30! dark:*:text-white/30!',
disabled: 'pointer-events-none opacity-40',
hidden: 'invisible',
}
const dropdownCaptionClassNames = {
caption_label: 'hidden',
dropdown_root: 'relative min-w-0',
dropdown:
'cursor-pointer appearance-none rounded-md bg-transparent px-2 py-1 text-center text-sm/6 font-medium text-zinc-950 hover:bg-zinc-950/5 focus:outline-hidden focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-accent-500 dark:text-white dark:hover:bg-white/5 dark:[&>option]:bg-zinc-800',
}
export const calendarComponents = {
Chevron: ({ orientation }: { orientation?: 'up' | 'down' | 'left' | 'right' }) =>
orientation === 'left' ? (
<ChevronLeftIcon className="size-4 shrink-0" />
) : orientation === 'right' ? (
<ChevronRightIcon className="size-4 shrink-0" />
) : (
<ChevronDownIcon className="size-4 shrink-0" />
),
}
const calendarPanel =
'z-50 rounded-xl border border-zinc-950/10 bg-white p-3 shadow-lg outline-none dark:border-white/10 dark:bg-zinc-800'
const pickerTrigger =
'relative flex w-full cursor-pointer items-center gap-2 rounded-lg border border-zinc-950/10 bg-transparent px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(2.5)-1px)] text-left text-base/6 text-zinc-950 hover:border-zinc-950/20 focus:outline-hidden sm:px-[calc(--spacing(3)-1px)] sm:py-[calc(--spacing(1.5)-1px)] sm:text-sm/6 dark:border-white/10 dark:bg-white/5 dark:text-white dark:hover:border-white/20'
const pickerControl = [
'relative block w-full',
'before:absolute before:inset-px before:rounded-[calc(var(--radius-lg)-1px)]',
'before:bg-white before:shadow-sm dark:before:hidden',
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset sm:focus-within:after:ring-2 sm:focus-within:after:ring-accent-500',
]
export const PickerButton = forwardRef(function PickerButton(
{
valueId,
'aria-labelledby': labelledBy,
...props
}: { valueId: string } & React.ComponentPropsWithoutRef<typeof PopoverButton>,
ref: React.ForwardedRef<HTMLButtonElement>
) {
return <PopoverButton ref={ref} {...props} aria-labelledby={[labelledBy, valueId].filter(Boolean).join(' ')} />
})
export function DatePicker({
value,
onChange,
placeholder = 'Select date',
locale = 'en-US',
minYear = 1900,
maxYear = new Date().getFullYear() + 10,
className,
...props
}: {
value: string | null
onChange: (value: string | null) => void
placeholder?: string
locale?: string
minYear?: number
maxYear?: number
} & Omit<React.ComponentPropsWithoutRef<'div'>, 'onChange' | 'children'>) {
let { weekStartsOn, formatters } = useCalendarLocale(locale)
let valueId = useId()
let selected = value ? fromISO(value) : undefined
let label = selected ? new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(selected) : null
return (
<div data-slot="control" {...props} className={clsx([className, ...pickerControl])}>
<Popover>
<Headless.Input as={PickerButton} valueId={valueId} className={pickerTrigger}>
<CalendarIcon className="size-4 shrink-0 text-zinc-500 dark:text-zinc-400" />
<span id={valueId} className={clsx('min-w-0 flex-1 truncate', !label && 'text-zinc-500')}>
{label ?? placeholder}
</span>
<ChevronDownIcon className="size-4 shrink-0 text-zinc-500 dark:text-zinc-400" />
</Headless.Input>
<PopoverPanel anchor={{ to: 'bottom start', gap: 8, padding: 8 }} className={calendarPanel}>
{({ close }) => (
<DayPicker
mode="single"
required
selected={selected}
defaultMonth={selected}
onSelect={(date) => {
onChange(toISO(date))
close()
}}
captionLayout="dropdown"
startMonth={new Date(minYear, 0)}
endMonth={new Date(maxYear, 11)}
weekStartsOn={weekStartsOn}
formatters={formatters}
showOutsideDays
fixedWeeks
classNames={{ ...calendarClassNames, ...dropdownCaptionClassNames }}
components={calendarComponents}
/>
)}
</PopoverPanel>
</Popover>
</div>
)
}<script setup lang="ts">
import { CalendarIcon, ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from '@heroicons/vue/16/solid'
import { CalendarDate, DateFormatter, getLocalTimeZone, parseDate, today, type DateValue } from '@internationalized/date'
import {
CalendarCell,
CalendarCellTrigger,
CalendarGrid,
CalendarGridBody,
CalendarGridHead,
CalendarGridRow,
CalendarHeadCell,
CalendarNext,
CalendarPrev,
CalendarRoot,
PopoverContent,
PopoverPortal,
PopoverRoot,
PopoverTrigger,
} from 'reka-ui'
import { computed, ref, shallowRef, useAttrs, useId, watch } from 'vue'
import { useField } from './fields'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{ placeholder?: string; locale?: string; minYear?: number; maxYear?: number }>(),
{ placeholder: 'Select date', locale: 'en-US' },
)
const model = defineModel<string | null>({ default: null })
const attrs = useAttrs()
const field = useField()
const valueId = useId()
const rootAttrs = computed(() => {
const { class: _class, ...rest } = attrs
return rest
})
function parseISO(value: string) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return undefined
try {
return parseDate(value)
} catch {
return undefined
}
}
const timeZone = getLocalTimeZone()
const now = today(timeZone)
const open = ref(false)
const selected = computed<DateValue | undefined>(() => (model.value ? parseISO(model.value) : undefined))
const displayMonth = shallowRef<DateValue>(now)
watch(open, (value) => {
if (value) displayMonth.value = selected.value ?? now
})
const monthFormatter = computed(() => new DateFormatter(props.locale, { month: 'long' }))
const labelFormatter = computed(() => new DateFormatter(props.locale, { dateStyle: 'medium' }))
const label = computed(() =>
selected.value ? labelFormatter.value.format(selected.value.toDate(timeZone)) : null,
)
const months = computed(() =>
Array.from({ length: 12 }, (_, index) => ({
value: index + 1,
label: monthFormatter.value.format(new Date(2000, index, 1)),
})),
)
const minYear = computed(() => props.minYear ?? 1900)
const maxYear = computed(() => props.maxYear ?? now.year + 10)
const minValue = computed(() => new CalendarDate(minYear.value, 1, 1))
const maxValue = computed(() => new CalendarDate(maxYear.value, 12, 31))
const years = computed(() =>
Array.from({ length: maxYear.value - minYear.value + 1 }, (_, index) => maxYear.value - index),
)
function setMonth(event: Event) {
const month = Number((event.target as HTMLSelectElement).value)
displayMonth.value = displayMonth.value.set({ month })
}
function setYear(event: Event) {
const year = Number((event.target as HTMLSelectElement).value)
displayMonth.value = displayMonth.value.set({ year })
}
function onSelect(value: DateValue | undefined) {
if (!value) return
model.value = value.toString()
open.value = false
}
</script>
<template>
<span
v-bind="rootAttrs"
data-slot="control"
:class="[
attrs.class,
'relative block w-full',
'before:absolute before:inset-px before:rounded-[calc(var(--radius-lg)-1px)]',
'before:bg-white before:shadow-sm dark:before:hidden',
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset sm:focus-within:after:ring-2 sm:focus-within:after:ring-accent-500',
]"
>
<PopoverRoot v-model:open="open">
<PopoverTrigger
:id="field?.controlId"
type="button"
:aria-labelledby="[field?.labelledBy.value, valueId].filter(Boolean).join(' ')"
:aria-describedby="field?.describedBy.value"
class="relative flex w-full cursor-pointer items-center gap-2 rounded-lg border border-zinc-950/10 bg-transparent px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(2.5)-1px)] text-left text-base/6 text-zinc-950 hover:border-zinc-950/20 focus:outline-hidden sm:px-[calc(--spacing(3)-1px)] sm:py-[calc(--spacing(1.5)-1px)] sm:text-sm/6 dark:border-white/10 dark:bg-white/5 dark:text-white dark:hover:border-white/20"
>
<CalendarIcon class="size-4 shrink-0 text-zinc-500 dark:text-zinc-400" />
<span :id="valueId" class="min-w-0 flex-1 truncate" :class="label ? '' : 'text-zinc-500'">
{{ label ?? props.placeholder }}
</span>
<ChevronDownIcon class="size-4 shrink-0 text-zinc-500 dark:text-zinc-400" />
</PopoverTrigger>
<PopoverPortal>
<PopoverContent
align="start"
:side-offset="8"
:collision-padding="8"
class="z-50 rounded-xl border border-zinc-950/10 bg-white p-3 shadow-lg outline-none dark:border-white/10 dark:bg-zinc-800"
>
<CalendarRoot
v-slot="{ grid, weekDays }"
:model-value="selected"
:placeholder="displayMonth"
:locale="locale"
:min-value="minValue"
:max-value="maxValue"
weekday-format="short"
fixed-weeks
prevent-deselect
@update:model-value="onSelect"
@update:placeholder="displayMonth = $event"
>
<div class="flex items-center gap-1">
<CalendarPrev
class="inline-flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-md text-zinc-500 hover:bg-zinc-950/5 hover:text-zinc-950 focus:outline-hidden focus-visible:outline-2 focus-visible:outline-accent-500 disabled:opacity-30 dark:text-zinc-400 dark:hover:bg-white/5 dark:hover:text-white"
>
<ChevronLeftIcon class="size-4 shrink-0" />
</CalendarPrev>
<select
:value="displayMonth.month"
aria-label="Month"
class="min-w-0 flex-1 cursor-pointer appearance-none rounded-md px-2 py-1 text-center text-sm/6 font-medium text-zinc-950 hover:bg-zinc-950/5 focus:outline-hidden focus-visible:outline-2 focus-visible:outline-accent-500 dark:text-white dark:hover:bg-white/5"
@change="setMonth"
>
<option v-for="month in months" :key="month.value" :value="month.value">
{{ month.label }}
</option>
</select>
<select
:value="displayMonth.year"
aria-label="Year"
class="cursor-pointer appearance-none rounded-md px-2 py-1 text-center text-sm/6 font-medium text-zinc-950 hover:bg-zinc-950/5 focus:outline-hidden focus-visible:outline-2 focus-visible:outline-accent-500 dark:text-white dark:hover:bg-white/5"
@change="setYear"
>
<option v-for="year in years" :key="year" :value="year">{{ year }}</option>
</select>
<CalendarNext
class="inline-flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-md text-zinc-500 hover:bg-zinc-950/5 hover:text-zinc-950 focus:outline-hidden focus-visible:outline-2 focus-visible:outline-accent-500 disabled:opacity-30 dark:text-zinc-400 dark:hover:bg-white/5 dark:hover:text-white"
>
<ChevronRightIcon class="size-4 shrink-0" />
</CalendarNext>
</div>
<CalendarGrid
v-for="month in grid"
:key="month.value.toString()"
class="mt-2 border-collapse select-none"
>
<CalendarGridHead>
<CalendarGridRow class="flex">
<CalendarHeadCell
v-for="day in weekDays"
:key="day"
class="flex size-9 items-center justify-center text-xs/6 font-normal text-zinc-500 dark:text-zinc-400"
>
{{ day }}
</CalendarHeadCell>
</CalendarGridRow>
</CalendarGridHead>
<CalendarGridBody>
<CalendarGridRow
v-for="(week, weekIndex) in month.rows"
:key="`week-${weekIndex}`"
class="mt-1 flex"
>
<CalendarCell
v-for="day in week"
:key="day.toString()"
:date="day"
class="relative size-9 p-0 text-center"
>
<CalendarCellTrigger
:day="day"
:month="month.value"
class="inline-flex size-9 cursor-pointer items-center justify-center rounded-md text-sm/6 font-normal text-zinc-950 hover:bg-zinc-950/5 focus:outline-hidden focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-accent-500 data-disabled:pointer-events-none data-disabled:opacity-40 data-outside-view:text-zinc-950/30 data-selected:bg-accent-600! data-selected:text-white! data-today:font-semibold dark:text-white dark:hover:bg-white/10 dark:data-outside-view:text-white/30 dark:data-selected:bg-accent-500! dark:data-selected:text-white!"
/>
</CalendarCell>
</CalendarGridRow>
</CalendarGridBody>
</CalendarGrid>
</CalendarRoot>
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
</span>
</template>Component API
| Prop | Default | Description |
|---|---|---|
DatePickerrenders a <div> element | ||
value | - | The date as an ISO string (`yyyy-mm-dd`), or `null` when empty. Any other string shows as empty. |
onChange | - | Called with the new ISO date. Picking the selected day again keeps it. |
placeholder | 'Select date' | Shown while no date is picked. |
locale | 'en-US' | BCP 47 tag driving month names, weekday order and the label format. |
minYear | 1900 | First year offered in the year dropdown. |
maxYear | current year + 10 | Last year offered in the year dropdown. |
| Prop | Default | Description |
|---|---|---|
DatePickerbuilt on the Reka UI <PopoverRoot> component | ||
v-model | - | The date as an ISO string (`yyyy-mm-dd`), or `null` when empty. |
placeholder | 'Select date' | Shown while no date is picked. |
locale | 'en-US' | Drives the month names, the weekday order and the label format. |
minYear | 1900 | First year offered in the year select. |
maxYear | current year + 10 | Last year offered in the year select. |
Examples
Basic example
The value is a plain ISO string, the format a database and an API both understand. The popover closes as soon as a day is picked:
Value: null
'use client'
import { DatePicker } from '@/components/date-picker'
import { useState } from 'react'
export default function Example() {
let [date, setDate] = useState<string | null>(null)
return <DatePicker value={date} onChange={setDate} />
}<script setup lang="ts">
import DatePicker from '@/components/date-picker.vue'
import { ref } from 'vue'
const date = ref<string | null>(null)
</script>
<template>
<DatePicker v-model="date" />
</template>Limiting the years
minYear and maxYear bound the year dropdown, which is what makes a date of birth reachable in two clicks:
<DatePicker value={date} onChange={setDate} placeholder="Date of birth" minYear={1930} maxYear={2010} /><template>
<DatePicker v-model="birthday" placeholder="Date of birth" :min-year="1930" :max-year="2010" />
</template>Another locale
locale changes the month names, the first day of the week and the way the selected date is written out:
<DatePicker value={date} onChange={setDate} locale="fr-FR" placeholder="Choisir une date" /><template>
<DatePicker v-model="date" locale="fr-FR" placeholder="Choisir une date" />
</template>In a form
It sits on the same control base as Input, so it lines up with the other fields of a form:
Funds land on the next business day.
<Field>
<Label>Payout date</Label>
<Description>Funds land on the next business day.</Description>
<DatePicker value={date} onChange={setDate} />
</Field><template>
<Field>
<Label>Payout date</Label>
<Description>Funds land on the next business day.</Description>
<DatePicker v-model="date" />
</Field>
</template>