InputOTP
An input for entering one-time passcodes with individual visual slots.
Import
ts
import {
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot,
REGEXP_ONLY_CHARS,
} from '@heroui-vue/vue'Usage
Basic
We've sent a code to a****@gmail.com
<template>
<div class="demo-input-otp-card">
<div class="demo-input-otp-heading">
<Label>Verify account</Label>
<p>We've sent a code to a****@gmail.com</p>
</div>
<InputOTP v-model="value" :max-length="6">
<InputOTPGroup>
<InputOTPSlot :index="0" />
<InputOTPSlot :index="1" />
<InputOTPSlot :index="2" />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot :index="3" />
<InputOTPSlot :index="4" />
<InputOTPSlot :index="5" />
</InputOTPGroup>
</InputOTP>
<div class="demo-input-otp-footer">
<p>Didn't receive a code?</p>
<Link href="#">Resend</Link>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot, Label, Link } from '@heroui-vue/vue'
const value = ref('')
</script>
<style lang="less">
.demo-input-otp-card {
display: flex;
width: 280px;
flex-direction: column;
gap: 0.5rem;
}
.demo-input-otp-heading {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.demo-input-otp-heading p,
.demo-input-otp-footer p {
margin: 0;
color: var(--color-muted);
font-size: 0.875rem;
}
.demo-input-otp-footer {
display: flex;
align-items: center;
gap: 5px;
padding: 0.25rem 0.25rem 0;
}
.demo-input-otp-footer a {
color: var(--color-foreground);
text-decoration: underline;
}
</style>Four Digits
<template>
<div class="demo-input-otp-card">
<Label>Enter PIN</Label>
<InputOTP v-model="value" :max-length="4">
<InputOTPGroup>
<InputOTPSlot :index="0" />
<InputOTPSlot :index="1" />
<InputOTPSlot :index="2" />
<InputOTPSlot :index="3" />
</InputOTPGroup>
</InputOTP>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { InputOTP, InputOTPGroup, InputOTPSlot, Label } from '@heroui-vue/vue'
const value = ref('')
</script>
<style lang="less">
.demo-input-otp-card {
display: flex;
width: 280px;
flex-direction: column;
gap: 0.5rem;
}
</style>Disabled State
Code verification is currently disabled
<template>
<div class="demo-input-otp-card">
<Label is-disabled>Verify account</Label>
<Description>Code verification is currently disabled</Description>
<InputOTP :max-length="6" is-disabled>
<InputOTPGroup>
<InputOTPSlot :index="0" />
<InputOTPSlot :index="1" />
<InputOTPSlot :index="2" />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot :index="3" />
<InputOTPSlot :index="4" />
<InputOTPSlot :index="5" />
</InputOTPGroup>
</InputOTP>
</div>
</template>
<script setup lang="ts">
import { Description, InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot, Label } from '@heroui-vue/vue'
</script>
<style lang="less">
.demo-input-otp-card {
display: flex;
width: 280px;
flex-direction: column;
gap: 0.5rem;
}
</style>With Pattern
Only alphabetic characters are allowed
<template>
<div class="demo-input-otp-card">
<Label>Enter code (letters only)</Label>
<Description>Only alphabetic characters are allowed</Description>
<InputOTP v-model="value" :max-length="6" :pattern="REGEXP_ONLY_CHARS">
<InputOTPGroup>
<InputOTPSlot :index="0" />
<InputOTPSlot :index="1" />
<InputOTPSlot :index="2" />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot :index="3" />
<InputOTPSlot :index="4" />
<InputOTPSlot :index="5" />
</InputOTPGroup>
</InputOTP>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Description, InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot, Label, REGEXP_ONLY_CHARS } from '@heroui-vue/vue'
const value = ref('')
</script>
<style lang="less">
.demo-input-otp-card {
display: flex;
width: 280px;
flex-direction: column;
gap: 0.5rem;
}
</style>Controlled
Enter a 6-digit code
<template>
<div class="demo-input-otp-card">
<Label>Verify account</Label>
<InputOTP v-model="value" :max-length="6">
<InputOTPGroup>
<InputOTPSlot :index="0" />
<InputOTPSlot :index="1" />
<InputOTPSlot :index="2" />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot :index="3" />
<InputOTPSlot :index="4" />
<InputOTPSlot :index="5" />
</InputOTPGroup>
</InputOTP>
<Description>
<template v-if="value.length > 0">
Value: {{ value }} ({{ value.length }}/6) ·
<button class="demo-input-otp-link-button" type="button" @click="value = ''">
Clear
</button>
</template>
<template v-else>
Enter a 6-digit code
</template>
</Description>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Description, InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot, Label } from '@heroui-vue/vue'
const value = ref('')
</script>
<style lang="less">
.demo-input-otp-card {
display: flex;
width: 280px;
flex-direction: column;
gap: 0.5rem;
}
.demo-input-otp-link-button {
border: 0;
background: transparent;
color: var(--color-foreground);
cursor: var(--cursor-interactive);
font: inherit;
font-weight: 500;
padding: 0;
text-decoration: underline;
}
</style>With Validation
<template>
<div class="demo-input-otp-card">
<form class="demo-input-otp-form" @submit.prevent="handleSubmit">
<Label>Verify account</Label>
<Description>Hint: The code is 123456</Description>
<InputOTP
v-model="value"
:is-invalid="isInvalid"
:max-length="6"
aria-describedby="code-error"
name="code"
@change="isInvalid = false"
>
<InputOTPGroup>
<InputOTPSlot :index="0" />
<InputOTPSlot :index="1" />
<InputOTPSlot :index="2" />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot :index="3" />
<InputOTPSlot :index="4" />
<InputOTPSlot :index="5" />
</InputOTPGroup>
</InputOTP>
<span id="code-error" class="field-error" :data-visible="isInvalid ? 'true' : undefined">
Invalid code. Please try again.
</span>
<Button :is-disabled="value.length !== 6" type="submit">
Submit
</Button>
</form>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Button, Description, InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot, Label } from '@heroui-vue/vue'
const value = ref('')
const isInvalid = ref(false)
const handleSubmit = () => {
if (value.value !== '123456') {
isInvalid.value = true
return
}
isInvalid.value = false
value.value = ''
}
</script>
<style lang="less">
.demo-input-otp-card,
.demo-input-otp-form {
display: flex;
width: 280px;
flex-direction: column;
gap: 0.5rem;
}
</style>On Complete
<template>
<form class="demo-input-otp-form" @submit.prevent="handleSubmit">
<Label>Verify account</Label>
<InputOTP
v-model="value"
:max-length="6"
@complete="isComplete = true"
@change="isComplete = false"
>
<InputOTPGroup>
<InputOTPSlot :index="0" />
<InputOTPSlot :index="1" />
<InputOTPSlot :index="2" />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot :index="3" />
<InputOTPSlot :index="4" />
<InputOTPSlot :index="5" />
</InputOTPGroup>
</InputOTP>
<Button class="demo-input-otp-submit" :is-disabled="!isComplete" :is-pending="isSubmitting" type="submit" variant="primary">
<Spinner v-if="isSubmitting" color="current" size="sm" />
{{ isSubmitting ? 'Verifying...' : 'Verify Code' }}
</Button>
</form>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Button, InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot, Label, Spinner } from '@heroui-vue/vue'
const value = ref('')
const isComplete = ref(false)
const isSubmitting = ref(false)
const handleSubmit = () => {
isSubmitting.value = true
window.setTimeout(() => {
value.value = ''
isComplete.value = false
isSubmitting.value = false
}, 1200)
}
</script>
<style lang="less">
.demo-input-otp-form {
display: flex;
width: 280px;
flex-direction: column;
gap: 0.5rem;
}
.demo-input-otp-submit {
margin-top: 0.5rem;
width: 100%;
}
</style>Form Example
<template>
<form class="demo-input-otp-form" @submit.prevent="handleSubmit">
<div class="demo-input-otp-field">
<Label>Two-factor authentication</Label>
<Description>Enter the 6-digit code from your authenticator app</Description>
<InputOTP
v-model="value"
:is-invalid="Boolean(error)"
:max-length="6"
name="code"
@change="error = ''"
>
<InputOTPGroup>
<InputOTPSlot :index="0" />
<InputOTPSlot :index="1" />
<InputOTPSlot :index="2" />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot :index="3" />
<InputOTPSlot :index="4" />
<InputOTPSlot :index="5" />
</InputOTPGroup>
</InputOTP>
<span class="field-error" :data-visible="error ? 'true' : undefined">
{{ error }}
</span>
</div>
<Button class="demo-input-otp-submit" :is-disabled="value.length !== 6" :is-pending="isSubmitting" type="submit" variant="primary">
<Spinner v-if="isSubmitting" color="current" size="sm" />
{{ isSubmitting ? 'Verifying...' : 'Verify' }}
</Button>
<div class="demo-input-otp-help">
<p>Having trouble?</p>
<Link href="#">Use backup code</Link>
</div>
</form>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Button, Description, InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot, Label, Link, Spinner } from '@heroui-vue/vue'
const value = ref('')
const error = ref('')
const isSubmitting = ref(false)
const handleSubmit = () => {
error.value = ''
if (value.value.length !== 6) {
error.value = 'Please enter all 6 digits'
return
}
isSubmitting.value = true
window.setTimeout(() => {
if (value.value === '123456') {
value.value = ''
} else {
error.value = 'Invalid code. Please try again.'
}
isSubmitting.value = false
}, 1200)
}
</script>
<style lang="less">
.demo-input-otp-form,
.demo-input-otp-field {
display: flex;
width: 280px;
flex-direction: column;
}
.demo-input-otp-form {
gap: 1rem;
}
.demo-input-otp-field {
gap: 0.5rem;
}
.demo-input-otp-submit {
width: 100%;
}
.demo-input-otp-help {
display: flex;
justify-content: center;
gap: 0.25rem;
}
.demo-input-otp-help p {
margin: 0;
color: var(--color-muted);
font-size: 0.875rem;
}
.demo-input-otp-help a {
color: var(--color-foreground);
font-size: 0.875rem;
text-decoration: underline;
}
</style>Variants
<template>
<div class="demo-input-otp-stack">
<div class="demo-input-otp-card">
<Label>Primary variant</Label>
<InputOTP v-model="primaryValue" :max-length="6" variant="primary">
<InputOTPGroup>
<InputOTPSlot :index="0" />
<InputOTPSlot :index="1" />
<InputOTPSlot :index="2" />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot :index="3" />
<InputOTPSlot :index="4" />
<InputOTPSlot :index="5" />
</InputOTPGroup>
</InputOTP>
</div>
<div class="demo-input-otp-card">
<Label>Secondary variant</Label>
<InputOTP v-model="secondaryValue" :max-length="6" variant="secondary">
<InputOTPGroup>
<InputOTPSlot :index="0" />
<InputOTPSlot :index="1" />
<InputOTPSlot :index="2" />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot :index="3" />
<InputOTPSlot :index="4" />
<InputOTPSlot :index="5" />
</InputOTPGroup>
</InputOTP>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot, Label } from '@heroui-vue/vue'
const primaryValue = ref('')
const secondaryValue = ref('')
</script>
<style lang="less">
.demo-input-otp-stack,
.demo-input-otp-card {
display: flex;
flex-direction: column;
}
.demo-input-otp-stack {
gap: 1.5rem;
}
.demo-input-otp-card {
gap: 0.5rem;
}
</style>In Surface
We've sent a code to a****@gmail.com
<template>
<Surface class="demo-input-otp-surface">
<div class="demo-input-otp-heading">
<Label>Verify account</Label>
<p>We've sent a code to a****@gmail.com</p>
</div>
<InputOTP v-model="value" :max-length="6" variant="secondary">
<InputOTPGroup>
<InputOTPSlot :index="0" />
<InputOTPSlot :index="1" />
<InputOTPSlot :index="2" />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot :index="3" />
<InputOTPSlot :index="4" />
<InputOTPSlot :index="5" />
</InputOTPGroup>
</InputOTP>
<div class="demo-input-otp-footer">
<p>Didn't receive a code?</p>
<Link href="#">Resend</Link>
</div>
</Surface>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot, Label, Link, Surface } from '@heroui-vue/vue'
const value = ref('')
</script>
<style lang="less">
.demo-input-otp-surface {
box-sizing: border-box;
display: flex;
width: 100%;
max-width: 328px;
flex-direction: column;
gap: 0.5rem;
border-radius: 1.5rem;
padding: 1.5rem;
}
.demo-input-otp-heading {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.demo-input-otp-heading p,
.demo-input-otp-footer p {
margin: 0;
color: var(--color-muted);
font-size: 0.875rem;
}
.demo-input-otp-footer {
display: flex;
align-items: center;
gap: 5px;
padding: 0.25rem 0.25rem 0;
}
.demo-input-otp-footer a {
color: var(--color-foreground);
text-decoration: underline;
}
</style>Styling
Use InputOTP for value ownership and InputOTPSlot for visual digits. Demo layout CSS is included in each source panel.
CSS Classes
| Class | Description |
|---|---|
.input-otp | Root input group |
.input-otp__group | Slot group |
.input-otp__slot | Individual visual slot |
.input-otp__slot-value | Rendered character |
.input-otp__caret | Active caret |
.input-otp__separator | Separator |
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
modelValue | string | undefined | Controlled value for v-model |
defaultValue | string | '' | Initial uncontrolled value |
maxLength | number | 6 | Maximum passcode length |
pattern | RegExp \| string | undefined | Per-character input filter |
isInvalid | boolean | false | Invalid state |
isDisabled | boolean | false | Disabled state |
variant | 'primary' \| 'secondary' | 'primary' | Visual variant |
Events
| Event | Payload | Description |
|---|---|---|
update:modelValue | string | Emits when value changes |
change | string | Emits when value changes |
complete | string | Emits once the value reaches maxLength |