Skip to content

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

Hint: The code is 123456
Invalid code. Please try again.

<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

Enter the 6-digit code from your authenticator app

Having trouble?

Use backup code

<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

ClassDescription
.input-otpRoot input group
.input-otp__groupSlot group
.input-otp__slotIndividual visual slot
.input-otp__slot-valueRendered character
.input-otp__caretActive caret
.input-otp__separatorSeparator

API

Props

PropTypeDefaultDescription
modelValuestringundefinedControlled value for v-model
defaultValuestring''Initial uncontrolled value
maxLengthnumber6Maximum passcode length
patternRegExp \| stringundefinedPer-character input filter
isInvalidbooleanfalseInvalid state
isDisabledbooleanfalseDisabled state
variant'primary' \| 'secondary''primary'Visual variant

Events

EventPayloadDescription
update:modelValuestringEmits when value changes
changestringEmits when value changes
completestringEmits once the value reaches maxLength

Released under the Apache-2.0 License.