Skip to content
  1. Inputs
  2. Date picker

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-picker
pnpm add react-day-picker
yarn add react-day-picker
bun add react-day-picker
npm install reka-ui @internationalized/date
pnpm add reka-ui @internationalized/date
yarn add reka-ui @internationalized/date
bun add reka-ui @internationalized/date

It 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

PropDefaultDescription
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.
minYear1900First year offered in the year dropdown.
maxYearcurrent year + 10Last year offered in the year dropdown.
PropDefaultDescription
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.
minYear1900First year offered in the year select.
maxYearcurrent year + 10Last 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>