Keyboard shortcut: Ctrl + K
Get started

Input OTP

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.

Usage

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

const code = ref('')
</script>

<template>
  <VInputOTP v-model="code" :length="6" label="Verification code" />
</template>

Examples

Label and hint

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.

vue

4, a PIN

6, the default

8, a backup code

<script setup lang="ts">
import { VInputOTP, VTypography } from 'vectis-ui'
</script>

<template>
  <div class="column">
    <div class="row">
      <VInputOTP :length="4" label="Four digit code" />
      <VTypography variant="caption" tone="muted">4, a PIN</VTypography>
    </div>

    <div class="row">
      <VInputOTP :length="6" label="Six digit code" />
      <VTypography variant="caption" tone="muted">6, the default</VTypography>
    </div>

    <div class="row">
      <VInputOTP :length="8" label="Eight digit code" />
      <VTypography variant="caption" tone="muted">8, a backup code</VTypography>
    </div>
  </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-4);
}
</style>

Formats

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

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.

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

Submitting with empty boxes is refused

<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

PropTypeDefault
lengthnumber6
How many boxes the code has. It is ignored as soon as a pattern is given.
formatInputOTPFormat'numeric' | 'alpha' | 'alphanumeric''numeric'
Which characters the code is made of. It filters what can be typed or pasted, and decides which keyboard a phone offers.
patternstringnone
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.
separatorIconIconSourcenone
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.
sizeInputOTPSize'sm' | 'md' | 'lg''md'
The size of the boxes: 32, 40 or 48 pixels.
compactbooleanfalse
Takes 4px off the boxes, leaving the text and the icons as they are.
disabledbooleanfalse
Makes every box unusable, greyed out through the colour tokens.
readonlybooleanfalse
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.
invalidbooleanfalse
Marks the code as wrong, which colours the boxes and tells assistive technology so.
labelstringnone
What screen readers announce for the row as a whole. It falls back to the design system dictionary.
hintstringnone
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-modelstring''
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

EventType
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.

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