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>
Usage
import { DateRangeField } from '@hina-ui/vue'
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.
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>
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.
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>
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.
<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>
Clearable
clearable adds a button at the end that empties both sides, sets the value to null and returns focus to the first segment.
<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>
Sizes
size has three steps, sm, md and lg, matching the input of the same step in height.
<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>
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.
<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>
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.
<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>
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
minandmax, 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-labeloraria-labelledby. invalid, an end before the start and out-of-range values all setaria-invalidon 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 |