PIN Input
One-time-code and PIN entry split across single-character fields with paste distribution.
| Package | @vizejs/ui/pin-input |
| Maturity | stable |
| Own the source | vize lib pull pin-input |
| Requires | context, controllable-state, id |
| Aliases | otp input, one-time code, verification code, pin code, segmented input |
| Covers | HTML autocomplete one-time-code, Chakra PinInput, Reka UI PinInput |
Minimal setup
Use a Vue 3.5+ project with Vue SFC compilation configured (see Vite+ integration).
vp install @vizejs/ui
Add the public import below to <script setup lang="ts">. Components provide behavior without styles; the preview uses optional Paper styles.
import { PinInput, PinInputField } from "@vizejs/ui/pin-input";
Or copy the source into your project with vize lib pull pin-input (see Source Distribution).
Try it
This is the basic example maintained with the component source, using the public package imports. The preview adds the optional Paper styles.
Open the interactive example · Styling these components
Copy the example
<!-- Six-digit one-time code split across fields, bound with v-model and reporting completion. -->
<script setup lang="ts">
import { ref, useId } from "vue";
import { PinInput, PinInputField } from "@vizejs/ui/pin-input";
const code = ref("");
const verified = ref("");
const labelId = useId();
function onComplete(value: string): void {
verified.value = value;
}
</script>
<template>
<div>
<p :id="labelId">Enter the 6-digit code we sent to your phone</p>
<PinInput
v-slot="{ indexes }"
v-model="code"
:length="6"
otp
name="verificationCode"
:aria-labelledby="labelId"
@complete="onComplete"
>
<PinInputField v-for="index in indexes" :key="index" :index />
</PinInput>
<output>{{
verified ? `Verifying ${verified}…` : `${code.length} of 6 digits entered`
}}</output>
</div>
</template>
API
PinInput<Length extends number>
Source: src/families/form/pin-input/pin-input.vue
Props
| Prop | Type | Default | Description |
|---|---|---|---|
length (required) |
Length |
required |
Number of fields. A literal length types the complete characters tuple. |
modelValue |
string |
undefined |
Controlled code. undefined selects uncontrolled behavior. |
defaultValue |
string |
"" |
Initial uncontrolled code, also restored by form reset. |
type |
PinInputType |
"numeric" |
Accepted characters: decimal digits, or letters and digits. |
pattern |
RegExp |
undefined |
Single-character pattern that overrides type, for example /[0-9A-F]/. |
mask |
boolean |
false |
Render fields as password inputs so entered characters are masked. |
otp |
boolean |
true |
One-time-code mode: the first field advertises autocomplete="one-time-code" so browsers can autofill SMS codes. |
id |
string | null |
undefined |
Base id; fields use <id>-<index>. null and undefined select a deterministic fallback. |
name |
string |
undefined |
Native form field name. A hidden input submits the joined code. |
form |
string |
undefined |
Id of a form owner outside the component tree. |
placeholder |
string |
undefined |
Placeholder shown in each empty field, for example "○". |
disabled |
boolean |
false |
Disable every field and native form submission. |
required |
boolean |
false |
Require every field for native constraint validation. |
getFieldLabel |
(index: number, length: number) => string |
(index, length) => |
Accessible name of each field. |
ariaLabel |
string |
undefined |
Accessible name of the group when no label or aria-labelledby supplies one. |
ariaLabelledby |
string |
undefined |
Space-separated ids that label the group. |
ariaDescribedby |
string |
undefined |
Space-separated ids that describe every field. |
ariaErrormessage |
string |
undefined |
Id of the validation error message used while invalid. |
ariaInvalid |
PinInputAriaInvalid |
false |
Invalid state announced on every field. |
Events
| Event | Payload | Description |
|---|---|---|
update:modelValue |
[value: string] |
Fired when the joined code requests a new controlled value. |
complete |
[value: string, characters: PinInputCharacters<Length>] |
Fired when the last field is filled, with the code and its characters as a typed tuple. |
Slots
| Slot | Slot props | Description |
|---|---|---|
default |
PinInputSlotState |
Renders one PinInputField per entry in indexes, plus any separators. |
Exposed
| Member | Type | Description |
|---|---|---|
value |
string |
Entered characters joined, without gaps. |
length |
number |
Number of fields. |
indexes |
readonly number[] |
Field indexes to render, [0, 1, ..., length - 1]. |
complete |
boolean |
Whether every field is filled. |
disabled |
boolean |
Whether the fields are disabled. |
state |
PinInputState |
Stable state token. |
root |
HTMLDivElement | null |
Rendered group element. |
focus |
(index?: number) => void |
Focus a field (default: the first empty one). |
setValue |
(value: string) => boolean |
Replace the code (filtered and truncated); returns whether it changed. |
clear |
() => boolean |
Clear every field; returns whether the value changed. |
PinInputField
Source: src/families/form/pin-input/pin-input-field.vue
Props
| Prop | Type | Default | Description |
|---|---|---|---|
index (required) |
number |
required |
Zero-based position of this field in the code. |
ariaLabel |
string |
undefined |
Accessible name overriding the group's getFieldLabel. |
Behavior
Normative state x input -> outcome table for pin-input.vue and
pin-input-field.vue (@vizejs/ui/pin-input). The model is the joined code
(no gaps); a literal length types the complete characters tuple. Every row
is proven by the named test in pin-input.test.ts or pin-input-ssr.test.ts;
compile-only assertions live in pin-input.types.test-d.ts.
| # | State | Input | Outcome | Proven by |
|---|---|---|---|---|
| PI1 | helpers | sanitize / write / remove / narrow | accepted characters are filtered (NFKC folds full-width), writes clamp to the end and truncate, removals shift left, tuples narrow by length | sanitizes, writes, removes, and narrows code characters |
| PI2 | named, seeded | render | labelled role="group"; fields with deterministic ids, one-time-code autocomplete on the first field, inputmode, per-field labels, hidden joined value |
renders a labelled group of one-time-code fields with form hooks |
| PI3 | empty | typing | characters fill the first gap, focus advances, rejects are ignored, complete(value, tuple) fires once, typing over a field replaces it |
typing fills fields left to right, advances focus, and emits a typed completion |
| PI4 | any | paste / SMS autofill | pasted or autofilled codes are filtered and distributed across fields; focus lands on the last written field | paste and autofill distribute characters across fields |
| PI5 | filled | Backspace / Delete / arrows / Home / End | Backspace clears the focused character or deletes backwards from an empty field; Delete removes forward; arrows/Home/End move focus; Tab stays native | Backspace, Delete, arrows, Home, and End edit and move between fields |
| PI6 | options | mask / alphanumeric / pattern / controlled | mask renders password fields, type="alphanumeric" uses a text keyboard, pattern overrides accepted characters, controlled values win |
masking, alphanumeric codes, custom patterns, and controlled values |
| PI7 | in a form | submit / reset | hidden input submits the joined code; fields are required only until complete; form reset restores defaultValue |
submits the joined code, requires completion, and restores defaults on reset |
| PI8 | disabled | edits | every field is natively disabled and edits are ignored | disabled codes disable every field and ignore edits |
| PI9 | imperative | expose / missing provider | focus (first empty field by default), setValue, clear, state; fields outside a PinInput throw VIZE_UI_CONTEXT_MISSING |
exposes focus, setValue, clear, and state; fields need a PinInput |
| PI10 | SSR / hydration | isolated requests | byte-identical markup, hydration without diagnostics, and interactive fields | renders byte-identical code fields and hydrates without mismatches |
Public extension contract
| Surface | Contract |
|---|---|
| Parts | root (group), field (one native input per character). |
| Data attributes | data-vize-ui, root data-state/data-complete; field data-index/data-filled. |
| Slots | Root default slot receives PinInputSlotState; render a PinInputField per indexes entry. |
The subpath is tree-shakable and ships no CSS; those package contracts are
pinned by distribution.test.ts, check:size, and check:tree-shaking.