DateRangeField

Types a start and an end date segment by segment.

9
1
2026
9
30
2026

Value: 2026-09-01 to 2026-09-30

<script setup lang="ts">
  import { ref } from 'vue'
  import { CalendarRange } from '@lucide/vue'
  import { DateRangeField, Stack, Text, type DateRangeValue } from '@hina-ui/vue'

  const range = ref<DateRangeValue | null>({ start: '2026-09-01', end: '2026-09-30' })
</script>

<template>
  <Stack gap="sm" align="start">
    <DateRangeField v-model="range" clearable aria-label="Event period" class="w-96">
      <template #leading>
        <CalendarRange />
      </template>
    </DateRangeField>
    <Text tone="muted" size="sm">
      Value: {{ range ? `${range.start ?? 'empty'} to ${range.end ?? 'empty'}` : 'empty' }}
    </Text>
  </Stack>
</template>
vue

Usage

import { DateRangeField } from '@hina-ui/vue'
ts

The date range field puts a start and an end set of date segments in one field, separated by a dash. Each segment accepts digits only and moves on once complete, and finishing the start date moves straight into the end date. v-model binds a { start, end } object whose fields are ISO 8601 strings in the same shape as DateField; with only one side filled the other is null, and with both sides empty the whole value is null. Attributes it does not declare land on the group element that wraps the segments, so name it with aria-label or aria-labelledby.

mm
dd
yyyy
mm
dd
yyyy

Value: empty

<script setup lang="ts">
  import { ref } from 'vue'
  import { DateRangeField, Stack, Text, type DateRangeValue } from '@hina-ui/vue'

  const range = ref<DateRangeValue | null>(null)
</script>

<template>
  <Stack gap="sm" align="start">
    <DateRangeField v-model="range" aria-label="Release window" class="w-96" />
    <Text tone="muted" size="sm">
      Value: {{ range ? `${range.start ?? 'empty'} to ${range.end ?? 'empty'}` : 'empty' }}
    </Text>
  </Stack>
</template>
vue

Examples

Date and time

granularity sets the precision for both sides: the default day is date only, hour adds the hour, minute adds hour and minute and second adds seconds. hourCycle picks the 12 or 24 hour clock, defaulting to the locale.

9
4
2026
7
00
PM
9
4
2026
9
30
PM

Value: 2026-09-04T19:00 to 2026-09-04T21:30

<script setup lang="ts">
  import { ref } from 'vue'
  import { DateRangeField, Stack, Text, type DateRangeValue } from '@hina-ui/vue'

  const range = ref<DateRangeValue | null>({
    start: '2026-09-04T19:00',
    end: '2026-09-04T21:30',
  })
</script>

<template>
  <Stack gap="sm" align="start">
    <DateRangeField
      v-model="range"
      granularity="minute"
      aria-label="Stream slot"
      class="w-[30rem]"
    />
    <Text tone="muted" size="sm">
      Value: {{ range ? `${range.start ?? 'empty'} to ${range.end ?? 'empty'}` : 'empty' }}
    </Text>
  </Stack>
</template>
vue

Range

min and max bound both sides. When either side falls outside them, or the end date is before the start date, the field shows as invalid but the value is still emitted. placeholder sets the date the segments start from while the field is empty, today by default.

9
20
2026
9
10
2026
<script setup lang="ts">
  import { ref } from 'vue'
  import { DateRangeField, type DateRangeValue } from '@hina-ui/vue'

  const range = ref<DateRangeValue | null>({ start: '2026-09-20', end: '2026-09-10' })
</script>

<template>
  <DateRangeField
    v-model="range"
    min="2026-09-01"
    max="2026-09-30"
    placeholder="2026-09-01"
    aria-label="Event period"
    class="w-96"
  />
</template>
vue

Clearable

clearable adds a button at the end that empties both sides, sets the value to null and returns focus to the first segment.

9
1
2026
9
30
2026
<script setup lang="ts">
  import { ref } from 'vue'
  import { DateRangeField, type DateRangeValue } from '@hina-ui/vue'

  const range = ref<DateRangeValue | null>({ start: '2026-09-01', end: '2026-09-30' })
</script>

<template>
  <DateRangeField v-model="range" clearable aria-label="Event period" class="w-96" />
</template>
vue

Sizes

size has three steps, sm, md and lg, matching the input of the same step in height.

9
1
2026
9
30
2026
9
1
2026
9
30
2026
9
1
2026
9
30
2026
<script setup lang="ts">
  import { DateRangeField, Stack } from '@hina-ui/vue'

  const range = { start: '2026-09-01', end: '2026-09-30' }
</script>

<template>
  <Stack class="w-96">
    <DateRangeField size="sm" :model-value="range" aria-label="Small" />
    <DateRangeField size="md" :model-value="range" aria-label="Medium" />
    <DateRangeField size="lg" :model-value="range" aria-label="Large" />
  </Stack>
</template>
vue

States

invalid gives the field a warning color, disabled disables the group and readonly keeps it focusable but not editable. variant="secondary" is the flat form used inside a surface.

9
1
2026
9
30
2026
9
1
2026
9
30
2026
9
1
2026
9
30
2026
9
1
2026
9
30
2026
<script setup lang="ts">
  import { DateRangeField, Stack } from '@hina-ui/vue'

  const range = { start: '2026-09-01', end: '2026-09-30' }
</script>

<template>
  <Stack class="w-96">
    <DateRangeField :model-value="range" invalid aria-label="Invalid" />
    <DateRangeField :model-value="range" disabled aria-label="Disabled" />
    <DateRangeField :model-value="range" readonly aria-label="Read only" />
    <DateRangeField :model-value="range" variant="secondary" aria-label="Secondary" />
  </Stack>
</template>
vue

In a form

Inside a FormField the label is linked to the whole range input through aria-labelledby, and the error message is rendered by the field; validation rules and submission belong to the Form. The value is an object whose other side is null while only one side is filled, so write the completeness check on the object itself for the error to land on this field.

mm
dd
yyyy
mm
dd
yyyy
<script setup lang="ts">
  import { reactive, ref } from 'vue'
  import * as v from 'valibot'
  import { Button, DateRangeField, Form, FormField, Text, type DateRangeValue } from '@hina-ui/vue'

  const schema = v.object({
    period: v.pipe(
      v.nullable(v.object({ start: v.nullable(v.string()), end: v.nullable(v.string()) })),
      v.check(value => !!value?.start && !!value?.end, 'Fill in the whole event period'),
    ),
  })

  const values = reactive({ period: null as DateRangeValue | null })
  const saved = ref('')

  async function save(data: unknown) {
    await new Promise(resolve => setTimeout(resolve, 600))
    saved.value = JSON.stringify(data)
  }
</script>

<template>
  <Form v-slot="{ submitting }" :values="values" :rules="schema" class="w-80" @submit="save">
    <FormField name="period" label="Event period" required>
      <DateRangeField v-model="values.period" />
    </FormField>
    <Button type="submit" :loading="submitting" class="self-start">Save</Button>
    <Text v-if="saved" tone="muted" size="sm">Saved: {{ saved }}</Text>
  </Form>
</template>
vue

Behavior

  • Clicking a segment starts typing; a click on the blank area focuses the first empty segment, which is the first end segment once the start date is complete.
  • Each segment accepts digits only and moves on once complete, and the last start segment moves into the end date; the left and right arrow keys move between segments, up and down step the value and Backspace clears the current segment.
  • A side is emitted only when every one of its segments is filled; clearing any segment makes that side null.
  • An end before the start, or either side outside min and max, is still emitted and marked invalid.

Accessibility

  • Segments are role="spinbutton" named with a “start date” or “end date” prefix; the dash and the separators are hidden from assistive technology.
  • Name the group with aria-label or aria-labelledby.
  • invalid, an end before the start and out-of-range values all set aria-invalid on the group element.

API

Props

Prop
Type
Default
Description
modelValue
DateRangeValue | null
null
{ start, end }, both sides ISO 8601 strings
placeholder
string
Today
Where the segments start from while empty, ISO 8601
min
string
—
Lower bound of the accepted range
max
string
—
Upper bound of the accepted range
granularity
'day' | 'hour' | 'minute' | 'second'
'day'
Precision
hourCycle
12 | 24
Locale
Hour cycle
clearable
boolean
false
Whether the clear button is shown
readonly
boolean
false
Whether the field is read only
name
string
—
Form field name
variant
'primary' | 'secondary'
'primary'
Variant
size
'sm' | 'md' | 'lg'
'md'
Size
disabled
boolean
false
Whether the field is disabled
invalid
boolean
false
Whether validation failed
class
string
—
Classes appended to the root element

DateRangeValue is { start: string | null; end: string | null } and is exported from the package entry.

Slots

Slot
Description
leading
Content of the leading square
trailing
Content of the trailing square

Events

Event
Payload
Description
update:modelValue
value: DateRangeValue | null
The value changed
clear
—
The clear was clicked