A code typed one character to a box: a one-time password, a licence key, a reference. Pasting fills the whole row, and the value is the characters alone, never the separators.
label renders nothing here: it names the row for assistive technology. hint is the text the reader sees, tied to the row so it is read out along with the label.
vue
Check your phone
We sent a six digit code to +33 6 12 34 56 78.
The code lasts ten minutes. Ask for another one if it has expired.
<script setup lang="ts">
import { ref } from 'vue'
import { VInputOTP, VTypography } from 'vectis-ui'
const code = ref('')
</script>
<template>
<div class="column">
<!-- `label` names the row for assistive technology and renders nothing: the
instructions above are the page's own, written where they read best. -->
<VTypography as="h3" variant="heading-4">Check your phone</VTypography>
<VTypography tone="muted">We sent a six digit code to +33 6 12 34 56 78.</VTypography>
<VInputOTP
v-model="code"
:length="6"
label="Verification code"
hint="The code lasts ten minutes. Ask for another one if it has expired."
/>
</div>
</template>
<style scoped>
.column {
display: grid;
justify-items: start;
gap: var(--vectis-space-3);
}
</style>
Sizes
size sets the height to 32, 40 or 48 pixels, and compact takes 4px off it. The character inside is scaled a notch or two above the row's own step.
vue
sm
md
lg
sm compact
md compact
lg compact
<script setup lang="ts">
import { VInputOTP, VTypography } from 'vectis-ui'
const sizes = ['sm', 'md', 'lg'] as const
</script>
<template>
<div class="column">
<div v-for="size in sizes" :key="size" class="row">
<VInputOTP :length="4" :size="size" :label="`Code, ${size}`" />
<VTypography variant="caption" tone="muted">{{ size }}</VTypography>
</div>
<!-- Compact takes 4px off the boxes and leaves the text where it was. -->
<div v-for="size in sizes" :key="`${size}-compact`" class="row">
<VInputOTP :length="4" :size="size" compact :label="`Code, ${size} compact`" />
<VTypography variant="caption" tone="muted">{{ size }} compact</VTypography>
</div>
</div>
</template>
<style scoped>
.column {
display: grid;
gap: var(--vectis-space-4);
}
.row {
display: flex;
align-items: center;
gap: var(--vectis-space-4);
}
</style>
Length
length is how many boxes the code has, six by default. It is ignored the moment a pattern is given.
format decides which characters the code is made of, filtering what can be typed or pasted and choosing the keyboard a phone offers. Outside a numeric code the value is forced to capitals.
vue
Digits only, and a phone offers its number pad
Letters only, typed in either case and kept in capitals
Letters and digits, again in capitals
<script setup lang="ts">
import { ref } from 'vue'
import { VInputOTP } from 'vectis-ui'
const pin = ref('')
const word = ref('')
const key = ref('')
</script>
<template>
<div class="column">
<VInputOTP
v-model="pin"
:length="6"
format="numeric"
label="Numeric code"
hint="Digits only, and a phone offers its number pad"
/>
<!-- Outside a numeric code the value is forced to capitals, so it has one
canonical form whatever the reader typed. -->
<VInputOTP
v-model="word"
:length="5"
format="alpha"
label="Letter code"
hint="Letters only, typed in either case and kept in capitals"
/>
<VInputOTP
v-model="key"
:length="6"
format="alphanumeric"
label="Licence key"
hint="Letters and digits, again in capitals"
/>
</div>
</template>
<style scoped>
.column {
display: grid;
gap: var(--vectis-space-5);
}
</style>
Pattern
pattern writes the shape of the code out: every # is a box to fill and every other character a literal drawn between the boxes, never typed and never part of the value. It wins over length.
vue
-
Two groups of three. Value: empty
GT-
Three characters after the prefix. Value: empty
..
Nine digits in three groups. Value: empty
<script setup lang="ts">
import { ref } from 'vue'
import { VInputOTP } from 'vectis-ui'
const grouped = ref('')
const prefixed = ref('')
const reference = ref('')
</script>
<template>
<div class="column">
<!-- Each # is a box, everything else is drawn between them and never enters the
value. The pattern wins over `length`, which is then ignored. -->
<VInputOTP
v-model="grouped"
pattern="###-###"
label="Grouped code"
:hint="`Two groups of three. Value: ${grouped || 'empty'}`"
/>
<!-- A literal can be a real prefix rather than punctuation. It is shown, never
typed, and stays out of the value. -->
<VInputOTP
v-model="prefixed"
pattern="GT-###"
format="alphanumeric"
label="Ticket reference"
:hint="`Three characters after the prefix. Value: ${prefixed || 'empty'}`"
/>
<VInputOTP
v-model="reference"
pattern="###.###.###"
label="Payment reference"
:hint="`Nine digits in three groups. Value: ${reference || 'empty'}`"
/>
</div>
</template>
<style scoped>
.column {
display: grid;
gap: var(--vectis-space-5);
}
</style>
Separators
separatorIcon replaces the literals a pattern draws, every one of them, so it suits a template whose separators are punctuation and nothing else.
vue
-
The literal, as written
One icon in place of the dash
Both literals take the same icon
<script setup lang="ts">
import { VInputOTP } from 'vectis-ui'
import { chevron_right as chevronRight, close } from 'vectis-ui/icons'
</script>
<template>
<div class="column">
<!-- Left alone, a literal is drawn as the character the pattern names. -->
<VInputOTP pattern="###-###" label="Grouped code" hint="The literal, as written" />
<!-- The icon replaces EVERY literal of the pattern, so it suits one whose
separators are punctuation and nothing more. -->
<VInputOTP
pattern="###-###"
:separator-icon="chevronRight"
label="Grouped code with a chevron"
hint="One icon in place of the dash"
/>
<VInputOTP
pattern="###.###.###"
:separator-icon="close"
label="Payment reference with a cross"
hint="Both literals take the same icon"
/>
</div>
</template>
<style scoped>
.column {
display: grid;
gap: var(--vectis-space-5);
}
</style>
Pasting and autofill
A code pasted anywhere in the row is spread across every box, the pattern's literals consumed with it. The first box carries autocomplete="one-time-code", so a code arriving from an SMS or a password manager is spread the same way.
vue
Copy GT-4F2, then paste it anywhere in the row.
GT-
Value: empty
<script setup lang="ts">
import { ref } from 'vue'
import { VButton, VInputOTP, VTypography } from 'vectis-ui'
const SAMPLE = 'GT-4F2'
const code = ref('')
const copied = ref(false)
/* A plain clipboard write from a click handler, so nothing touches the browser
outside an event. */
async function copy() {
await navigator.clipboard.writeText(SAMPLE)
copied.value = true
}
</script>
<template>
<div class="column">
<div class="row">
<VTypography as="span"
>Copy <code>{{ SAMPLE }}</code
>, then paste it anywhere in the row.</VTypography
>
<VButton variant="outline" tone="neutral" size="sm" @click="copy">
{{ copied ? 'Copied' : 'Copy' }}
</VButton>
</div>
<!-- The paste is spread across the boxes and the prefix is consumed with it: a
code copied in its formatted form lands as three characters. -->
<VInputOTP
v-model="code"
pattern="GT-###"
format="alphanumeric"
label="Ticket reference"
:hint="`Value: ${code || 'empty'}`"
/>
</div>
</template>
<style scoped>
.column {
display: grid;
gap: var(--vectis-space-4);
}
.row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--vectis-space-3);
}
</style>
Reading the code
The value is one string of the characters alone, never the separators. The filled boxes always run from the first one: typing into a box past the first empty one fills that empty box, and emptying a box moves the following characters back. complete fires when the code becomes complete, carrying the finished code.
vue
Try 481902
Value: empty (0 of 6)
<script setup lang="ts">
import { ref } from 'vue'
import { VInputOTP, VTypography } from 'vectis-ui'
const EXPECTED = '481902'
const code = ref('')
const verdict = ref<'right' | 'wrong' | null>(null)
/* `complete` fires once every box is filled, which is the cue to verify rather than
something to work out from the value's length. */
function verify(value: string) {
verdict.value = value === EXPECTED ? 'right' : 'wrong'
}
</script>
<template>
<div class="column">
<VInputOTP
v-model="code"
:length="6"
:invalid="verdict === 'wrong'"
label="Verification code"
hint="Try 481902"
@complete="verify"
@update:model-value="verdict = null"
/>
<!-- The value is the characters alone and is shorter than the row while it is
being typed. -->
<VTypography variant="body-sm" tone="muted">
Value: {{ code || 'empty' }} ({{ code.length }} of 6)
</VTypography>
<VTypography v-if="verdict === 'right'" variant="body-sm" tone="success">
The code matches.
</VTypography>
<VTypography v-else-if="verdict === 'wrong'" variant="body-sm" tone="danger">
That code is not the one we sent.
</VTypography>
</div>
</template>
<style scoped>
.column {
display: grid;
justify-items: start;
gap: var(--vectis-space-3);
}
</style>
In a form
The row submits like any native field. name, form and required reach a hidden input that carries the code, and a code that does not fill every box is invalid, so the browser refuses to submit it.
vue
<script setup lang="ts">
import { ref } from 'vue'
import { VButton, VInputOTP, VTypography } from 'vectis-ui'
const code = ref('')
const submitted = ref<string | null>(null)
/* The form reads the code through `name`, as it would from any native field. A code
that does not fill every box is invalid, so the browser refuses to submit it. */
function onSubmit(event: Event) {
const data = new FormData(event.target as HTMLFormElement)
submitted.value = String(data.get('code'))
}
</script>
<template>
<form class="column" @submit.prevent="onSubmit">
<VInputOTP
v-model="code"
name="code"
required
:length="6"
label="Verification code"
hint="Submitting with empty boxes is refused"
/>
<VButton type="submit">Verify</VButton>
<VTypography v-if="submitted" variant="body-sm" tone="muted">
The form sent code={{ submitted }}
</VTypography>
</form>
</template>
<style scoped>
.column {
display: grid;
justify-items: start;
gap: var(--vectis-space-3);
}
</style>
States
disabled puts the whole row out of reach, greyed through the colour tokens. readonly shows the code frozen while the boxes keep the focus and can be copied from. invalid colours the boxes and tells assistive technology the code was refused.
vue
Out of reach entirely
Shown, and copyable, but not editable
That code is not the one we sent
<script setup lang="ts">
import { VInputOTP } from 'vectis-ui'
</script>
<template>
<div class="column">
<VInputOTP
model-value="481902"
:length="6"
disabled
label="Disabled code"
hint="Out of reach entirely"
/>
<!-- Frozen rather than out of reach: the boxes still take the focus and the code
can be selected and copied. -->
<VInputOTP
model-value="481902"
:length="6"
readonly
label="Read-only code"
hint="Shown, and copyable, but not editable"
/>
<VInputOTP
model-value="481900"
:length="6"
invalid
label="Rejected code"
hint="That code is not the one we sent"
/>
</div>
</template>
<style scoped>
.column {
display: grid;
gap: var(--vectis-space-5);
}
</style>
API
Props
Prop
Type
Default
length
number
6
How many boxes the code has. It is ignored as soon as a pattern is given.
Which characters the code is made of. It filters what can be typed or pasted, and decides which keyboard a phone offers.
pattern
string
none
The shape of the code: each # is a box to fill, and every other character is a separator shown between the boxes without ever being part of the value, 'GT-###' or '###.###.###'. It wins over length.
An icon drawn in place of every separator of the pattern. It suits a template whose separators are purely decorative, '###-###', and not one carrying meaningful text such as 'GT-###', which the icon would erase.
size
InputOTPSize'sm' | 'md' | 'lg'
'md'
The size of the boxes: 32, 40 or 48 pixels.
compact
boolean
false
Takes 4px off the boxes, leaving the text and the icons as they are.
disabled
boolean
false
Makes every box unusable, greyed out through the colour tokens.
readonly
boolean
false
Shows the code without letting it be changed. The boxes keep their focus and the code can still be selected and copied, which is what separates it from disabled.
invalid
boolean
false
Marks the code as wrong, which colours the boxes and tells assistive technology so.
label
string
none
What screen readers announce for the row as a whole. It falls back to the design system dictionary.
hint
string
none
A line of help under the boxes, where the code was sent or how long it lasts. It is tied to the row for assistive technology, so it is read out along with the label. Unlike label, which names the row without rendering anything, this is text the reader sees.
v-model
string
''
The code as one string, without the separators: a GT-### template still yields three characters. It is empty to begin with, and shorter than the full length while it is being typed.
Events
Event
Type
complete
[code: string]
The code has just become complete, with the finished code. This is the cue to verify it. Retyping a character of a complete code with the same one does not fire it again.
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.