Skip to content

Date Picker

Date Picker is the normal choice for one date in a form. The field stays editable, Calendar is an enhancement, and the submitted value is always YYYY-MM-DD.

DatePicker.vue

Installation

The registry installs Input, Popover, and Calendar first when they are missing, then adds the framework-native Date Picker:

Run one command from a Boring Stack application. Klean detects the framework and conventional destination, then adds the framework-native source and its direct dependencies.

Terminal
npx klean-ui add date-picker

  • No initializer or configuration file
  • No framework, alias, or theme questions
  • No Klean runtime dependency

No provider, configuration file, locale pack, or Date Picker variant is added.

When to use

Use Date Picker for issue dates, due dates, birthdays, effective dates, and other date-only fields where a time would be false precision.

When not to use

Use Calendar when the date surface should stay visible, Date Range Picker when two dates make one period, and Schedule Picker when a wall-clock time and timezone must become an exact instant.

Usage

Vue

DueDateField.vue
<script setup>
import { ref } from 'vue'
import DatePicker from '~/components/ui/date-picker/DatePicker.vue'

const dueAt = ref('')
</script>

<template>
  <label for="due-date">Due date</label>
  <DatePicker id="due-date" v-model="dueAt" name="dueAt" required />
</template>

React

DueDateField.jsx
import { useState } from 'react'
import DatePicker from '@/components/ui/date-picker/DatePicker.jsx'

export default function DueDateField() {
  const [dueAt, setDueAt] = useState('')

  return (
    <>
      <label htmlFor="due-date">Due date</label>
      <DatePicker
        id="due-date"
        value={dueAt}
        onValueChange={setDueAt}
        name="dueAt"
        required
      />
    </>
  )
}

Svelte

DueDateField.svelte
<script>
  import DatePicker from '~/components/ui/date-picker/DatePicker.svelte'

  let dueAt = $state('')
</script>

<label for="due-date">Due date</label>
<DatePicker id="due-date" bind:value={dueAt} name="dueAt" required />

The application owns the visible label and product availability rule. Date Picker owns the stable value, field validity, optional floating surface, keyboard navigation, and focus return. Click the field or press Arrow Down to open Calendar.

Relational dates

Two fields with different business meanings should stay two Date Pickers. Put their relationship in the form through ordinary min and max values.

invoice-dates.vue

The due date cannot equal the issue date, the issue date cannot move into the past, and changing either side repairs an obsolete opposite boundary. This is application policy expressed through the small Date Picker API—not an invoice mode hidden inside the component.

API

PurposeVueReactSvelte
Current valuev-modelvalue, onValueChangebind:value
Initial valuedefault-valuedefaultValuedefaultValue
Native form namenamenamename
Boundsmin, maxmin, maxmin, max
Product availabilityunavailableunavailableunavailable
Localelocale, dirlocale, dirlocale, dir
Open statev-model:openopen, onOpenChangebind:open
Native statesrequired, disabled, readonlyrequired, disabled, readOnlyrequired, disabled, readonly

Typing an invalid, unavailable, or out-of-bounds date sets native validity and aria-invalid without silently replacing the last valid application value. Choosing a date commits once and returns focus through the native-first Popover relationship.

Calendar, Date Picker, and Date Range Picker use date-only YYYY-MM-DD values. Choose Schedule Picker when time and timezone must resolve to an exact ISO instant.

  • Calendar — an always-visible date-only YYYY-MM-DD surface.
  • Date Range Picker — ordered date-only YYYY-MM-DD start and end values.
  • Schedule Picker — date, time, and IANA timezone stored as an exact ISO instant.
  • Input — a plain field when a calendar adds no value.

Complete framework source

Vue

DatePicker.vue
<script setup>
import { computed, nextTick, ref, useAttrs, useId, watch } from 'vue'
import { twMerge } from 'tailwind-merge'
import Calendar from '../calendar/Calendar.vue'
import {
  dateIsUnavailable,
  dateLabel,
  parseIsoDate,
  resolveLocale
} from '../calendar/date.js'
import Input from '../input/Input.vue'
import Popover from '../popover/Popover.vue'

defineOptions({ inheritAttrs: false })

const props = defineProps({
  /** A date-only ISO value: YYYY-MM-DD. */
  modelValue: { type: String, default: undefined },
  /** Initial value when modelValue is not controlled. */
  defaultValue: { type: String, default: undefined },
  id: { type: String, default: undefined },
  name: { type: String, default: undefined },
  placeholder: { type: String, default: 'YYYY-MM-DD' },
  min: { type: String, default: undefined },
  max: { type: String, default: undefined },
  unavailable: { type: Function, default: undefined },
  locale: { type: String, default: undefined },
  dir: { type: String, default: undefined },
  open: { type: Boolean, default: undefined },
  defaultOpen: { type: Boolean, default: false },
  required: { type: Boolean, default: false },
  disabled: { type: Boolean, default: false },
  readonly: { type: Boolean, default: false }
})

const emit = defineEmits(['update:modelValue', 'change', 'update:open'])
const attrs = useAttrs()
const generatedId = useId().replace(/[^a-zA-Z0-9_-]/g, '')
const inputId = computed(() => props.id ?? `klean-date-picker-${generatedId}`)
const popoverId = computed(() => `${inputId.value}-calendar`)
const descriptionId = computed(() => `${inputId.value}-description`)
const internalValue = ref(
  parseIsoDate(props.defaultValue) ? props.defaultValue : ''
)
const value = computed(() =>
  props.modelValue === undefined ? internalValue.value : props.modelValue
)
const draft = ref(value.value ?? '')
const input = ref()
const popover = ref()
const locale = computed(() => resolveLocale(props.locale))
const invalid = computed(() => {
  if (!draft.value) return false
  if (!parseIsoDate(draft.value)) return true
  return dateIsUnavailable(draft.value, {
    min: props.min,
    max: props.max,
    unavailable: props.unavailable
  })
})
const describedValue = computed(() =>
  !invalid.value && parseIsoDate(draft.value)
    ? dateLabel(draft.value, locale.value, { weekday: 'long' })
    : ''
)
const inputAttrs = computed(() => {
  const {
    class: _class,
    'data-slot': _dataSlot,
    'aria-describedby': _describedBy,
    ...rest
  } = attrs
  return rest
})
const describedBy = computed(
  () =>
    [attrs['aria-describedby'], describedValue.value && descriptionId.value]
      .filter(Boolean)
      .join(' ') || undefined
)
const rootClasses = computed(() =>
  twMerge(
    'relative flex w-full items-stretch **:data-[slot=input]:pe-12',
    attrs.class
  )
)

function commit(nextValue) {
  if (props.disabled || props.readonly) return
  if (nextValue && dateIsUnavailable(nextValue, props)) return
  if (props.modelValue === undefined) internalValue.value = nextValue
  draft.value = nextValue
  emit('update:modelValue', nextValue)
  emit('change', nextValue)
}

function handleInput(event) {
  const nextValue = event.target.value.trim()
  draft.value = nextValue
  if (
    !nextValue ||
    (parseIsoDate(nextValue) && !dateIsUnavailable(nextValue, props))
  ) {
    commit(nextValue)
  }
}

function handleInputKeydown(event) {
  if (event.key === 'ArrowDown' && !props.disabled && !props.readonly) {
    event.preventDefault()
    popover.value?.open()
  }
}

function handleCalendarChange(nextValue) {
  commit(nextValue)
  popover.value?.close()
}

function handleOpenUpdate(nextOpen) {
  emit('update:open', nextOpen)
}

watch(value, (nextValue) => {
  draft.value = nextValue ?? ''
})

watch(
  () => [invalid.value, props.required, draft.value],
  async () => {
    await nextTick()
    const element = input.value?.element
    if (!element) return
    if (props.required && !draft.value) {
      element.setCustomValidity('Choose a date.')
    } else if (invalid.value) {
      element.setCustomValidity('Enter an available date as YYYY-MM-DD.')
    } else {
      element.setCustomValidity('')
    }
  },
  { immediate: true }
)

defineExpose({
  input,
  focus: (options) => input.value?.focus(options),
  open: () => popover.value?.open(),
  close: () => popover.value?.close()
})
</script>

<template>
  <div data-slot="date-picker" :class="rootClasses">
    <Input
      ref="input"
      v-bind="inputAttrs"
      :id="inputId"
      type="text"
      inputmode="numeric"
      autocomplete="off"
      :name="name"
      :value="draft"
      :placeholder="placeholder"
      :required="required"
      :disabled="disabled"
      :readonly="readonly"
      :aria-invalid="invalid || undefined"
      :aria-describedby="describedBy"
      data-slot="date-picker-input"
      @input="handleInput"
      @click="!disabled && !readonly && popover?.open()"
      @keydown="handleInputKeydown"
    />
    <span
      v-if="describedValue"
      :id="descriptionId"
      data-slot="date-picker-description"
      class="sr-only"
    >
      {{ describedValue }}
    </span>
    <button
      type="button"
      :popovertarget="popoverId"
      data-slot="date-picker-button"
      class="absolute inset-y-0 inset-e-0 grid min-w-11 place-items-center rounded-e-md text-gray-500 hover:bg-gray-100 hover:text-gray-950 focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:opacity-40 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white dark:focus-visible:outline-white"
      :disabled="disabled || readonly"
      :aria-label="draft ? `Change date, ${describedValue}` : 'Choose a date'"
    >
      <svg
        aria-hidden="true"
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        stroke-width="1.8"
        class="size-5"
      >
        <path
          d="M7 3v3M17 3v3M4 9h16M5 5h14a1 1 0 0 1 1 1v14H4V6a1 1 0 0 1 1-1Z"
        />
      </svg>
    </button>
    <Popover
      ref="popover"
      :id="popoverId"
      :open="open"
      :default-open="defaultOpen"
      placement="bottom-start"
      data-slot="date-picker-popover"
      class="w-[min(22rem,calc(100vw-1rem))] p-0"
      @update:open="handleOpenUpdate"
    >
      <Calendar
        :model-value="parseIsoDate(value) ? value : undefined"
        :default-value="parseIsoDate(draft) ? draft : undefined"
        :min="min"
        :max="max"
        :unavailable="unavailable"
        :locale="locale"
        :dir="dir"
        :disabled="disabled"
        :readonly="readonly"
        @update:model-value="handleCalendarChange"
      />
    </Popover>
  </div>
</template>

React

DatePicker.jsx
import {
  forwardRef,
  useEffect,
  useId,
  useImperativeHandle,
  useRef,
  useState
} from 'react'
import { twMerge } from 'tailwind-merge'
import Calendar from '../calendar/Calendar.jsx'
import {
  dateIsUnavailable,
  dateLabel,
  parseIsoDate,
  resolveLocale
} from '../calendar/date.js'
import Input from '../input/Input.jsx'
import Popover from '../popover/Popover.jsx'

const DatePicker = forwardRef(function DatePicker(
  {
    value,
    defaultValue,
    onValueChange,
    onChange,
    id,
    name,
    placeholder = 'YYYY-MM-DD',
    min,
    max,
    unavailable,
    locale: localeProp,
    dir,
    open,
    defaultOpen = false,
    onOpenChange,
    required = false,
    disabled = false,
    readOnly = false,
    className,
    'aria-describedby': ariaDescribedBy,
    ...inputProps
  },
  forwardedRef
) {
  const generatedId = useId().replace(/[^a-zA-Z0-9_-]/g, '')
  const inputId = id ?? `klean-date-picker-${generatedId}`
  const popoverId = `${inputId}-calendar`
  const descriptionId = `${inputId}-description`
  const [internalValue, setInternalValue] = useState(
    parseIsoDate(defaultValue) ? defaultValue : ''
  )
  const selected = value === undefined ? internalValue : value
  const [draft, setDraft] = useState(selected ?? '')
  const inputRef = useRef(null)
  const popoverRef = useRef(null)
  const locale = resolveLocale(localeProp)
  const invalid = Boolean(
    draft &&
      (!parseIsoDate(draft) ||
        dateIsUnavailable(draft, { min, max, unavailable }))
  )
  const describedValue =
    !invalid && parseIsoDate(draft)
      ? dateLabel(draft, locale, { weekday: 'long' })
      : ''
  const describedBy =
    [ariaDescribedBy, describedValue && descriptionId]
      .filter(Boolean)
      .join(' ') || undefined

  function commit(nextValue) {
    if (disabled || readOnly) return
    if (nextValue && dateIsUnavailable(nextValue, { min, max, unavailable }))
      return
    if (value === undefined) setInternalValue(nextValue)
    setDraft(nextValue)
    onValueChange?.(nextValue)
  }

  function handleChange(event) {
    onChange?.(event)
    if (event.defaultPrevented) return
    const nextValue = event.target.value.trim()
    setDraft(nextValue)
    if (
      !nextValue ||
      (parseIsoDate(nextValue) &&
        !dateIsUnavailable(nextValue, { min, max, unavailable }))
    ) {
      commit(nextValue)
    }
  }

  function handleCalendarChange(nextValue) {
    commit(nextValue)
    popoverRef.current?.close()
  }

  useEffect(() => setDraft(selected ?? ''), [selected])

  useEffect(() => {
    if (!inputRef.current) return
    if (required && !draft) inputRef.current.setCustomValidity('Choose a date.')
    else if (invalid)
      inputRef.current.setCustomValidity(
        'Enter an available date as YYYY-MM-DD.'
      )
    else inputRef.current.setCustomValidity('')
  }, [draft, invalid, required])

  useImperativeHandle(forwardedRef, () => ({
    input: inputRef.current,
    focus: (options) => inputRef.current?.focus(options),
    open: () => popoverRef.current?.open(),
    close: () => popoverRef.current?.close()
  }))

  return (
    <div
      data-slot="date-picker"
      className={twMerge(
        'relative flex w-full items-stretch **:data-[slot=input]:pe-12',
        className
      )}
    >
      <Input
        {...inputProps}
        ref={inputRef}
        id={inputId}
        type="text"
        inputMode="numeric"
        autoComplete="off"
        name={name}
        value={draft}
        placeholder={placeholder}
        required={required}
        disabled={disabled}
        readOnly={readOnly}
        aria-invalid={invalid || undefined}
        aria-describedby={describedBy}
        data-slot="date-picker-input"
        onChange={handleChange}
        onClick={() => !disabled && !readOnly && popoverRef.current?.open()}
        onKeyDown={(event) => {
          inputProps.onKeyDown?.(event)
          if (
            !event.defaultPrevented &&
            event.key === 'ArrowDown' &&
            !disabled &&
            !readOnly
          ) {
            event.preventDefault()
            popoverRef.current?.open()
          }
        }}
      />
      {describedValue ? (
        <span
          id={descriptionId}
          data-slot="date-picker-description"
          className="sr-only"
        >
          {describedValue}
        </span>
      ) : null}
      <button
        type="button"
        popoverTarget={popoverId}
        data-slot="date-picker-button"
        className="absolute inset-y-0 inset-e-0 grid min-w-11 place-items-center rounded-e-md text-gray-500 hover:bg-gray-100 hover:text-gray-950 focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:opacity-40 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white dark:focus-visible:outline-white"
        disabled={disabled || readOnly}
        aria-label={draft ? `Change date, ${describedValue}` : 'Choose a date'}
      >
        <svg
          aria-hidden="true"
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          strokeWidth="1.8"
          className="size-5"
        >
          <path d="M7 3v3M17 3v3M4 9h16M5 5h14a1 1 0 0 1 1 1v14H4V6a1 1 0 0 1 1-1Z" />
        </svg>
      </button>
      <Popover
        ref={popoverRef}
        id={popoverId}
        open={open}
        defaultOpen={defaultOpen}
        onOpenChange={onOpenChange}
        placement="bottom-start"
        data-slot="date-picker-popover"
        className="w-[min(22rem,calc(100vw-1rem))] p-0"
      >
        <Calendar
          value={parseIsoDate(selected) ? selected : undefined}
          defaultValue={parseIsoDate(draft) ? draft : undefined}
          min={min}
          max={max}
          unavailable={unavailable}
          locale={locale}
          dir={dir}
          disabled={disabled}
          readOnly={readOnly}
          onValueChange={handleCalendarChange}
        />
      </Popover>
    </div>
  )
})

export default DatePicker

Svelte

DatePicker.svelte
<script>
  import { untrack } from "svelte";
  import { twMerge } from "tailwind-merge";
  import Calendar from "../calendar/Calendar.svelte";
  import {
    dateIsUnavailable,
    dateLabel,
    parseIsoDate,
    resolveLocale,
  } from "../calendar/date.js";
  import Input from "../input/Input.svelte";
  import Popover from "../popover/Popover.svelte";

  let {
    value = $bindable(),
    defaultValue,
    onchange,
    id,
    name,
    placeholder = "YYYY-MM-DD",
    min,
    max,
    unavailable,
    locale: localeProp,
    dir,
    open = $bindable(),
    defaultOpen = false,
    onopenchange,
    required = false,
    disabled = false,
    readonly = false,
    class: className,
    "aria-describedby": externalDescribedBy,
    ...inputProps
  } = $props();

  const componentId = $props.id();
  const generatedId = componentId.replace(/[^a-zA-Z0-9_-]/g, "");
  let inputId = $derived(id ?? `klean-date-picker-${generatedId}`);
  let popoverId = $derived(`${inputId}-calendar`);
  let descriptionId = $derived(`${inputId}-description`);
  let input;
  let popover;
  let locale = $derived(resolveLocale(localeProp));
  const initialDraft = untrack(() =>
    parseIsoDate(value)
      ? value
      : parseIsoDate(defaultValue)
        ? defaultValue
        : "",
  );
  let draft = $state(initialDraft);
  let invalid = $derived(
    Boolean(
      draft &&
        (!parseIsoDate(draft) ||
          dateIsUnavailable(draft, { min, max, unavailable })),
    ),
  );
  let describedValue = $derived(
    !invalid && parseIsoDate(draft)
      ? dateLabel(draft, locale, { weekday: "long" })
      : "",
  );
  let describedBy = $derived(
    [externalDescribedBy, describedValue && descriptionId]
      .filter(Boolean)
      .join(" ") || undefined,
  );

  function commit(nextValue) {
    if (disabled || readonly) return;
    if (nextValue && dateIsUnavailable(nextValue, { min, max, unavailable }))
      return;
    value = nextValue;
    draft = nextValue;
    onchange?.(nextValue);
  }

  function handleInput(event) {
    const nextValue = event.target.value.trim();
    draft = nextValue;
    if (
      !nextValue ||
      (parseIsoDate(nextValue) &&
        !dateIsUnavailable(nextValue, { min, max, unavailable }))
    ) {
      commit(nextValue);
    }
  }

  function chooseDate(nextValue) {
    commit(nextValue);
    popover?.close();
  }

  $effect(() => {
    if (value !== undefined && value !== draft && parseIsoDate(value)) {
      draft = value;
    }
  });

  $effect(() => {
    const element = input?.getElement();
    if (!element) return;
    if (required && !draft) element.setCustomValidity("Choose a date.");
    else if (invalid)
      element.setCustomValidity("Enter an available date as YYYY-MM-DD.");
    else element.setCustomValidity("");
  });

  export function focus(options) {
    input?.focus(options);
  }

  export function show() {
    popover?.show();
  }

  export function close() {
    popover?.close();
  }
</script>

<div
  data-slot="date-picker"
  class={twMerge(
    "relative flex w-full items-stretch **:data-[slot=input]:pe-12",
    className,
  )}
>
  <Input
    {...inputProps}
    bind:this={input}
    {name}
    id={inputId}
    type="text"
    inputmode="numeric"
    autocomplete="off"
    value={draft}
    {placeholder}
    {required}
    {disabled}
    {readonly}
    aria-invalid={invalid || undefined}
    aria-describedby={describedBy}
    data-slot="date-picker-input"
    oninput={handleInput}
    onclick={() => !disabled && !readonly && popover?.show()}
    onkeydown={(event) => {
      inputProps.onkeydown?.(event);
      if (
        !event.defaultPrevented &&
        event.key === "ArrowDown" &&
        !disabled &&
        !readonly
      ) {
        event.preventDefault();
        popover?.show();
      }
    }}
  />
  {#if describedValue}
    <span
      id={descriptionId}
      data-slot="date-picker-description"
      class="sr-only"
    >
      {describedValue}
    </span>
  {/if}
  <button
    type="button"
    popovertarget={popoverId}
    data-slot="date-picker-button"
    class="absolute inset-y-0 inset-e-0 grid min-w-11 place-items-center rounded-e-md text-gray-500 hover:bg-gray-100 hover:text-gray-950 focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:opacity-40 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white dark:focus-visible:outline-white"
    disabled={disabled || readonly}
    aria-label={draft ? `Change date, ${describedValue}` : "Choose a date"}
  >
    <svg
      aria-hidden="true"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      stroke-width="1.8"
      class="size-5"
    >
      <path
        d="M7 3v3M17 3v3M4 9h16M5 5h14a1 1 0 0 1 1 1v14H4V6a1 1 0 0 1 1-1Z"
      />
    </svg>
  </button>
  <Popover
    bind:this={popover}
    bind:open
    id={popoverId}
    {defaultOpen}
    onOpenChange={onopenchange}
    placement="bottom-start"
    data-slot="date-picker-popover"
    class="w-[min(22rem,calc(100vw-1rem))] p-0"
  >
    <Calendar
      value={parseIsoDate(value) ? value : undefined}
      defaultValue={parseIsoDate(draft) ? draft : undefined}
      {min}
      {max}
      {unavailable}
      {locale}
      {dir}
      {disabled}
      {readonly}
      onchange={chooseDate}
    />
  </Popover>
</div>

All open source projects are released under the MIT License.