Skip to content

Date Range Picker ​

Date Range Picker handles one decision with two date-only boundaries. It submits both dates as stable YYYY-MM-DD values and keeps the contiguous range visible in Calendar.

DateRangePicker.vue

Installation ​

The registry resolves Input, Popover, and Calendar before installing the framework-native range composition:

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-range-picker

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

When to use ​

Use Date Range Picker for reporting periods, stays, booking windows, availability, campaigns, and filters where start and end form one decision.

When not to use ​

Use two Date Pickers when the fields have different business meanings or asymmetric rules, such as invoice issue and due dates. Use Schedule Picker when either boundary needs a time and timezone. Use Calendar when the date surface should remain visible.

Usage ​

Vue ​

ReportingPeriod.vue
<script setup>
import { ref } from 'vue'
import DateRangePicker from '~/components/ui/date-range-picker/DateRangePicker.vue'

const period = ref({ start: '', end: '' })
</script>

<template>
  <DateRangePicker
    v-model="period"
    name="period"
    label="Reporting period"
    start-label="From"
    end-label="To"
    required
  />
</template>

React ​

ReportingPeriod.jsx
import { useState } from 'react'
import DateRangePicker from '@/components/ui/date-range-picker/DateRangePicker.jsx'

export default function ReportingPeriod() {
  const [period, setPeriod] = useState({ start: '', end: '' })

  return (
    <DateRangePicker
      value={period}
      onValueChange={setPeriod}
      name="period"
      label="Reporting period"
      startLabel="From"
      endLabel="To"
      required
    />
  )
}

Svelte ​

ReportingPeriod.svelte
<script>
  import DateRangePicker from '~/components/ui/date-range-picker/DateRangePicker.svelte'

  let period = $state({ start: '', end: '' })
</script>

<DateRangePicker
  bind:value={period}
  name="period"
  label="Reporting period"
  startLabel="From"
  endLabel="To"
  required
/>

Value and form contract ​

The value is { start, end }. A picker named period submits two native form entries: period[start] and period[end]. Committed state is always empty, start-only, or a complete ordered range. It never contains a partial date, an end without a start, or a timezone-dependent Date.

Ranges are inclusive: the start and end may be the same day. That is useful for one-day reports and stays. If a product requires at least one day between the boundaries, express that product rule with availability or compose two Date Pickers with derived min and max values.

Choosing a second date earlier than the first orders the result. The component does not leave the application with an inverted range.

Range rules ​

  • The calendar anchors to the field that opened it. It flips or shifts before leaving the viewport and follows the field through scroll, resize, and layout changes.
  • Start and end are inclusive; selecting the same date twice creates a valid one-day range.
  • Reverse calendar selection is ordered automatically. An inverted typed draft is reported as invalid without changing committed application state.
  • Clearing the start clears both boundaries. Typing an end without a start remains an invalid draft.
  • min and max constrain both boundaries. An unavailable date cannot be selected or crossed, so the result remains one contiguous range.
  • Arrow Down opens from either field. Escape and explicit dismissal return focus to that field; outside dismissal leaves focus at the new target.
  • Required, disabled, readonly, controlled, uncontrolled, locale, direction, and native form behavior need no additional configuration.

API ​

PurposeVueReactSvelte
Current rangev-modelvalue, onValueChangebind:value
Initial rangedefault-valuedefaultValuedefaultValue
Native form namesnamenamename
Group and field labelslabel, start-label, end-labellabel, startLabel, endLabellabel, startLabel, endLabel
Boundsmin, maxmin, maxmin, max
Product availabilityunavailableunavailableunavailable
Open statev-model:openopen, onOpenChangebind:open
Native statesrequired, disabled, readonlyrequired, disabled, readOnlyrequired, disabled, readonly

The root is a semantic fieldset with one legend and two labelled editable fields. Arrow Down opens Calendar from either field. Selecting the start keeps the surface open and advances to the end; selecting the end closes it and returns focus reliably.

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 — one always-visible date-only YYYY-MM-DD surface.
  • Date Picker — independent date-only YYYY-MM-DD values and asymmetric rules.
  • Schedule Picker — date and time as an exact ISO instant; future scheduling by default, historical editing with allowPast.
  • Popover — the floating surface used by the compact range.

Complete framework source ​

Vue ​

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

defineOptions({ inheritAttrs: false })

function normalizeRange(value) {
  return {
    start: parseIsoDate(value?.start) ? value.start : '',
    end: parseIsoDate(value?.end) ? value.end : ''
  }
}

function rangeContainsUnavailable(start, end, unavailable) {
  if (!unavailable || !parseIsoDate(start) || !parseIsoDate(end)) return false
  const [first, last] =
    compareDates(start, end) <= 0 ? [start, end] : [end, start]

  for (
    let date = first;
    date && compareDates(date, last) <= 0;
    date = addDays(date, 1)
  ) {
    if (unavailable(date)) return true
  }

  return false
}

const props = defineProps({
  /** Date-only ISO boundaries: { start: YYYY-MM-DD, end: YYYY-MM-DD }. */
  modelValue: { type: Object, default: undefined },
  defaultValue: { type: Object, default: () => ({}) },
  id: { type: String, default: undefined },
  /** Produces name[start] and name[end] native form entries. */
  name: { type: String, default: undefined },
  label: { type: String, default: 'Date range' },
  startLabel: { type: String, default: 'Start date' },
  endLabel: { type: String, default: 'End date' },
  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 baseId = computed(() => props.id ?? `klean-date-range-${generatedId}`)
const startId = computed(() => `${baseId.value}-start`)
const endId = computed(() => `${baseId.value}-end`)
const popoverId = computed(() => `${baseId.value}-calendar`)
const statusId = computed(() => `${baseId.value}-status`)
const internalValue = ref(normalizeRange(props.defaultValue))
const value = computed(() =>
  props.modelValue === undefined
    ? internalValue.value
    : normalizeRange(props.modelValue)
)
const startDraft = ref(value.value.start)
const endDraft = ref(value.value.end)
const activePart = ref('start')
const preview = ref('')
const internalPopoverOpen = ref(props.defaultOpen)
const startInput = ref()
const endInput = ref()
const popover = ref()
const locale = computed(() => resolveLocale(props.locale))
const limits = computed(() => ({
  min: props.min,
  max: props.max,
  unavailable: props.unavailable
}))
const startInvalid = computed(
  () =>
    Boolean(startDraft.value) &&
    (!parseIsoDate(startDraft.value) ||
      dateIsUnavailable(startDraft.value, limits.value))
)
const endInvalid = computed(
  () =>
    Boolean(endDraft.value) &&
    (!parseIsoDate(endDraft.value) ||
      dateIsUnavailable(endDraft.value, limits.value))
)
const endWithoutStartInvalid = computed(
  () => Boolean(endDraft.value) && !Boolean(startDraft.value)
)
const orderInvalid = computed(
  () =>
    parseIsoDate(startDraft.value) &&
    parseIsoDate(endDraft.value) &&
    compareDates(endDraft.value, startDraft.value) < 0
)
const rangeUnavailable = computed(
  () =>
    !orderInvalid.value &&
    rangeContainsUnavailable(
      startDraft.value,
      endDraft.value,
      props.unavailable
    )
)
const popoverOpen = computed(() =>
  props.open === undefined ? internalPopoverOpen.value : props.open
)
const calendarUnavailable = computed(
  () => (date) =>
    dateIsUnavailable(date, limits.value) ||
    (activePart.value === 'end' &&
      value.value.start &&
      rangeContainsUnavailable(value.value.start, date, props.unavailable))
)
const statusText = computed(() => {
  if (endWithoutStartInvalid.value)
    return 'Choose a start date before the end date.'
  if (orderInvalid.value)
    return 'The end date must be on or after the start date.'
  if (startInvalid.value || endInvalid.value) {
    return 'Enter available dates as YYYY-MM-DD.'
  }
  if (rangeUnavailable.value) {
    return 'The range cannot include an unavailable date.'
  }
  if (value.value.start && value.value.end) {
    return `${dateLabel(value.value.start, locale.value)} through ${dateLabel(value.value.end, locale.value)}.`
  }
  if (value.value.start) return 'Choose an end date.'
  return 'Choose a start date, then an end date.'
})
const orderedDecoration = computed(() => {
  const start = value.value.start
  const end = value.value.end || preview.value
  if (!start || !end) return { start, end }
  return compareDates(start, end) <= 0
    ? { start, end }
    : { start: end, end: start }
})
const calendarValue = computed(() =>
  activePart.value === 'end'
    ? value.value.end || preview.value || value.value.start
    : value.value.start
)
const fieldsetAttrs = computed(() => {
  const { class: _class, 'data-slot': _dataSlot, ...rest } = attrs
  return rest
})
const rootClasses = computed(() =>
  twMerge(
    'grid w-full gap-3 **:data-[slot=date-range-field]:relative **:data-[slot=date-range-field]:flex **:data-[slot=date-range-field]:items-stretch **:data-[slot=input]:pe-12',
    attrs.class
  )
)

function updateRange(nextRange) {
  const normalized = normalizeRange(nextRange)
  if (props.modelValue === undefined) internalValue.value = normalized
  emit('update:modelValue', normalized)
  emit('change', normalized)
}

function openPart(part, source) {
  if (props.disabled || props.readonly) return
  activePart.value = part
  preview.value = ''
  const input =
    part === 'end' ? endInput.value?.element : startInput.value?.element
  popover.value?.open(source ?? input)
}

function handleDraft(part, event) {
  const next = event.target.value.trim()
  if (part === 'start') startDraft.value = next
  else endDraft.value = next

  if (part === 'start' && !next) {
    endDraft.value = ''
    updateRange({ start: '', end: '' })
    return
  }
  if (part === 'end' && next && !value.value.start) return
  if (next && (!parseIsoDate(next) || dateIsUnavailable(next, limits.value)))
    return

  const nextRange = {
    start: part === 'start' ? next : value.value.start,
    end: part === 'end' ? next : value.value.end
  }
  if (
    nextRange.start &&
    nextRange.end &&
    compareDates(nextRange.end, nextRange.start) < 0
  ) {
    return
  }
  if (
    nextRange.start &&
    nextRange.end &&
    rangeContainsUnavailable(nextRange.start, nextRange.end, props.unavailable)
  ) {
    return
  }
  updateRange(nextRange)
}

function chooseDate(date) {
  if (props.disabled || props.readonly || calendarUnavailable.value(date))
    return

  if (activePart.value === 'start' || !value.value.start) {
    updateRange({ start: date, end: '' })
    startDraft.value = date
    endDraft.value = ''
    activePart.value = 'end'
    preview.value = date
    return
  }

  const range =
    compareDates(date, value.value.start) < 0
      ? { start: date, end: value.value.start }
      : { start: value.value.start, end: date }
  updateRange(range)
  startDraft.value = range.start
  endDraft.value = range.end
  preview.value = ''
  popover.value?.close()
}

function handleCalendarFocus(date) {
  if (
    activePart.value === 'end' &&
    value.value.start &&
    !calendarUnavailable.value(date)
  ) {
    preview.value = date
  }
}

function handleInputKeydown(event, part) {
  if (event.key !== 'ArrowDown' || props.disabled || props.readonly) return
  event.preventDefault()
  openPart(part, event.currentTarget)
}

function handleOpenChange(nextOpen) {
  if (props.open === undefined) internalPopoverOpen.value = nextOpen
  emit('update:open', nextOpen)
}

watch(value, (nextValue) => {
  startDraft.value = nextValue.start
  endDraft.value = nextValue.end
})

watch(
  () => [
    startInvalid.value,
    endInvalid.value,
    endWithoutStartInvalid.value,
    orderInvalid.value,
    rangeUnavailable.value,
    props.required,
    value.value.start,
    value.value.end
  ],
  async () => {
    await nextTick()
    const startElement = startInput.value?.element
    const endElement = endInput.value?.element
    if (startElement) {
      startElement.setCustomValidity(
        startInvalid.value || orderInvalid.value || rangeUnavailable.value
          ? statusText.value
          : props.required && !value.value.start
            ? 'Choose a start date.'
            : ''
      )
    }
    if (endElement) {
      endElement.setCustomValidity(
        endInvalid.value ||
          endWithoutStartInvalid.value ||
          orderInvalid.value ||
          rangeUnavailable.value
          ? statusText.value
          : props.required && !value.value.end
            ? 'Choose an end date.'
            : ''
      )
    }
  },
  { immediate: true }
)

defineExpose({
  focus: (part = 'start', options) =>
    (part === 'end' ? endInput.value : startInput.value)?.focus(options),
  open: (part = 'start') => openPart(part),
  close: () => popover.value?.close()
})
</script>

<template>
  <fieldset
    v-bind="fieldsetAttrs"
    data-slot="date-range-picker"
    :disabled="disabled"
    :class="rootClasses"
  >
    <legend data-slot="date-range-legend" class="text-sm font-medium">
      {{ label }}
    </legend>

    <div class="grid gap-3 sm:grid-cols-2">
      <div class="grid gap-2">
        <label :for="startId" class="text-sm text-gray-600 dark:text-gray-400">
          {{ startLabel }}
        </label>
        <div data-slot="date-range-field">
          <Input
            ref="startInput"
            :id="startId"
            type="text"
            inputmode="numeric"
            autocomplete="off"
            :name="name ? `${name}[start]` : undefined"
            :value="startDraft"
            placeholder="YYYY-MM-DD"
            :required="required"
            :readonly="readonly"
            :aria-invalid="
              startInvalid || orderInvalid || rangeUnavailable || undefined
            "
            :aria-describedby="statusId"
            :aria-controls="popoverId"
            :aria-expanded="popoverOpen && activePart === 'start'"
            aria-haspopup="grid"
            data-slot="date-range-start"
            @input="handleDraft('start', $event)"
            @click="openPart('start', $event.currentTarget)"
            @keydown="handleInputKeydown($event, 'start')"
          />
          <button
            type="button"
            :popovertarget="popoverId"
            popovertargetaction="show"
            data-slot="date-range-start-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="`Choose ${startLabel.toLowerCase()}`"
            aria-haspopup="grid"
            @click="activePart = 'start'"
          >
            <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>
        </div>
      </div>

      <div class="grid gap-2">
        <label :for="endId" class="text-sm text-gray-600 dark:text-gray-400">
          {{ endLabel }}
        </label>
        <div data-slot="date-range-field">
          <Input
            ref="endInput"
            :id="endId"
            type="text"
            inputmode="numeric"
            autocomplete="off"
            :name="name ? `${name}[end]` : undefined"
            :value="endDraft"
            placeholder="YYYY-MM-DD"
            :required="required"
            :readonly="readonly"
            :aria-invalid="
              endWithoutStartInvalid ||
              endInvalid ||
              orderInvalid ||
              rangeUnavailable ||
              undefined
            "
            :aria-describedby="statusId"
            :aria-controls="popoverId"
            :aria-expanded="popoverOpen && activePart === 'end'"
            aria-haspopup="grid"
            data-slot="date-range-end"
            @input="handleDraft('end', $event)"
            @click="openPart('end', $event.currentTarget)"
            @keydown="handleInputKeydown($event, 'end')"
          />
          <button
            type="button"
            :popovertarget="popoverId"
            popovertargetaction="show"
            data-slot="date-range-end-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="`Choose ${endLabel.toLowerCase()}`"
            aria-haspopup="grid"
            @click="activePart = 'end'"
          >
            <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>
        </div>
      </div>
    </div>

    <p
      :id="statusId"
      data-slot="date-range-status"
      class="text-sm text-gray-600 dark:text-gray-400"
      :class="{
        'text-red-700 dark:text-red-400':
          startInvalid ||
          endInvalid ||
          endWithoutStartInvalid ||
          orderInvalid ||
          rangeUnavailable
      }"
      aria-live="polite"
    >
      {{ statusText }}
    </p>

    <Popover
      ref="popover"
      :id="popoverId"
      :open="open"
      :default-open="defaultOpen"
      placement="bottom-start"
      data-slot="date-range-popover"
      class="w-[min(22rem,calc(100vw-1rem))] p-0"
      @update:open="handleOpenChange"
    >
      <Calendar
        :model-value="calendarValue"
        :default-value="value.start"
        :min="min"
        :max="max"
        :unavailable="calendarUnavailable"
        :range-start="orderedDecoration.start"
        :range-end="value.end ? orderedDecoration.end : undefined"
        :range-preview="!value.end ? orderedDecoration.end : undefined"
        :locale="locale"
        :dir="dir"
        :disabled="disabled"
        :readonly="readonly"
        @focus-change="handleCalendarFocus"
        @update:model-value="chooseDate"
      />
    </Popover>
  </fieldset>
</template>

React ​

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

function normalizeRange(value) {
  return {
    start: parseIsoDate(value?.start) ? value.start : '',
    end: parseIsoDate(value?.end) ? value.end : ''
  }
}

function rangeContainsUnavailable(start, end, unavailable) {
  if (!unavailable || !parseIsoDate(start) || !parseIsoDate(end)) return false
  const [first, last] =
    compareDates(start, end) <= 0 ? [start, end] : [end, start]

  for (
    let date = first;
    date && compareDates(date, last) <= 0;
    date = addDays(date, 1)
  ) {
    if (unavailable(date)) return true
  }

  return false
}

const DateRangePicker = forwardRef(function DateRangePicker(
  {
    value,
    defaultValue = {},
    onValueChange,
    id,
    name,
    label = 'Date range',
    startLabel = 'Start date',
    endLabel = 'End date',
    min,
    max,
    unavailable,
    locale: localeProp,
    dir,
    open,
    defaultOpen = false,
    onOpenChange,
    required = false,
    disabled = false,
    readOnly = false,
    className,
    ...fieldsetProps
  },
  forwardedRef
) {
  const generatedId = useId().replace(/[^a-zA-Z0-9_-]/g, '')
  const baseId = id ?? `klean-date-range-${generatedId}`
  const startId = `${baseId}-start`
  const endId = `${baseId}-end`
  const popoverId = `${baseId}-calendar`
  const statusId = `${baseId}-status`
  const [internalValue, setInternalValue] = useState(() =>
    normalizeRange(defaultValue)
  )
  const range = value === undefined ? internalValue : normalizeRange(value)
  const [startDraft, setStartDraft] = useState(range.start)
  const [endDraft, setEndDraft] = useState(range.end)
  const [activePart, setActivePart] = useState('start')
  const [preview, setPreview] = useState('')
  const [internalPopoverOpen, setInternalPopoverOpen] = useState(defaultOpen)
  const startInput = useRef(null)
  const endInput = useRef(null)
  const popoverRef = useRef(null)
  const locale = resolveLocale(localeProp)
  const limits = { min, max, unavailable }
  const startInvalid = Boolean(
    startDraft &&
      (!parseIsoDate(startDraft) || dateIsUnavailable(startDraft, limits))
  )
  const endInvalid = Boolean(
    endDraft && (!parseIsoDate(endDraft) || dateIsUnavailable(endDraft, limits))
  )
  const endWithoutStartInvalid = Boolean(endDraft && !startDraft)
  const orderInvalid = Boolean(
    parseIsoDate(startDraft) &&
      parseIsoDate(endDraft) &&
      compareDates(endDraft, startDraft) < 0
  )
  const rangeUnavailable = Boolean(
    !orderInvalid && rangeContainsUnavailable(startDraft, endDraft, unavailable)
  )
  const popoverOpen = open === undefined ? internalPopoverOpen : open
  let statusText
  if (endWithoutStartInvalid)
    statusText = 'Choose a start date before the end date.'
  else if (orderInvalid)
    statusText = 'The end date must be on or after the start date.'
  else if (startInvalid || endInvalid)
    statusText = 'Enter available dates as YYYY-MM-DD.'
  else if (rangeUnavailable)
    statusText = 'The range cannot include an unavailable date.'
  else if (range.start && range.end)
    statusText = `${dateLabel(range.start, locale)} through ${dateLabel(range.end, locale)}.`
  else if (range.start) statusText = 'Choose an end date.'
  else statusText = 'Choose a start date, then an end date.'
  const decoratedEnd = range.end || preview
  const decoration =
    range.start && decoratedEnd && compareDates(range.start, decoratedEnd) > 0
      ? { start: decoratedEnd, end: range.start }
      : { start: range.start, end: decoratedEnd }
  const calendarValue =
    activePart === 'end' ? range.end || preview || range.start : range.start

  function updateRange(nextRange) {
    const normalized = normalizeRange(nextRange)
    if (value === undefined) setInternalValue(normalized)
    onValueChange?.(normalized)
  }

  function calendarUnavailable(date) {
    return (
      dateIsUnavailable(date, limits) ||
      (activePart === 'end' &&
        range.start &&
        rangeContainsUnavailable(range.start, date, unavailable))
    )
  }

  function openPart(part, source) {
    if (disabled || readOnly) return
    setActivePart(part)
    setPreview('')
    const input = part === 'end' ? endInput.current : startInput.current
    popoverRef.current?.open(source ?? input)
  }

  function handleDraft(part, event) {
    const next = event.target.value.trim()
    if (part === 'start') setStartDraft(next)
    else setEndDraft(next)

    if (part === 'start' && !next) {
      setEndDraft('')
      updateRange({ start: '', end: '' })
      return
    }
    if (part === 'end' && next && !range.start) return
    if (next && (!parseIsoDate(next) || dateIsUnavailable(next, limits))) return
    const nextRange = {
      start: part === 'start' ? next : range.start,
      end: part === 'end' ? next : range.end
    }
    if (
      nextRange.start &&
      nextRange.end &&
      compareDates(nextRange.end, nextRange.start) < 0
    ) {
      return
    }
    if (
      nextRange.start &&
      nextRange.end &&
      rangeContainsUnavailable(nextRange.start, nextRange.end, unavailable)
    ) {
      return
    }
    updateRange(nextRange)
  }

  function chooseDate(date) {
    if (disabled || readOnly || calendarUnavailable(date)) return

    if (activePart === 'start' || !range.start) {
      updateRange({ start: date, end: '' })
      setStartDraft(date)
      setEndDraft('')
      setActivePart('end')
      setPreview(date)
      return
    }
    const nextRange =
      compareDates(date, range.start) < 0
        ? { start: date, end: range.start }
        : { start: range.start, end: date }
    updateRange(nextRange)
    setStartDraft(nextRange.start)
    setEndDraft(nextRange.end)
    setPreview('')
    popoverRef.current?.close()
  }

  function handleOpenChange(nextOpen) {
    if (open === undefined) setInternalPopoverOpen(nextOpen)
    onOpenChange?.(nextOpen)
  }

  useEffect(() => {
    setStartDraft(range.start)
    setEndDraft(range.end)
  }, [range.end, range.start])

  useEffect(() => {
    if (startInput.current) {
      startInput.current.setCustomValidity(
        startInvalid || orderInvalid || rangeUnavailable
          ? statusText
          : required && !range.start
            ? 'Choose a start date.'
            : ''
      )
    }
    if (endInput.current) {
      endInput.current.setCustomValidity(
        endInvalid || endWithoutStartInvalid || orderInvalid || rangeUnavailable
          ? statusText
          : required && !range.end
            ? 'Choose an end date.'
            : ''
      )
    }
  }, [
    endInvalid,
    endWithoutStartInvalid,
    orderInvalid,
    rangeUnavailable,
    range.end,
    range.start,
    required,
    startInvalid,
    statusText
  ])

  useImperativeHandle(forwardedRef, () => ({
    focus: (part = 'start', options) =>
      (part === 'end' ? endInput.current : startInput.current)?.focus(options),
    open: (part = 'start') => openPart(part),
    close: () => popoverRef.current?.close()
  }))

  const calendarButtonClasses =
    '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'

  return (
    <fieldset
      {...fieldsetProps}
      data-slot="date-range-picker"
      disabled={disabled}
      className={twMerge(
        'grid w-full gap-3 **:data-[slot=date-range-field]:relative **:data-[slot=date-range-field]:flex **:data-[slot=date-range-field]:items-stretch **:data-[slot=input]:pe-12',
        className
      )}
    >
      <legend data-slot="date-range-legend" className="text-sm font-medium">
        {label}
      </legend>
      <div className="grid gap-3 sm:grid-cols-2">
        <div className="grid gap-2">
          <label
            htmlFor={startId}
            className="text-sm text-gray-600 dark:text-gray-400"
          >
            {startLabel}
          </label>
          <div data-slot="date-range-field">
            <Input
              ref={startInput}
              id={startId}
              type="text"
              inputMode="numeric"
              autoComplete="off"
              name={name ? `${name}[start]` : undefined}
              value={startDraft}
              placeholder="YYYY-MM-DD"
              required={required}
              readOnly={readOnly}
              aria-invalid={
                startInvalid || orderInvalid || rangeUnavailable || undefined
              }
              aria-describedby={statusId}
              aria-controls={popoverId}
              aria-expanded={popoverOpen && activePart === 'start'}
              aria-haspopup="grid"
              data-slot="date-range-start"
              onChange={(event) => handleDraft('start', event)}
              onClick={(event) => openPart('start', event.currentTarget)}
              onKeyDown={(event) => {
                if (event.key === 'ArrowDown' && !disabled && !readOnly) {
                  event.preventDefault()
                  openPart('start', event.currentTarget)
                }
              }}
            />
            <button
              type="button"
              popoverTarget={popoverId}
              popoverTargetAction="show"
              data-slot="date-range-start-button"
              className={calendarButtonClasses}
              disabled={disabled || readOnly}
              aria-label={`Choose ${startLabel.toLowerCase()}`}
              aria-haspopup="grid"
              onClick={() => setActivePart('start')}
            >
              <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>
          </div>
        </div>
        <div className="grid gap-2">
          <label
            htmlFor={endId}
            className="text-sm text-gray-600 dark:text-gray-400"
          >
            {endLabel}
          </label>
          <div data-slot="date-range-field">
            <Input
              ref={endInput}
              id={endId}
              type="text"
              inputMode="numeric"
              autoComplete="off"
              name={name ? `${name}[end]` : undefined}
              value={endDraft}
              placeholder="YYYY-MM-DD"
              required={required}
              readOnly={readOnly}
              aria-invalid={
                endInvalid ||
                endWithoutStartInvalid ||
                orderInvalid ||
                rangeUnavailable ||
                undefined
              }
              aria-describedby={statusId}
              aria-controls={popoverId}
              aria-expanded={popoverOpen && activePart === 'end'}
              aria-haspopup="grid"
              data-slot="date-range-end"
              onChange={(event) => handleDraft('end', event)}
              onClick={(event) => openPart('end', event.currentTarget)}
              onKeyDown={(event) => {
                if (event.key === 'ArrowDown' && !disabled && !readOnly) {
                  event.preventDefault()
                  openPart('end', event.currentTarget)
                }
              }}
            />
            <button
              type="button"
              popoverTarget={popoverId}
              popoverTargetAction="show"
              data-slot="date-range-end-button"
              className={calendarButtonClasses}
              disabled={disabled || readOnly}
              aria-label={`Choose ${endLabel.toLowerCase()}`}
              aria-haspopup="grid"
              onClick={() => setActivePart('end')}
            >
              <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>
          </div>
        </div>
      </div>
      <p
        id={statusId}
        data-slot="date-range-status"
        className={twMerge(
          'text-sm text-gray-600 dark:text-gray-400',
          (startInvalid ||
            endInvalid ||
            endWithoutStartInvalid ||
            orderInvalid ||
            rangeUnavailable) &&
            'text-red-700 dark:text-red-400'
        )}
        aria-live="polite"
      >
        {statusText}
      </p>
      <Popover
        ref={popoverRef}
        id={popoverId}
        open={open}
        defaultOpen={defaultOpen}
        onOpenChange={handleOpenChange}
        placement="bottom-start"
        data-slot="date-range-popover"
        className="w-[min(22rem,calc(100vw-1rem))] p-0"
      >
        <Calendar
          value={calendarValue}
          defaultValue={range.start}
          min={min}
          max={max}
          unavailable={calendarUnavailable}
          rangeStart={decoration.start}
          rangeEnd={range.end ? decoration.end : undefined}
          rangePreview={!range.end ? decoration.end : undefined}
          locale={locale}
          dir={dir}
          disabled={disabled}
          readOnly={readOnly}
          onFocusChange={(date) => {
            if (
              activePart === 'end' &&
              range.start &&
              !calendarUnavailable(date)
            ) {
              setPreview(date)
            }
          }}
          onValueChange={chooseDate}
        />
      </Popover>
    </fieldset>
  )
})

export default DateRangePicker

Svelte ​

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

  function normalizeRange(candidate) {
    return {
      start: parseIsoDate(candidate?.start) ? candidate.start : "",
      end: parseIsoDate(candidate?.end) ? candidate.end : "",
    };
  }

  function rangeContainsUnavailable(start, end, unavailable) {
    if (!unavailable || !parseIsoDate(start) || !parseIsoDate(end))
      return false;
    const [first, last] =
      compareDates(start, end) <= 0 ? [start, end] : [end, start];

    for (
      let date = first;
      date && compareDates(date, last) <= 0;
      date = addDays(date, 1)
    ) {
      if (unavailable(date)) return true;
    }

    return false;
  }

  let {
    value = $bindable(),
    defaultValue = {},
    onchange,
    id,
    name,
    label = "Date range",
    startLabel = "Start date",
    endLabel = "End date",
    min,
    max,
    unavailable,
    locale: localeProp,
    dir,
    open = $bindable(),
    defaultOpen = false,
    onopenchange,
    required = false,
    disabled = false,
    readonly = false,
    class: className,
    ...fieldsetProps
  } = $props();

  const initialRange = normalizeRange(
    untrack(() => (value === undefined ? defaultValue : value)),
  );
  if (untrack(() => value) === undefined) value = initialRange;
  const componentId = $props.id();
  const generatedId = componentId.replace(/[^a-zA-Z0-9_-]/g, "");
  let baseId = $derived(id ?? `klean-date-range-${generatedId}`);
  let startId = $derived(`${baseId}-start`);
  let endId = $derived(`${baseId}-end`);
  let popoverId = $derived(`${baseId}-calendar`);
  let statusId = $derived(`${baseId}-status`);
  let range = $derived(normalizeRange(value));
  let startDraft = $state(initialRange.start);
  let endDraft = $state(initialRange.end);
  let activePart = $state("start");
  let preview = $state("");
  let internalPopoverOpen = $state(untrack(() => defaultOpen));
  let startInput;
  let endInput;
  let popover;
  let locale = $derived(resolveLocale(localeProp));
  let startInvalid = $derived(
    Boolean(
      startDraft &&
        (!parseIsoDate(startDraft) ||
          dateIsUnavailable(startDraft, { min, max, unavailable })),
    ),
  );
  let endInvalid = $derived(
    Boolean(
      endDraft &&
        (!parseIsoDate(endDraft) ||
          dateIsUnavailable(endDraft, { min, max, unavailable })),
    ),
  );
  let endWithoutStartInvalid = $derived(Boolean(endDraft && !startDraft));
  let orderInvalid = $derived(
    Boolean(
      parseIsoDate(startDraft) &&
        parseIsoDate(endDraft) &&
        compareDates(endDraft, startDraft) < 0,
    ),
  );
  let rangeUnavailable = $derived(
    !orderInvalid &&
      rangeContainsUnavailable(startDraft, endDraft, unavailable),
  );
  let popoverOpen = $derived(open ?? internalPopoverOpen);
  let statusText = $derived.by(() => {
    if (endWithoutStartInvalid)
      return "Choose a start date before the end date.";
    if (orderInvalid) return "The end date must be on or after the start date.";
    if (startInvalid || endInvalid)
      return "Enter available dates as YYYY-MM-DD.";
    if (rangeUnavailable)
      return "The range cannot include an unavailable date.";
    if (range.start && range.end)
      return `${dateLabel(range.start, locale)} through ${dateLabel(range.end, locale)}.`;
    if (range.start) return "Choose an end date.";
    return "Choose a start date, then an end date.";
  });
  let decoration = $derived.by(() => {
    const decoratedEnd = range.end || preview;
    return range.start &&
      decoratedEnd &&
      compareDates(range.start, decoratedEnd) > 0
      ? { start: decoratedEnd, end: range.start }
      : { start: range.start, end: decoratedEnd };
  });
  let calendarValue = $derived(
    activePart === "end" ? range.end || preview || range.start : range.start,
  );
  const calendarButtonClasses =
    "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";

  function updateRange(nextRange) {
    value = normalizeRange(nextRange);
    onchange?.(value);
  }

  function calendarUnavailable(date) {
    return (
      dateIsUnavailable(date, { min, max, unavailable }) ||
      (activePart === "end" &&
        range.start &&
        rangeContainsUnavailable(range.start, date, unavailable))
    );
  }

  function openPart(part, source) {
    if (disabled || readonly) return;
    activePart = part;
    preview = "";
    const input =
      part === "end" ? endInput?.getElement() : startInput?.getElement();
    popover?.show(source ?? input);
  }

  function handleDraft(part, event) {
    const next = event.target.value.trim();
    if (part === "start") startDraft = next;
    else endDraft = next;

    if (part === "start" && !next) {
      endDraft = "";
      updateRange({ start: "", end: "" });
      return;
    }
    if (part === "end" && next && !range.start) return;
    if (
      next &&
      (!parseIsoDate(next) ||
        dateIsUnavailable(next, { min, max, unavailable }))
    )
      return;
    const nextRange = {
      start: part === "start" ? next : range.start,
      end: part === "end" ? next : range.end,
    };
    if (
      nextRange.start &&
      nextRange.end &&
      compareDates(nextRange.end, nextRange.start) < 0
    ) {
      return;
    }
    if (
      nextRange.start &&
      nextRange.end &&
      rangeContainsUnavailable(nextRange.start, nextRange.end, unavailable)
    ) {
      return;
    }
    updateRange(nextRange);
  }

  function chooseDate(date) {
    if (disabled || readonly || calendarUnavailable(date)) return;

    if (activePart === "start" || !range.start) {
      updateRange({ start: date, end: "" });
      startDraft = date;
      endDraft = "";
      activePart = "end";
      preview = date;
      return;
    }
    const nextRange =
      compareDates(date, range.start) < 0
        ? { start: date, end: range.start }
        : { start: range.start, end: date };
    updateRange(nextRange);
    startDraft = nextRange.start;
    endDraft = nextRange.end;
    preview = "";
    popover?.close();
  }

  function handleKeydown(event, part) {
    if (event.key !== "ArrowDown" || disabled || readonly) return;
    event.preventDefault();
    openPart(part, event.currentTarget);
  }

  function handleOpenChange(nextOpen) {
    if (open === undefined) internalPopoverOpen = nextOpen;
    onopenchange?.(nextOpen);
  }

  $effect(() => {
    startDraft = range.start;
    endDraft = range.end;
  });

  $effect(() => {
    const startElement = startInput?.getElement();
    const endElement = endInput?.getElement();
    if (startElement) {
      startElement.setCustomValidity(
        startInvalid || orderInvalid || rangeUnavailable
          ? statusText
          : required && !range.start
            ? "Choose a start date."
            : "",
      );
    }
    if (endElement) {
      endElement.setCustomValidity(
        endInvalid || endWithoutStartInvalid || orderInvalid || rangeUnavailable
          ? statusText
          : required && !range.end
            ? "Choose an end date."
            : "",
      );
    }
  });

  export function focus(part = "start", options) {
    (part === "end" ? endInput : startInput)?.focus(options);
  }

  export function show(part = "start") {
    openPart(part);
  }

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

<fieldset
  {...fieldsetProps}
  data-slot="date-range-picker"
  {disabled}
  class={twMerge(
    "grid w-full gap-3 **:data-[slot=date-range-field]:relative **:data-[slot=date-range-field]:flex **:data-[slot=date-range-field]:items-stretch **:data-[slot=input]:pe-12",
    className,
  )}
>
  <legend data-slot="date-range-legend" class="text-sm font-medium">
    {label}
  </legend>
  <div class="grid gap-3 sm:grid-cols-2">
    <div class="grid gap-2">
      <label for={startId} class="text-sm text-gray-600 dark:text-gray-400">
        {startLabel}
      </label>
      <div data-slot="date-range-field">
        <Input
          bind:this={startInput}
          id={startId}
          type="text"
          inputmode="numeric"
          autocomplete="off"
          name={name ? `${name}[start]` : undefined}
          value={startDraft}
          placeholder="YYYY-MM-DD"
          {required}
          {readonly}
          aria-invalid={startInvalid ||
            orderInvalid ||
            rangeUnavailable ||
            undefined}
          aria-describedby={statusId}
          aria-controls={popoverId}
          aria-expanded={popoverOpen && activePart === "start"}
          aria-haspopup="grid"
          data-slot="date-range-start"
          oninput={(event) => handleDraft("start", event)}
          onclick={(event) => openPart("start", event.currentTarget)}
          onkeydown={(event) => handleKeydown(event, "start")}
        />
        <button
          type="button"
          popovertarget={popoverId}
          popovertargetaction="show"
          data-slot="date-range-start-button"
          class={calendarButtonClasses}
          disabled={disabled || readonly}
          aria-label={`Choose ${startLabel.toLowerCase()}`}
          aria-haspopup="grid"
          onclick={() => (activePart = "start")}
        >
          <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>
      </div>
    </div>
    <div class="grid gap-2">
      <label for={endId} class="text-sm text-gray-600 dark:text-gray-400">
        {endLabel}
      </label>
      <div data-slot="date-range-field">
        <Input
          bind:this={endInput}
          id={endId}
          type="text"
          inputmode="numeric"
          autocomplete="off"
          name={name ? `${name}[end]` : undefined}
          value={endDraft}
          placeholder="YYYY-MM-DD"
          {required}
          {readonly}
          aria-invalid={endInvalid ||
            endWithoutStartInvalid ||
            orderInvalid ||
            rangeUnavailable ||
            undefined}
          aria-describedby={statusId}
          aria-controls={popoverId}
          aria-expanded={popoverOpen && activePart === "end"}
          aria-haspopup="grid"
          data-slot="date-range-end"
          oninput={(event) => handleDraft("end", event)}
          onclick={(event) => openPart("end", event.currentTarget)}
          onkeydown={(event) => handleKeydown(event, "end")}
        />
        <button
          type="button"
          popovertarget={popoverId}
          popovertargetaction="show"
          data-slot="date-range-end-button"
          class={calendarButtonClasses}
          disabled={disabled || readonly}
          aria-label={`Choose ${endLabel.toLowerCase()}`}
          aria-haspopup="grid"
          onclick={() => (activePart = "end")}
        >
          <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>
      </div>
    </div>
  </div>
  <p
    id={statusId}
    data-slot="date-range-status"
    class={twMerge(
      "text-sm text-gray-600 dark:text-gray-400",
      (startInvalid ||
        endInvalid ||
        endWithoutStartInvalid ||
        orderInvalid ||
        rangeUnavailable) &&
        "text-red-700 dark:text-red-400",
    )}
    aria-live="polite"
  >
    {statusText}
  </p>
  <Popover
    bind:this={popover}
    bind:open
    id={popoverId}
    {defaultOpen}
    onOpenChange={handleOpenChange}
    placement="bottom-start"
    data-slot="date-range-popover"
    class="w-[min(22rem,calc(100vw-1rem))] p-0"
  >
    <Calendar
      value={calendarValue}
      defaultValue={range.start}
      {min}
      {max}
      unavailable={calendarUnavailable}
      rangeStart={decoration.start}
      rangeEnd={range.end ? decoration.end : undefined}
      rangePreview={!range.end ? decoration.end : undefined}
      {locale}
      {dir}
      {disabled}
      {readonly}
      onfocuschange={(date) => {
        if (activePart === "end" && range.start && !calendarUnavailable(date)) {
          preview = date;
        }
      }}
      onchange={chooseDate}
    />
  </Popover>
</fieldset>

All open source projects are released under the MIT License.