InputOtp

A one-time password input for verification codes and secure authentication

Usage

<script setup lang="ts">
import { InputOtp } from '@hareui/vue'
</script>

We've sent a code to a****@gmail.com

Didn't receive a code?

Resend
<script setup lang="ts">import { InputOtp, Link } from '@hareui/vue'</script><template>  <div class="flex w-[280px] flex-col gap-2">    <div class="flex flex-col gap-1">      <p class="text-sm text-muted">We've sent a code to a****@gmail.com</p>    </div>    <InputOtp label="Verify account" :groups="[3, 3]" />    <div class="flex items-center gap-[5px] px-1 pt-1">      <p class="text-sm text-muted">Didn't receive a code?</p>      <Link class="text-foreground underline" href="#">        Resend      </Link>    </div>  </div></template>

Anatomy

HeroUI's InputOTP is a low-level anatomy component built on the input-otp library: a single real <input> (invisible, but fully interactive) overlays a row of decorative slots, so typing, Backspace, arrow keys and paste are native browser text-editing behavior rather than custom per-slot focus management. InputOtp bundles that box together with a Label, Description and FieldError, following the same field convention as SearchField and NumberField. Slots are laid out with groups, an array of slot counts per visual group; a separator is rendered between each group.

<template>
  <InputOtp v-model="code" :length="6" :groups="[3, 3]" label="…" description="…" error-message="…">
    <template #label />                       <!-- Label content -->
    <template #description />                 <!-- Description content -->
    <template #error="{ validationErrors }" /> <!-- FieldError content, rendered while invalid -->
  </InputOtp>
</template>
ui keydata-slotElement
base—root <div> (HareUI-only), stacks label/box/description/error
—labelLabel (iff label / #label)
root—the box (<div data-input-otp-container>), HeroUI's .input-otp
groupinput-otp-groupone <div> per entry in groups
slotinput-otp-slotone <div> per character
slotValueinput-otp-slot-valuethe character inside a filled slot
caretinput-otp-caretblinking fake caret in the active, empty slot
separatorinput-otp-separator<div> between groups
inputinput-otpthe real, invisible <input> that receives focus, typing and paste; carries data-disabled / data-invalid
—descriptionDescription (iff description / #description)
—field-errorFieldError (while invalid, iff error-message / #error / validation errors)

Examples

Variants

The InputOtp component supports two visual variants:

  • primary (default) - Standard styling with shadow, suitable for most use cases
  • secondary - Lower emphasis variant without shadow, suitable for use in Surface components
<script setup lang="ts">import { InputOtp } from '@hareui/vue'</script><template>  <div class="flex flex-col gap-6">    <InputOtp label="Primary variant" variant="primary" :groups="[3, 3]" />    <InputOtp label="Secondary variant" variant="secondary" :groups="[3, 3]" />  </div></template>

In Surface

When used inside a Surface component, use variant="secondary" to apply the lower emphasis variant suitable for surface backgrounds.

We've sent a code to a****@gmail.com

Didn't receive a code?

Resend
<script setup lang="ts">import { InputOtp, Link, Surface } from '@hareui/vue'</script><template>  <Surface class="flex w-full flex-col gap-2 rounded-3xl p-6">    <p class="text-sm text-muted">We've sent a code to a****@gmail.com</p>    <InputOtp label="Verify account" variant="secondary" :groups="[3, 3]" />    <div class="flex items-center gap-[5px] px-1 pt-1">      <p class="text-sm text-muted">Didn't receive a code?</p>      <Link class="text-foreground underline" href="#">        Resend      </Link>    </div>  </Surface></template>

Disabled State

Code verification is currently disabled
<script setup lang="ts">import { InputOtp } from '@hareui/vue'</script><template>  <InputOtp class="w-[280px]" label="Verify account" description="Code verification is currently disabled" disabled :groups="[3, 3]" /></template>

Four Digits

A single group (the default when groups isn't set) renders with no separator.

<script setup lang="ts">import { InputOtp } from '@hareui/vue'</script><template>  <InputOtp class="w-[280px]" label="Enter PIN" :length="4" /></template>

Controlled

Control the value with v-model to synchronize with state, clear the input, or implement custom validation.

Enter a 6-digit code

<script setup lang="ts">import { InputOtp } from '@hareui/vue'import { ref } from 'vue'const value = ref('')</script><template>  <div class="flex w-[280px] flex-col gap-2">    <InputOtp v-model="value" label="Verify account" :groups="[3, 3]" />    <p class="text-sm text-muted">      <template v-if="value.length > 0">        Value: {{ value }} ({{ value.length }}/6) &bull;        <button class="font-medium text-foreground underline" type="button" @click="value = ''">          Clear        </button>      </template>      <template v-else>        Enter a 6-digit code      </template>    </p>  </div></template>

On Complete

Use the @complete event to trigger actions when every slot has just become filled.

<script setup lang="ts">import { Button, Form, InputOtp, Spinner } from '@hareui/vue'import { ref } from 'vue'const value = ref('')const isComplete = ref(false)const isSubmitting = ref(false)function handleComplete(code: string) {  isComplete.value = true  // eslint-disable-next-line no-console  console.log('Code complete:', code)}function handleSubmit(e: Event) {  e.preventDefault()  isSubmitting.value = true  // Simulate API call  setTimeout(() => {    isSubmitting.value = false    value.value = ''    isComplete.value = false  }, 2000)}</script><template>  <Form class="flex w-[280px] flex-col gap-2" @submit="handleSubmit">    <InputOtp      v-model="value"      label="Verify account"      :groups="[3, 3]"      @complete="handleComplete"      @update:model-value="isComplete = false"    />    <Button class="mt-2 w-full" :disabled="!isComplete" :pending="isSubmitting" type="submit" variant="primary">      <template v-if="isSubmitting">        <Spinner color="current" size="sm" />        Verifying...      </template>      <template v-else>        Verify Code      </template>    </Button>  </Form></template>

Form Example

A complete two-factor authentication form with validation and submission.

Enter the 6-digit code from your authenticator app

Having trouble?

Use backup code
<script setup lang="ts">import { Button, Form, InputOtp, Link, Spinner } from '@hareui/vue'import { ref } from 'vue'const value = ref('')const error = ref('')const isSubmitting = ref(false)function handleSubmit(e: Event) {  e.preventDefault()  error.value = ''  if (value.value.length !== 6) {    error.value = 'Please enter all 6 digits'    return  }  isSubmitting.value = true  // Simulate API call  setTimeout(() => {    if (value.value === '123456') {      // eslint-disable-next-line no-console      console.log('Code verified successfully!')      value.value = ''    }    else {      error.value = 'Invalid code. Please try again.'    }    isSubmitting.value = false  }, 1500)}</script><template>  <Form class="flex w-[280px] flex-col gap-4" @submit="handleSubmit">    <InputOtp      v-model="value"      label="Two-factor authentication"      description="Enter the 6-digit code from your authenticator app"      :invalid="!!error"      :error-message="error"      :groups="[3, 3]"      @update:model-value="error = ''"    />    <Button class="w-full" :disabled="value.length !== 6" :pending="isSubmitting" type="submit" variant="primary">      <template v-if="isSubmitting">        <Spinner color="current" size="sm" />        Verifying...      </template>      <template v-else>        Verify      </template>    </Button>    <div class="flex items-center justify-center gap-1">      <p class="text-sm text-muted">Having trouble?</p>      <Link class="text-sm text-foreground underline" href="#">        Use backup code      </Link>    </div>  </Form></template>

With Pattern

Use the pattern prop to restrict input to specific characters: 'digits', 'chars' or 'alphanumeric', mirroring HeroUI's exported REGEXP_ONLY_DIGITS / REGEXP_ONLY_CHARS / REGEXP_ONLY_DIGITS_AND_CHARS patterns.

Only alphabetic characters are allowed
<script setup lang="ts">import { InputOtp } from '@hareui/vue'</script><template>  <InputOtp class="w-[280px]" label="Enter code (letters only)" description="Only alphabetic characters are allowed" pattern="chars" :groups="[3, 3]" /></template>

With Validation

Use invalid together with error-message to surface errors.

Hint: The code is 123456
<script setup lang="ts">import { Button, Form, InputOtp } from '@hareui/vue'import { ref } from 'vue'const value = ref('')const isInvalid = ref(false)function onSubmit(e: Event) {  e.preventDefault()  if (value.value !== '123456') {    isInvalid.value = true    return  }  isInvalid.value = false  value.value = ''  // eslint-disable-next-line no-console  console.log('Code verified successfully!')}function handleChange(val: string | undefined) {  value.value = val ?? ''  isInvalid.value = false}</script><template>  <Form class="flex w-[280px] flex-col gap-2" @submit="onSubmit">    <InputOtp      :model-value="value"      label="Verify account"      description="Hint: The code is 123456"      name="code"      :invalid="isInvalid"      error-message="Invalid code. Please try again."      :groups="[3, 3]"      @update:model-value="handleChange"    />    <Button :disabled="value.length !== 6" type="submit">      Submit    </Button>  </Form></template>

Customization

Tailwind CSS

Resend code
<script setup lang="ts">import { InputOtp, Link } from '@hareui/vue'const slotClass = 'rounded-lg border-border/80 bg-default data-[active=true]:border-accent/40 data-[active=true]:bg-accent-soft'</script><template>  <div class="flex w-72 flex-col gap-2">    <InputOtp      label="Verify account"      :groups="[3, 3]"      :ui="{ slot: slotClass, separator: 'bg-border' }"    />    <Link class="text-sm text-link" href="#">      Resend code    </Link>  </div></template>

Global Configuration

app.use(createHareUI({
  ui: { inputOtp: { slots: { slot: 'size-12 rounded-xl border-2 font-bold' } } },
}))

Styling Reference

Slots

  • base → root <div> — HareUI-only flex-col wrapper around the label, box, description and error
  • root → [data-input-otp-container] — the box; relative flex w-full items-center gap-2
  • group → [data-slot="input-otp-group"] — one per entry in groups
  • slot → [data-slot="input-otp-slot"] — a single character cell
  • slotValue → [data-slot="input-otp-slot-value"] — the rendered character
  • caret → [data-slot="input-otp-caret"] — the blinking fake caret
  • separator → [data-slot="input-otp-separator"] — the dash between groups
  • input → [data-slot="input-otp"] — the real, invisible <input> (as in HeroUI, where input-otp passes data-slot to the input)

Interactive States

  • Active: [data-active="true"] on the slot that currently owns the (hidden) selection
  • Filled: [data-filled="true"] on a slot with a character
  • Disabled: [data-disabled="true"] on the input and every slot
  • Invalid: [data-invalid="true"] on the input and every slot

API Reference

Props

PropTypeDefaultDescription
modelValuestring-The current code. Bind with v-model. The code. Bind with v-model.
defaultValuestring-The initial code when uncontrolled (no v-model).
lengthnumber6Number of slots (characters) the code has.
groupsnumber[]-Number of slots per visual group. A separator is rendered between groups, e.g. [3, 3] for a 6-digit code split in half. Defaults to a single group of length slots (no separator).
pattern"digits" | "chars" | "alphanumeric"-Restricts which characters can be entered, mirroring HeroUI's exported REGEXP_ONLY_* patterns: 'digits' only allows 0-9, 'chars' only allows a-z/A-Z, 'alphanumeric' allows both. Unset allows any character, matching HeroUI's default since input-otp 1.4.
variant"primary" | "secondary"'primary'Visual variant of the slots.
labelstring-Label text rendered above the field.
descriptionstring-Help text rendered above the box, below the label.
errorMessagestring-Error message rendered below the field while invalid. Defaults to the validation errors.
disabledbooleanfalseWhether the field is disabled.
invalidbooleanundefinedWhether the field is invalid. Overrides validation when set.
requiredbooleanfalseWhether all slots must be filled before form submission.
validateValidateFn<string>-Validates the code. Return an error message (or several) when invalid, true/null/undefined when valid.
validationBehaviorValidationBehavior'native'native blocks form submission and shows errors on change or submit; aria shows errors in realtime. Defaults to the surrounding Form.
namestring-The name of the field, used when submitting an HTML form.
idstring-The id of the underlying input; label, description and error ids derive from it. Generated when omitted.
uiComponentSlots<{ slots: { base: string; root: string[]; group: string; slot: string[]; slotValue: string[]; caret: string; separator: string; input: string[]; }; variants: { variant: { primary: { root: string; }; secondary: { slot: string[]; }; }; }; defaultVariants: { variant: string; }; }>-Per-slot class overrides.

Slots

SlotPropsDescription
labelanyLabel content. Replaces the label prop.
descriptionanyDescription content. Replaces the description prop.
errorValidationResultError content, rendered while invalid. Replaces the errorMessage prop.

Emits

EventPayloadDescription
update:modelValue[value: string | undefined]-
complete[value: string]-

Accessibility

  • A single native <input> carries focus, typing, selection and paste, so standard text-editing shortcuts (Backspace, arrow keys, Home/End, select-and-retype) work without extra wiring
  • autocomplete="one-time-code" lets browsers and password managers offer SMS-delivered codes
  • label associates with the input through for/id; description and error-message are wired via aria-describedby
  • With name, the input submits its value with a form like any text field
  • required and pattern participate in native HTML5 constraint validation alongside validate

On this page

No Headings