InputOtp
A one-time password input for verification codes and secure authentication
Usage
<script setup lang="ts">
import { InputOtp } from '@hareui/vue'
</script><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 key | data-slot | Element |
|---|---|---|
base | — | root <div> (HareUI-only), stacks label/box/description/error |
| — | label | Label (iff label / #label) |
root | — | the box (<div data-input-otp-container>), HeroUI's .input-otp |
group | input-otp-group | one <div> per entry in groups |
slot | input-otp-slot | one <div> per character |
slotValue | input-otp-slot-value | the character inside a filled slot |
caret | input-otp-caret | blinking fake caret in the active, empty slot |
separator | input-otp-separator | <div> between groups |
input | input-otp | the real, invisible <input> that receives focus, typing and paste; carries data-disabled / data-invalid |
| — | description | Description (iff description / #description) |
| — | field-error | FieldError (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 casessecondary- 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.
<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
<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) • <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.
<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.
<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.
<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
<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 errorroot→[data-input-otp-container]— the box;relative flex w-full items-center gap-2group→[data-slot="input-otp-group"]— one per entry ingroupsslot→[data-slot="input-otp-slot"]— a single character cellslotValue→[data-slot="input-otp-slot-value"]— the rendered charactercaret→[data-slot="input-otp-caret"]— the blinking fake caretseparator→[data-slot="input-otp-separator"]— the dash between groupsinput→[data-slot="input-otp"]— the real, invisible<input>(as in HeroUI, whereinput-otppassesdata-slotto 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
| Prop | Type | Default | Description |
|---|---|---|---|
modelValue | string | - | The current code. Bind with v-model.
The code. Bind with v-model. |
defaultValue | string | - | The initial code when uncontrolled (no v-model). |
length | number | 6 | Number of slots (characters) the code has. |
groups | number[] | - | 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. |
label | string | - | Label text rendered above the field. |
description | string | - | Help text rendered above the box, below the label. |
errorMessage | string | - | Error message rendered below the field while invalid. Defaults to the validation errors. |
disabled | boolean | false | Whether the field is disabled. |
invalid | boolean | undefined | Whether the field is invalid. Overrides validation when set. |
required | boolean | false | Whether all slots must be filled before form submission. |
validate | ValidateFn<string> | - | Validates the code. Return an error message (or several) when invalid, true/null/undefined when valid. |
validationBehavior | ValidationBehavior | 'native' | native blocks form submission and shows errors on change or submit; aria shows errors in realtime.
Defaults to the surrounding Form. |
name | string | - | The name of the field, used when submitting an HTML form. |
id | string | - | The id of the underlying input; label, description and error ids derive from it. Generated when omitted. |
ui | ComponentSlots<{ 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
| Slot | Props | Description |
|---|---|---|
label | any | Label content. Replaces the label prop. |
description | any | Description content. Replaces the description prop. |
error | ValidationResult | Error content, rendered while invalid. Replaces the errorMessage prop. |
Emits
| Event | Payload | Description |
|---|---|---|
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 codeslabelassociates with the input throughfor/id;descriptionanderror-messageare wired viaaria-describedby- With
name, the input submits its value with a form like any text field requiredandpatternparticipate in native HTML5 constraint validation alongsidevalidate





