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.
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.
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
<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
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
<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.
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
| Purpose | Vue | React | Svelte |
|---|---|---|---|
| Current value | v-model | value, onValueChange | bind:value |
| Initial value | default-value | defaultValue | defaultValue |
| Native form name | name | name | name |
| Bounds | min, max | min, max | min, max |
| Product availability | unavailable | unavailable | unavailable |
| Locale | locale, dir | locale, dir | locale, dir |
| Open state | v-model:open | open, onOpenChange | bind:open |
| Native states | required, disabled, readonly | required, disabled, readOnly | required, 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.
Related components
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-DDsurface. - Date Range Picker — ordered date-only
YYYY-MM-DDstart 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
<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
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
<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>