Keyboard shortcut: Ctrl + K
Get started

Time input

A time field in one of three forms: typed with a mask, filled from a clock, or a list of times at a fixed interval. The value is always a 24-hour HH:mm string.

Usage

vue
<script setup lang="ts">
import { ref } from 'vue'
import { VTimeInput } from 'vectis-ui'

const time = ref<string | null>(null)
</script>

<template>
  <VTimeInput v-model="time" label="Start time" />
</template>

Examples

Label, hint and icon

label and hint behave as on any field. pickerIcon changes the glyph that opens the clock, iconStart puts an icon at the start of the field, and loading shows a spinner in place of the clock icon. pickerIconLabel, clearLabel, loadingText and iconStartLabel rename what each of them announces.

vue

Type it or pick it from the clock

The glyph that opens the clock is yours to choose

Loading…

An icon at the start, a spinner at the end while something loads

<script setup lang="ts">
import { ref } from 'vue'
import { VTimeInput } from 'vectis-ui'
import { expand_more as expandMore, search } from 'vectis-ui/icons'

const start = ref<string | null>('09:15')
const meeting = ref<string | null>(null)
</script>

<template>
  <div class="column">
    <VTimeInput
      v-model="start"
      label="Start time"
      hint="Type it or pick it from the clock"
      show-picker
    />

    <VTimeInput
      v-model="meeting"
      label="Meeting"
      hint="The glyph that opens the clock is yours to choose"
      mode="picker"
      :picker-icon="expandMore"
    />

    <!-- The start icon is rendered before whatever else fills that end of the field. -->
    <VTimeInput
      v-model="start"
      label="Filter by time"
      hint="An icon at the start, a spinner at the end while something loads"
      :icon-start="search"
      loading
    />
  </div>
</template>

<style scoped>
.column {
  display: flex;
  flex-direction: column;
  gap: var(--vectis-space-5);
  max-inline-size: 26rem;
}
</style>

Sizes

size sets the field height to 32, 40 or 48 pixels, and compact takes 4px off it. The clock keeps its own measurements.

vue
<script setup lang="ts">
import { ref } from 'vue'
import { VTimeInput } from 'vectis-ui'

const rows = ref(
  (['sm', 'md', 'lg'] as const).flatMap((size) => [
    { key: size, size, compact: false, label: size, time: '09:15' as string | null },
    {
      key: `${size}-compact`,
      size,
      compact: true,
      label: `${size}, compact`,
      time: '09:15' as string | null,
    },
  ]),
)
</script>

<template>
  <div class="column">
    <VTimeInput
      v-for="row in rows"
      :key="row.key"
      v-model="row.time"
      :size="row.size"
      :compact="row.compact"
      :label="row.label"
      show-picker
    />
  </div>
</template>

<style scoped>
.column {
  display: flex;
  flex-direction: column;
  gap: var(--vectis-space-5);
  max-inline-size: 26rem;
}
</style>

Modes

mode chooses the form of the field: input masks it so only digits are typed, the clock then being opt-in through showPicker; picker makes the clock the only way in, so it is forced on there; list drops the clock for a searchable list of times.

vue

No icon, no panel: the mask is the whole control

showPicker adds the icon and the panel it opens

Nothing can be typed, so the clock is the only way in

Every half hour, found by typing rather than by scrolling

<script setup lang="ts">
import { ref } from 'vue'
import { VTimeInput } from 'vectis-ui'

const typed = ref<string | null>('09:15')
const withPicker = ref<string | null>('09:15')
const readOnly = ref<string | null>('09:15')
const fromList = ref<string | null>('09:30')
</script>

<template>
  <div class="column">
    <VTimeInput
      v-model="typed"
      label="Typed, the default"
      hint="No icon, no panel: the mask is the whole control"
    />

    <VTimeInput
      v-model="withPicker"
      show-picker
      label="Typed, with the clock"
      hint="showPicker adds the icon and the panel it opens"
    />

    <VTimeInput
      v-model="readOnly"
      mode="picker"
      label="Read-only"
      hint="Nothing can be typed, so the clock is the only way in"
    />

    <VTimeInput
      v-model="fromList"
      mode="list"
      :minute-step="30"
      label="List"
      hint="Every half hour, found by typing rather than by scrolling"
    />
  </div>
</template>

<style scoped>
.column {
  display: flex;
  flex-direction: column;
  gap: var(--vectis-space-5);
  max-inline-size: 26rem;
}
</style>

Steps

minuteStep is what the face offers, what the arrow keys move by and what the list is cut at. It leaves the mask alone, and is worth setting on a list before anything else: the default of one minute is 1440 rows.

vue

The face offers four minutes an hour, and the arrow keys move by the same step

The step is what the rows are cut at: 48 of them here rather than 1440

The mask still takes any time; the step drives the clock and the arrow keys

<script setup lang="ts">
import { ref } from 'vue'
import { VTimeInput } from 'vectis-ui'

const appointment = ref<string | null>('14:15')
const slot = ref<string | null>('09:30')
const typed = ref<string | null>('08:00')
</script>

<template>
  <div class="column">
    <VTimeInput
      v-model="appointment"
      mode="picker"
      :minute-step="15"
      label="Quarter hours, on the clock"
      hint="The face offers four minutes an hour, and the arrow keys move by the same step"
    />

    <VTimeInput
      v-model="slot"
      mode="list"
      :minute-step="30"
      label="Half hours, as a list"
      hint="The step is what the rows are cut at: 48 of them here rather than 1440"
    />

    <VTimeInput
      v-model="typed"
      show-picker
      :minute-step="5"
      label="Five minutes, typed"
      hint="The mask still takes any time; the step drives the clock and the arrow keys"
    />
  </div>
</template>

<style scoped>
.column {
  display: flex;
  flex-direction: column;
  gap: var(--vectis-space-5);
  max-inline-size: 26rem;
}
</style>

What may be chosen

min, max, allowedHours and allowedMinutes restrict what may be chosen. The list and the clock leave out what cannot be chosen; the typed field commits the entry and turns invalid through the control's own validity instead.

vue

Type 08:00 and leave the field: the value stands and the field turns invalid

The rows outside the bounds are not offered at all

The face prints only the hours it can take

<script setup lang="ts">
import { ref } from 'vue'
import { VTimeInput } from 'vectis-ui'

const typed = ref<string | null>('09:30')
const listed = ref<string | null>('09:30')
const picked = ref<string | null>('09:30')
</script>

<template>
  <div class="column">
    <VTimeInput
      v-model="typed"
      format="24h"
      min="09:00"
      max="17:00"
      label="Typed"
      hint="Type 08:00 and leave the field: the value stands and the field turns invalid"
    />

    <VTimeInput
      v-model="listed"
      mode="list"
      format="24h"
      :minute-step="30"
      min="09:00"
      max="17:00"
      label="List"
      hint="The rows outside the bounds are not offered at all"
    />

    <VTimeInput
      v-model="picked"
      mode="picker"
      format="24h"
      :minute-step="30"
      min="09:00"
      max="17:00"
      label="Clock"
      hint="The face prints only the hours it can take"
    />
  </div>
</template>

<style scoped>
.column {
  display: flex;
  flex-direction: column;
  gap: var(--vectis-space-5);
  max-inline-size: 26rem;
}
</style>

Clearable

clearable adds a cross that empties the value, to the left of the clock icon rather than in its place. The list form takes its own cross from the combobox it is built on, wording included.

vue

The cross sits to the left of the clock icon, never in its place

Emptying the field is then the reader's own business

Here the cross is the combobox's own, chevron included

<script setup lang="ts">
import { ref } from 'vue'
import { VTimeInput } from 'vectis-ui'

const withCross = ref<string | null>('09:15')
const withoutCross = ref<string | null>('09:15')
const inList = ref<string | null>('09:30')
</script>

<template>
  <div class="column">
    <VTimeInput
      v-model="withCross"
      clearable
      show-picker
      label="Clearable"
      hint="The cross sits to the left of the clock icon, never in its place"
    />

    <VTimeInput
      v-model="withoutCross"
      show-picker
      label="Not clearable, the default"
      hint="Emptying the field is then the reader's own business"
    />

    <VTimeInput
      v-model="inList"
      clearable
      mode="list"
      :minute-step="30"
      label="Clearable, as a list"
      hint="Here the cross is the combobox's own, chevron included"
    />
  </div>
</template>

<style scoped>
.column {
  display: flex;
  flex-direction: column;
  gap: var(--vectis-space-5);
  max-inline-size: 26rem;
}
</style>

States

invalid is for a rule the browser cannot check by itself. disabled greys the field out and prevents the panel from opening. readonly shows the value frozen: nothing can be typed, no clock is rendered and the AM/PM button goes with it, while the field keeps its contrast and takes the focus.

vue

For a rule the browser cannot check by itself

Greyed through the colour tokens, and the panel can no longer be opened

No typing, no clock, no clear cross

<script setup lang="ts">
import { ref } from 'vue'
import { VTimeInput } from 'vectis-ui'

const invalid = ref<string | null>('09:15')
</script>

<template>
  <div class="column">
    <VTimeInput
      v-model="invalid"
      invalid
      show-picker
      label="Invalid"
      hint="For a rule the browser cannot check by itself"
    />

    <VTimeInput
      model-value="09:15"
      disabled
      show-picker
      label="Disabled, with a value"
      hint="Greyed through the colour tokens, and the panel can no longer be opened"
    />

    <VTimeInput model-value="09:15" disabled mode="picker" label="Disabled, picker only" />

    <!-- Frozen rather than out of reach: it still takes the focus and can be copied from. -->
    <VTimeInput
      model-value="09:15"
      readonly
      show-picker
      clearable
      label="Read-only"
      hint="No typing, no clock, no clear cross"
    />

    <VTimeInput
      model-value="09:30"
      disabled
      mode="list"
      :minute-step="30"
      label="Disabled, as a list"
    />
  </div>
</template>

<style scoped>
.column {
  display: flex;
  flex-direction: column;
  gap: var(--vectis-space-5);
  max-inline-size: 26rem;
}
</style>

Twelve-hour clock

The value is a 24-hour string whatever is on screen. Where the half of the day is chosen depends on the form: a button inside a typed field, the clock's own pair beside its numerals, and nothing in a list, every row spelling out its own.

vue

The mask says nothing about the half of the day, so a button in the field does

Here the pair belongs to the clock, beside its two large numerals

Every row spells its own half of the day, so neither control is needed

07:00, 19:00, 19:30
<script setup lang="ts">
import { ref } from 'vue'
import { VTimeInput } from 'vectis-ui'

/* The value stays a 24-hour string in all three: 19:00 is what the model holds while the
   field reads 7:00 PM. */
const typed = ref<string | null>('07:00')
const onTheClock = ref<string | null>('19:00')
const fromList = ref<string | null>('19:30')
</script>

<template>
  <div class="column">
    <VTimeInput
      v-model="typed"
      format="12h"
      show-picker
      label="Typed"
      hint="The mask says nothing about the half of the day, so a button in the field does"
    />

    <VTimeInput
      v-model="onTheClock"
      format="12h"
      mode="picker"
      label="Read-only"
      hint="Here the pair belongs to the clock, beside its two large numerals"
    />

    <VTimeInput
      v-model="fromList"
      format="12h"
      mode="list"
      :minute-step="30"
      label="List"
      hint="Every row spells its own half of the day, so neither control is needed"
    />

    <output class="value" aria-label="The three values held">
      {{ typed }}, {{ onTheClock }}, {{ fromList }}
    </output>
  </div>
</template>

<style scoped>
.column {
  display: flex;
  flex-direction: column;
  gap: var(--vectis-space-5);
  max-inline-size: 26rem;
}
.value {
  font-family: var(--vectis-text-family-code);
  font-size: var(--vectis-text-body-sm-size);
  color: var(--vectis-color-text-muted);
}
</style>

Localization

locale decides the clock, the mask and how a time is written out, and takes precedence over the global locale. format sits above both, for a field that has to be read one way whatever the language.

vue

format overrides what the tag would have chosen

<script setup lang="ts">
import { ref } from 'vue'
import { VTimeInput } from 'vectis-ui'

/* Nothing here is tabulated: the clock, the mask and the wording all come from the tag.
   en-US and en-GB share every word and differ only in the clock they count on. */
const locales = ref([
  { tag: 'en-US', label: 'en-US, twelve hours', time: '19:30' },
  { tag: 'en-GB', label: 'en-GB, the same words on a 24-hour clock', time: '19:30' },
  { tag: 'fr-FR', label: 'fr-FR', time: '19:30' },
  { tag: 'ja-JP', label: 'ja-JP', time: '19:30' },
])

const forced = ref<string | null>('19:30')
</script>

<template>
  <div class="column">
    <VTimeInput
      v-for="locale in locales"
      :key="locale.tag"
      v-model="locale.time"
      :locale="locale.tag"
      :label="locale.label"
      show-picker
    />

    <VTimeInput
      v-model="forced"
      locale="en-US"
      format="24h"
      show-picker
      label="en-US, forced onto a 24-hour clock"
      hint="format overrides what the tag would have chosen"
    />
  </div>
</template>

<style scoped>
.column {
  display: flex;
  flex-direction: column;
  gap: var(--vectis-space-5);
  max-inline-size: 26rem;
}
</style>

Placement

placement names the preferred opening direction of the panel, above or below the field.

vue
<script setup lang="ts">
import { ref } from 'vue'
import { VTimeInput } from 'vectis-ui'

const placements = ['bottom-start', 'bottom-end', 'top-start', 'top-end'] as const

const times = ref<Record<string, string | null>>({
  'bottom-start': '09:15',
  'bottom-end': '09:15',
  'top-start': '09:15',
  'top-end': '09:15',
})
</script>

<template>
  <div class="grid">
    <VTimeInput
      v-for="placement in placements"
      :key="placement"
      v-model="times[placement]"
      :placement="placement"
      :label="placement"
      mode="picker"
    />
  </div>
</template>

<style scoped>
.grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
  gap: var(--vectis-space-5);
  max-inline-size: 26rem;
}
</style>

API

Props

PropTypeDefault
formatTimePickerFormat'12h' | '24h'none
Whether times are shown on a 12- or a 24-hour clock. Left out, the reader's language decides, which is almost always what one wants.
modeTimeInputMode'picker' | 'input' | 'list''input'
Which form the field takes: one that can be typed into, a picker one where the clock is the only way in, so the clock is forced on there, or a list of times at a fixed interval, where a clock would make no sense. It is a different question from readonly, which freezes the field by every route at once.
showPickerbooleanfalse
Offers the picker beside a field one can type into: an icon at the end of the field, and a panel that opens on focus. The clock follows what is typed, and with no value it opens empty. It means nothing in picker mode, where the clock is forced on, nor in list mode, where the list is the panel.
minuteStepnumber1
The interval between two times that can be chosen. It applies to the picker, to the arrow keys and to the rows of the list.
minstringnone
The earliest time that can be chosen, inclusive, as a canonical 24-hour string. The picker and the list both leave out what it rules out, and a time typed past it makes the field invalid.
maxstringnone
The latest time that can be chosen, inclusive, written like min.
allowedHoursTimePickerAllowednone
Which hours can be chosen: the list of them, or a rule answering for one. The hour a rule is handed is always the 24-hour one, whichever clock is on display.
allowedMinutesTimePickerAllowednone
Which minutes can be chosen: the list of them, or a rule answering for one.
localestringnone
A BCP 47 locale, which decides the clock and how a time is written out. It takes precedence over the design system's global locale and falls back to it.
labelstringnone
The label above the field.
hintstringnone
A line of help under the field.
placeholderstringnone
What the field says while empty.
sizeTimeInputSize'sm' | 'md' | 'lg''md'
The height of the field: 32, 40 or 48 pixels.
compactbooleanfalse
Takes 4px off the height.
disabledbooleanfalse
Makes the field unusable, greyed out through the colour tokens.
readonlybooleanfalse
Shows the time without letting it be changed: nothing can be typed, there is no clock and no clear cross, and the attributes announcing a panel go with it. The field keeps the focus and can be copied from, which is what separates it from disabled.
invalidbooleanfalse
Marks the field as invalid, for a rule of your own.
iconStartIconSourcenone
An icon inside the field, at the start. Decorative until a @click:icon-start listener turns it into a button.
iconStartLabelstringnone
What the start icon does, in words, once it is clickable.
pickerIconLabelstringnone
What the end icon does, in words. It names the button that opens the clock, and falls back to the design system dictionary.
loadingbooleanfalse
Shows a spinner in place of the clock icon. It says that something is being loaded and changes nothing else: the field can still be typed into and the panel still opens.
loadingTextstringnone
What screen readers announce while the spinner turns. It falls back to the design system dictionary.
clearablebooleanfalse
Offers a cross that empties the value, shown before the end icon.
clearLabelstringnone
What that cross does, in words. It falls back to the design system dictionary.
pickerIconIconSourceschedule
The icon that opens the clock, at the end of the field. It has no effect on the list form, whose chevron follows the combobox convention. The clear cross appears to its left rather than in its place.
placementTimeInputPlacement'bottom' | 'bottom-start' | 'bottom-end' | 'top' | 'top-start' | 'top-end''bottom-start'
Where the panel opens relative to the field.
v-modelstring | nullnull
The time, always as a 24-hour string whatever clock is displayed, so you never have to know which one the reader's language uses.

Events

EventType
click:icon-start[event: MouseEvent]
The start icon was clicked. Attaching this listener is what turns that icon into a real button, which then needs iconStartLabel.
clear[]
The clear cross emptied the field. The value has already been reset.

Slots

SlotType
start{}
Content at the start of the field, rendered after iconStart rather than in its place.
value-end{}
Controls of your own inside the field, placed before the ones the field owns: the clear cross and the icon that opens the panel. Those two are the component own affordance, which is why there is no end slot here.
footerTimeInputFooterSlotProps
The strip at the foot of the clock, which replaces the Cancel and OK buttons rather than joining them. It receives both actions, and they are what make it usable: the clock writes a draft that only confirm commits, so a footer of your own without it would leave the value unchangeable through the panel. It also receives cancel, and close, the same function under the name VDateInput's footer hands out. It is not rendered in list mode, which has no panel of its own.

Types

The types the tables above name, written as the library declares them. The ones carrying export can be imported from vectis-ui to type your own code; the others are the shape of what a slot hands out.

export interface BuiltinIcon {
  name: string
  paths: readonly [string] | readonly [string, string]
}
export type IconRender =
  | { path: string; viewBox?: string }
  | { component: Component; props?: Record<string, unknown> }
  | { src: string }
  | { text: string; class?: string }
  | { class: string }
export type IconSource = string | BuiltinIcon | IconRender
export interface TimeInputFooterSlotProps {
  confirm: () => void
  cancel: () => void
  close: () => void
}
export type TimePickerAllowed = number[] | ((value: number) => boolean)