DateRangePicker

Combines start and end date fields and a RangeCalendar in a popover for picking a date range

Usage

<script setup lang="ts">
import { DateRangePicker } from '@hareui/vue'
</script>
Trip dates
mm
dd
yyyy
mm
dd
yyyy
<script setup lang="ts">import { DateRangePicker } from '@hareui/vue'</script><template>  <DateRangePicker class="w-80" full-width start-name="startDate" end-name="endDate" label="Trip dates" calendar-label="Trip dates" /></template>

Anatomy

DateRangePicker renders a Label, a segmented date-input-group holding the start and end segments, a range separator and a calendar trigger button, a Description/FieldError, and a popover with an embedded RangeCalendar. The text parts come from props; each can be replaced with a slot.

<template>
  <DateRangePicker label="…" calendar-label="…" start-name="…" end-name="…" description="…" error-message="…">
    <template #label />       <!-- Label content -->
    <template #separator />   <!-- replaces the default " - " between the inputs -->
    <template #indicator />   <!-- replaces the default calendar icon in the trigger -->
    <template #description /> <!-- Description content -->
    <template #error />       <!-- FieldError content, rendered when invalid -->
    <template #calendar />    <!-- replaces the default RangeCalendar rendered inside the popover -->
  </DateRangePicker>
</template>
ui keydata-slotElement
basedate-range-picker<div role="group"> root
—labelLabel (iff label / #label)
groupdate-input-group<div role="group"> holding the inputs and the trigger
inputContainerdate-input-group-input-containerscrollable wrapper around both inputs (iff input-container)
inputdate-input-group-inputone per end (slot="start" / slot="end")
segmentdate-input-group-segmentone per date part (year, month, day, …)
rangeSeparatordate-range-picker-range-separator<span> between the start and end inputs
triggerdate-range-picker-trigger<button> that opens the popover, rendered in the group's suffix
triggerIndicatordate-range-picker-trigger-indicator<span> holding the default/custom calendar icon
—descriptionDescription (iff description / #description)
—field-errorFieldError (while invalid, iff error-message / #error / validation errors)
popoverdate-range-picker-popoverthe popover content wrapping the RangeCalendar

DateRangePicker values are { start, end } objects of @internationalized/date values (CalendarDate, CalendarDateTime or ZonedDateTime) — the same types HeroUI uses. Like React Aria, v-model only emits complete ranges (or null once both ends are cleared).

Examples

Disabled

Trip dates
10
3
2026
10
7
2026
This date range picker is disabled.
<script setup lang="ts">import { getLocalTimeZone, today } from '@internationalized/date'import { DateRangePicker } from '@hareui/vue'const start = today(getLocalTimeZone())</script><template>  <DateRangePicker    disabled    class="w-80"    full-width    start-name="startDate"    end-name="endDate"    label="Trip dates"    calendar-label="Trip dates"    :model-value="{ start, end: start.add({ days: 4 }) }"    description="This date range picker is disabled."  /></template>

Controlled

Trip dates
10
3
2026
10
7
2026
Current value: 2026-10-03 -> 2026-10-07
<script setup lang="ts">import type { DateRangeValue } from '@hareui/vue'import { getLocalTimeZone, today } from '@internationalized/date'import { Button, DateRangePicker, Description } from '@hareui/vue'import { shallowRef } from 'vue'const start = today(getLocalTimeZone())const value = shallowRef<DateRangeValue | null>({ start, end: start.add({ days: 4 }) })function setWeek() {  const nextStart = today(getLocalTimeZone())  value.value = { start: nextStart, end: nextStart.add({ days: 6 }) }}</script><template>  <div class="flex w-80 flex-col gap-4">    <DateRangePicker v-model="value" full-width start-name="startDate" end-name="endDate" label="Trip dates" calendar-label="Trip dates" />    <Description>      Current value: {{ value ? `${value.start.toString()} -> ${value.end.toString()}` : '(empty)' }}    </Description>    <div class="flex gap-2">      <Button variant="tertiary" @click="setWeek">        Set week      </Button>      <Button variant="tertiary" @click="value = null">        Clear      </Button>    </div>  </div></template>

Validation

Booking period
mm
dd
yyyy
mm
dd
yyyy
<script setup lang="ts">import type { DateRangeValue } from '@hareui/vue'import { getLocalTimeZone, today } from '@internationalized/date'import { DateRangePicker } from '@hareui/vue'import { computed, shallowRef } from 'vue'const value = shallowRef<DateRangeValue | null>(null)const currentDate = today(getLocalTimeZone())const isInvalid = computed(() => value.value != null && (value.value.start.compare(currentDate) < 0 || value.value.end.compare(value.value.start) < 0))</script><template>  <DateRangePicker    v-model="value"    required    class="w-80"    full-width    :invalid="isInvalid"    :min-value="currentDate"    start-name="startDate"    end-name="endDate"    label="Booking period"    calendar-label="Booking period"    error-message="Select a valid range starting today or later."  /></template>

Format Options

Control how DateRangePicker values are displayed with props such as granularity, hour-cycle and hide-time-zone. For granularities beyond "day", the #calendar slot composes two TimeFields alongside the RangeCalendar; onChange commits a time edit without closing the popover.

Date range
2
3
2026
8
45
AM
UTC
2
10
2026
6
45
PM
UTC
Granularity
Hour cycle
<script setup lang="ts">import type { CalendarDateTime, DateValue, ZonedDateTime } from '@internationalized/date'import type { DateRangeValue, ListBoxItem } from '@hareui/vue'import type { TimeValue } from 'reka-ui'import { DateFormatter, getLocalTimeZone, parseDate, parseZonedDateTime, Time } from '@internationalized/date'import { DateRangePicker, Label, RangeCalendar, Select, Separator, Switch, TimeField } from '@hareui/vue'import { computed, ref, shallowRef } from 'vue'type Granularity = 'day' | 'hour' | 'minute' | 'second'type HourCycle = 12 | 24const granularityOptions: ListBoxItem[] = [  { label: 'Day', value: 'day' },  { label: 'Hour', value: 'hour' },  { label: 'Minute', value: 'minute' },  { label: 'Second', value: 'second' },]const hourCycleOptions: ListBoxItem[] = [  { label: '12-hour', value: 12 },  { label: '24-hour', value: 24 },]const granularity = ref<Granularity>('minute')const hourCycle = ref<HourCycle>(12)const hideTimeZone = ref(false)const timeGranularity = computed(() => (granularity.value === 'day' ? undefined : granularity.value))function defaultValueFor(g: Granularity): DateRangeValue {  const tz = getLocalTimeZone()  if (g === 'day')    return { start: parseDate('2025-02-03'), end: parseDate('2025-02-10') }  return { start: parseZonedDateTime(`2026-02-03T08:45:00[${tz}]`), end: parseZonedDateTime(`2026-02-10T18:45:00[${tz}]`) }}const value = shallowRef<DateRangeValue | null>(defaultValueFor(granularity.value))function onGranularityChange(next: string | number) {  granularity.value = next as Granularity  value.value = defaultValueFor(granularity.value)}const dateFormatter = new DateFormatter('en-US', { day: 'numeric', month: 'short', year: 'numeric' })function formatRange(range: DateRangeValue) {  const tz = getLocalTimeZone()  return dateFormatter.formatRange(range.start.toDate(tz), range.end.toDate(tz))}type WithTime = CalendarDateTime | ZonedDateTimefunction hasTime(v: DateValue | undefined): v is WithTime {  return !!v && 'hour' in v}function timeOf(v: DateValue | undefined) {  return hasTime(v) ? new Time(v.hour, v.minute, v.second) : undefined}// The calendar only edits the date and the time fields only edit the time; each merges onto the other.function withDate(picked: DateValue, current: DateValue | undefined): DateValue {  return hasTime(current) ? current.set({ year: picked.year, month: picked.month, day: picked.day }) : picked}function withTime(time: TimeValue, current: DateValue): DateValue {  return hasTime(current) ? current.set({ hour: time.hour, minute: time.minute, second: time.second }) : current}</script><template>  <div class="flex w-full flex-col gap-4">    <DateRangePicker      :key="granularity"      v-model="value"      class="w-max min-w-80"      input-container      :granularity="granularity"      :hour-cycle="hourCycle"      :hide-time-zone="hideTimeZone"      start-name="startDate"      end-name="endDate"      label="Date range"      :ui="{ popover: 'flex w-63 max-w-63 flex-col gap-3' }"    >      <template #calendar="{ value: range, onSelect, onChange }">        <RangeCalendar          aria-label="Trip dates"          class="w-full"          year-picker          :model-value="range ?? undefined"          @update:model-value="(r) => r?.start && r.end && onSelect({ start: withDate(r.start, range?.start), end: withDate(r.end, range?.end) })"        />        <div v-if="timeGranularity && range" class="flex flex-col gap-3">          <div class="flex items-center justify-between">            <Label>Start Time</Label>            <TimeField              aria-label="Start Time"              name="startTime"              variant="secondary"              :granularity="timeGranularity"              :hour-cycle="hourCycle"              :hide-time-zone="hideTimeZone"              :model-value="timeOf(range.start)"              @update:model-value="(t) => t && onChange({ start: withTime(t, range.start), end: range.end })"            />          </div>          <div class="flex items-center justify-between">            <Label>End Time</Label>            <TimeField              aria-label="End Time"              name="endTime"              variant="secondary"              :granularity="timeGranularity"              :hour-cycle="hourCycle"              :hide-time-zone="hideTimeZone"              :model-value="timeOf(range.end)"              @update:model-value="(t) => t && onChange({ start: range.start, end: withTime(t, range.end) })"            />          </div>        </div>        <span class="mt-1 text-xs text-muted">          Selected: {{ range ? formatRange(range) : 'No date selected' }}        </span>      </template>    </DateRangePicker>    <Separator class="my-5" />    <Label class="text-xs font-medium text-muted">      Format Options    </Label>    <div class="flex flex-wrap gap-4">      <Select class="w-[120px]" label="Granularity" name="granularity" variant="secondary" :items="granularityOptions" :model-value="granularity" @update:model-value="(v) => onGranularityChange(v as string)" />      <Select class="w-[120px]" label="Hour cycle" variant="secondary" :items="hourCycleOptions" :model-value="hourCycle" @update:model-value="(v) => (hourCycle = Number(v) as HourCycle)" />    </div>    <div class="flex min-w-[529px] flex-col gap-2">      <Switch v-model="hideTimeZone" label="Hide timezone" />    </div>  </div></template>

Input Container

input-container wraps the start and end inputs in a horizontally scrollable container (HeroUI's DateField.InputContainer), so long date-time ranges scroll inside the group instead of overflowing it.

Date range
2
3
2026
8
45
00
AM
UTC
2
10
2026
6
45
00
PM
UTC
<script setup lang="ts">import type { DateValue } from '@internationalized/date'import type { DateRangeValue } from '@hareui/vue'import { getLocalTimeZone, parseZonedDateTime, Time } from '@internationalized/date'import { DateRangePicker, Label, RangeCalendar, TimeField } from '@hareui/vue'const localTimeZone = getLocalTimeZone()const defaultValue: DateRangeValue = {  start: parseZonedDateTime(`2026-02-03T08:45:00[${localTimeZone}]`),  end: parseZonedDateTime(`2026-02-10T18:45:00[${localTimeZone}]`),}type WithTime = DateValue & { hour: number, minute: number, second: number, set: (fields: object) => DateValue }const timeOf = (v: DateValue) => new Time((v as WithTime).hour, (v as WithTime).minute, (v as WithTime).second)const withDate = (picked: DateValue, current: DateValue | undefined) => current ? (current as WithTime).set({ year: picked.year, month: picked.month, day: picked.day }) : pickedconst withTime = (time: Time, current: DateValue) => (current as WithTime).set({ hour: time.hour, minute: time.minute, second: time.second })</script><template>  <DateRangePicker    class="w-full max-w-2xs min-w-80"    input-container    granularity="second"    :hour-cycle="12"    :default-value="defaultValue"    label="Date range"    :ui="{ popover: 'flex w-fit flex-col gap-3' }"  >    <template #calendar="{ value: range, onSelect, onChange }">      <RangeCalendar        aria-label="Trip dates"        year-picker        :model-value="range ?? undefined"        @update:model-value="(r) => r?.start && r.end && onSelect({ start: withDate(r.start, range?.start), end: withDate(r.end, range?.end) })"      />      <div v-if="range" class="flex flex-col gap-3">        <div class="flex items-center justify-between">          <Label>Start Time</Label>          <TimeField            aria-label="Start Time"            variant="secondary"            granularity="second"            :hour-cycle="12"            :model-value="timeOf(range.start)"            @update:model-value="(t) => t && onChange({ start: withTime(t as Time, range.start), end: range.end })"          />        </div>        <div class="flex items-center justify-between">          <Label>End Time</Label>          <TimeField            aria-label="End Time"            variant="secondary"            granularity="second"            :hour-cycle="12"            :model-value="timeOf(range.end)"            @update:model-value="(t) => t && onChange({ start: range.start, end: withTime(t as Time, range.end) })"          />        </div>      </div>    </template>  </DateRangePicker></template>

Form Example

Trip dates
mm
dd
yyyy
mm
dd
yyyy
Select your check-in and check-out dates.
<script setup lang="ts">import type { DateRangeValue } from '@hareui/vue'import { getLocalTimeZone, today } from '@internationalized/date'import { Button, DateRangePicker, Form } from '@hareui/vue'import { computed, ref, shallowRef } from 'vue'const value = shallowRef<DateRangeValue | null>(null)const isSubmitting = ref(false)const currentDate = today(getLocalTimeZone())const isInvalid = computed(() => value.value != null && (value.value.start.compare(currentDate) < 0 || value.value.end.compare(value.value.start) < 0))function handleSubmit(event: Event) {  event.preventDefault()  if (!value.value || isInvalid.value)    return  isSubmitting.value = true  setTimeout(() => {    value.value = null    isSubmitting.value = false  }, 1200)}</script><template>  <Form class="flex w-80 flex-col gap-3" @submit="handleSubmit">    <DateRangePicker      v-model="value"      required      full-width      :invalid="isInvalid"      :min-value="currentDate"      start-name="tripStartDate"      end-name="tripEndDate"      label="Trip dates"      calendar-label="Trip dates"      description="Select your check-in and check-out dates."      error-message="Please choose a valid range in the future."    />    <Button class="w-full" :disabled="!value || isInvalid" :pending="isSubmitting" type="submit">      {{ isSubmitting ? 'Submitting...' : 'Submit' }}    </Button>  </Form></template>

Custom Indicator

The #indicator slot replaces the default calendar icon rendered inside the trigger.

Trip dates
mm
dd
yyyy
mm
dd
yyyy
Replace the default calendar icon by passing custom children.
<script setup lang="ts">import { Icon } from '@iconify/vue'import { DateRangePicker } from '@hareui/vue'</script><template>  <DateRangePicker    class="w-80"    full-width    start-name="startDate"    end-name="endDate"    label="Trip dates"    calendar-label="Trip dates"    description="Replace the default calendar icon by passing custom children."  >    <template #indicator>      <Icon class="size-4" icon="gravity-ui:chevron-down" />    </template>  </DateRangePicker></template>

Render Function

HeroUI's render prop replaces the root element. Option A has no render prop; attributes you pass to DateRangePicker fall through to the root element instead.

Trip dates
mm
dd
yyyy
mm
dd
yyyy
<script setup lang="ts">import { DateRangePicker } from '@hareui/vue'</script><template>  <!--    HeroUI's render-function demo swaps the root element through a `render` prop. Option A has no    `render` prop; attributes passed to `DateRangePicker` fall through to the root element instead.  -->  <DateRangePicker data-custom="foo" class="w-80" full-width start-name="startDate" end-name="endDate" label="Trip dates" calendar-label="Trip dates" /></template>

International Calendar

By default, DateRangePicker displays dates using the calendar system for the given locale. The example below shows the Indian calendar system.

Trip dates
11
7
1948
शक
18
7
1948
शक
<script setup lang="ts">import { getLocalTimeZone, today } from '@internationalized/date'import { DateRangePicker } from '@hareui/vue'const start = today(getLocalTimeZone())</script><template>  <DateRangePicker    class="w-80"    full-width    locale="hi-IN-u-ca-indian"    start-name="startDate"    end-name="endDate"    label="Trip dates"    calendar-label="Trip dates"    :default-value="{ start, end: start.add({ days: 7 }) }"  /></template>

Note: update:modelValue always emits dates in the same calendar system as model-value or default-value (Gregorian if neither is given), regardless of the displayed locale.

Customization

Tailwind CSS

Style the field parts through :ui; the #calendar slot lets you fully restyle (or replace) the embedded RangeCalendar — bind its value and call onSelect to keep it wired to the picker.

Stay dates
mm
dd
yyyy
mm
dd
yyyy
<script setup lang="ts">import { DateRangePicker, RangeCalendar } from '@hareui/vue'</script><template>  <!--    HeroUI restyles the composed RangeCalendar's parts directly. Option A reaches the field parts through    `:ui`, and the `#calendar` slot replaces the embedded RangeCalendar; bind `value` and call `onSelect`    to keep it wired to the picker.  -->  <DateRangePicker    class="w-80"    full-width    variant="secondary"    start-name="checkin"    end-name="checkout"    label="Stay dates"    :ui="{      group: 'rounded-xl border border-border/80 bg-default shadow-sm',      rangeSeparator: 'text-muted',      trigger: 'text-muted',      popover: 'border border-border/80 shadow-sm',    }"  >    <template #calendar="{ value, onSelect }">      <RangeCalendar        aria-label="Stay dates"        class="rounded-2xl bg-surface p-2"        :ui="{ heading: 'font-medium text-foreground', navButton: 'text-muted hover:bg-default', headerCell: 'text-xs text-muted' }"        :model-value="value ?? undefined"        @update:model-value="(range) => range?.start && range.end && onSelect({ start: range.start, end: range.end })"      />    </template>  </DateRangePicker></template>

Global Configuration

app.use(createHareUI({
  ui: { dateRangePicker: { slots: { triggerIndicator: 'text-muted', rangeSeparator: 'px-2 text-default', popover: 'p-0' } } },
}))

Styling Reference

Slots

  • base → [data-slot="date-range-picker"] – root field container (inline-flex flex-col gap-1)
  • group, inputContainer, input, segment → the internal date-input-group's slots, shared with DateField
  • trigger → [data-slot="date-range-picker-trigger"] – button that opens the popover
  • triggerIndicator → [data-slot="date-range-picker-trigger-indicator"] – default/custom calendar icon
  • rangeSeparator → [data-slot="date-range-picker-range-separator"] – separator between the start and end inputs
  • popover → [data-slot="date-range-picker-popover"] – popover content wrapping the RangeCalendar

Note: Label, Description, FieldError and RangeCalendar have their own themes. See their respective pages for customization options.

Interactive States

  • Open: [data-state="open"|"closed"] on the trigger and the popover
  • Disabled: :disabled on the trigger, [data-disabled] on the root, the group and each segment
  • Invalid: [data-invalid="true"] on the group — automatically hides the description while invalid
  • Required: [data-required="true"] on the root — shows the label's required asterisk
  • Focus visible: :focus-visible on the trigger
  • Hover: :hover on the trigger

Reka UI Limitations

  • shouldForceLeadingZeros has no equivalent: Reka's DateRangeFieldRoot doesn't expose a flag to force leading zeros, so the format-options demo drops the "Force leading zeros" switch.
  • v-model is driven through a local draft: Reka's DateRangeFieldRoot emits every partial edit ({ start, end: undefined }) as its model value, while React Aria keeps an incomplete range as an internal placeholder and only emits complete ranges. DateRangePicker feeds Reka the draft and emits only complete ranges (or null), matching HeroUI.
  • Reka's DateRangePickerRoot reads isDateUnavailable once at setup, with a single argument. The field side calls the matcher with a null anchor (as React Aria does outside the calendar); the anchor-aware form only applies inside the embedded RangeCalendar.
  • HeroUI's composed RangeCalendar passes its own 1900/2099 minValue/maxValue defaults, which override the picker's bounds, so its popover never greys out-of-range days (the field still validates them). The embedded RangeCalendar here matches that: min-value/max-value validate the field but don't disable calendar days.

API Reference

Props

PropTypeDefaultDescription
labelstring-Label text rendered above the group.
calendarLabelstring-Accessible label for the range calendar inside the popover.
descriptionstring-Helper text rendered below the group. Hidden while the field is invalid.
errorMessagestring-Error message rendered below the group while invalid. Defaults to the validation errors.
idstringuseId()The id of the field; label, description and error ids derive from it.
startNamestring-The name of the start date, used when submitting a form.
endNamestring-The name of the end date, used when submitting a form.
defaultValueDateRangeValue | null-The initial range when uncontrolled (no v-model).
placeholderDateValue-The placeholder date, used to determine which segments/month to show when no range is selected.
defaultPlaceholderDateValue-The placeholder date when uncontrolled.
granularityGranularity'day' (a `CalendarDate` value), otherwise 'minute'The granularity of the fields: which segments are rendered, up to and including this unit.
hourCycleHourCycle-The hour cycle used to format times. Defaults to the locale's preference.
stepDateStep-The stepping interval for the time segments.
hideTimeZoneboolean-Whether to hide the time zone segment.
minValueDateValue-The minimum selectable date.
maxValueDateValue-The maximum selectable date.
localestring-The locale used to format the fields and the calendar. Defaults to the ConfigProvider locale.
isDateUnavailable((date: DateValue, anchorDate: DateValue | null) => boolean)-Callback returning whether a date is unavailable. Inside the calendar, anchorDate is the already-picked start of an in-progress selection (null otherwise), matching React Aria's isDateUnavailable(date, anchorDate).
allowNonContiguousRangesbooleanfalseWhether a range may contain unavailable dates.
variant"primary" | "secondary"'primary'Visual variant of the group.
fullWidthbooleanfalseWhether the field takes the full width of its container.
inputContainerbooleanfalseWraps the start/end inputs in a horizontally scrollable container (HeroUI's DateField.InputContainer), for long time ranges that overflow the group.
disabledbooleanfalseWhether the picker is disabled.
invalidbooleanundefinedWhether the field is invalid. Overrides validation when set.
requiredbooleanfalseWhether a range is required.
readonlybooleanfalseWhether the range can be selected but not changed.
validateValidateFn<DateRangeValue>-Validates the committed range. Return an error message (or several) when invalid.
validationBehaviorValidationBehavior'native'native blocks form submission and shows errors on commit or submit; aria shows errors in realtime. Defaults to the surrounding Form.
openbooleanundefinedThe controlled open state of the popover.
defaultOpenbooleanfalseThe open state of the popover when it is initially rendered.
ui(ComponentSlots<{ slots: { base: string; trigger: string[]; triggerIndicator: string; rangeSeparator: string; popover: string[]; }; variants: { fullWidth: { false: { base: string; }; true: { base: string; }; }; }; defaultVariants: { fullWidth: boolean; }; }> & { group?: C; } & Pick<ComponentSlots<{ slots: { base: string[]; inputContainer: string; input: string[]; segment: string[]; prefix: string; suffix: string; }; variants: { variant: { primary: { base: string; }; secondary: { base: string[]; }; }; fullWidth: { false: { base: string; }; true: { base: string; }; }; }; defaultVariants: { fullWidth: boolean; variant: string; }; }>, "input" | "inputContainer" | "segment">)-Per-slot class overrides. group, inputContainer, input and segment are the internal date-input-group's.
modelValueDateRangeValue | null-The selected range (null when empty). Bind with v-model. Only complete ranges are emitted, as in React Aria.

Slots

SlotPropsDescription
labelanyLabel content. Replaces the label prop.
indicatoranyReplaces the default calendar icon inside the trigger.
separatoranyReplaces the default - between the start and end inputs.
descriptionanyDescription content. Replaces the description prop.
errorValidationResultError content, rendered while invalid. Replaces the errorMessage prop.
calendarDateRangePickerCalendarSlotPropsReplaces the default RangeCalendar rendered inside the popover.

Emits

EventPayloadDescription
update:modelValue[value: DateRangeValue | null | undefined]-
update:open[value: boolean]-
update:placeholder[value: DateValue]-

Accessibility

  • The label names the input group (aria-labelledby); each end is its own segmented spinbutton group
  • The field's aria-describedby lists the ids of the rendered description (<id>-description) and error (<id>-error)
  • start-name/end-name submit each end through its own hidden input, which also carries native required
  • Keyboard: ←/→ move between segments (across the separator), ↑/↓ increment/decrement the focused segment, digits type directly into the focused segment and auto-advance
  • Opening the popover moves focus into the calendar (the range start, else today); Esc or an outside click closes it, and completing a range closes it and returns focus to the trigger

On this page

No Headings