OTP Input

A field for one-time codes, with one slot per character. The code can be typed, pasted or filled in automatically from a text message.

Installation

bash
npx @smicolon/fasla-ui@latest add input-otp

Run init once per project before adding components. See Installation.

Preview

Variants

Four types that differ in how the slots are grouped.

Simple
Digits only
With Separator
With Spacing

States

The active slot is where the next character goes. The field shows an error state when the code is wrong, and fades when disabled.

Default
Active
Destructive
Disabled

Label and error message

Give the field a visible label, and show any error message below it so screen readers read it with the field.

Verification code

Enter the 6-digit code from the text message. In this example, it is 123456.

Accessibility

  • One real input lies over the slots and holds the whole code, so screen readers read it as one text field. It asks the phone for the code from a text message (autoComplete="one-time-code") and shows the numeric keyboard (inputMode="numeric"). The slots are hidden from screen readers, so the code is not read twice.
  • Always name the field: aria-labelledby with a visible label, or aria-label when there is none.
  • aria-invalid goes to the input itself, so screen readers say the code is invalid. Link the error message with aria-describedby.
  • Typing fills the slots in order, and Backspace clears the last one. The arrow keys move between slots, and a paste fills them all at once. A character that fails pattern is ignored.
  • Digits typed or pasted in Arabic-Indic form, such as ١٢٣, are stored as Western digits, so the code is the same in every language and passes a digits-only pattern.
  • Direction is inherited from dir, never a prop. On a right-to-left page the first slot is on the right and the code fills from the right. Every arrow moves the way it points: the left arrow goes to the next slot, in every browser.

Props

InputOTP

PropTypeDefaultDescription
maxLengthnumber—How many characters the code has: one slot for each. Required.
valuestring—The code, for a controlled field. Use it with onChange.
defaultValuestring—The code on first render, for an uncontrolled field.
onChange(value) => void—Called with the new code on every change.
onComplete(value) => void—Called with the code once the last slot is filled.
patternstring—A regular expression the code must match, such as REGEXP_ONLY_DIGITS from input-otp. A change that fails it is ignored. Arabic-Indic digits are read as Western digits.
pasteTransformer(text) => string—Cleans pasted text before it is checked, such as removing the spaces and dashes from a copied code.
inputModestring"numeric"The on-screen keyboard to show. Use "text" for codes with letters.
autoCompletestring"one-time-code"Lets the phone offer the code from a text message.
disabledbooleanfalseDisables the field and dims it to 50%.
aria-invalidboolean—Marks the code wrong: the input is announced as invalid, and every slot's border turns red.
aria-labelstring—Names the field for screen readers when there is no visible label.
aria-labelledbystring—The id of the visible label that names the field.
aria-describedbystring—The id of the helper text or error message read with the field.
namestring—The name the code is submitted under.
containerClassNamestring—Extra classes for the row that holds the groups and separators, such as gap-3 for more than the default 8px between them.
childrenReactNode—The groups and separators.
classNamestring—Extra classes for the hidden input.

InputOTPGroup

PropTypeDefaultDescription
childrenReactNode—The slots, side by side, sharing their borders.
classNamestring—Extra classes for the group.

InputOTPSlot

PropTypeDefaultDescription
indexnumber—Which character of the code the slot shows, counting from 0.
aria-invalidboolean—Marks this slot alone as wrong. Without it, the slot follows aria-invalid on InputOTP.
classNamestring—Extra classes for the slot.

InputOTPSeparator

PropTypeDefaultDescription
childrenReactNode—Replaces the line with your own content, in the same 24px box.
classNamestring—Extra classes for the separator.

Usage

Simple, six digits

tsx
import { REGEXP_ONLY_DIGITS } from "input-otp"
import {
  InputOTP,
  InputOTPGroup,
  InputOTPSeparator,
  InputOTPSlot,
} from "@/components/ui/input-otp"

export function VerificationCode() {
  return (
    <InputOTP
      maxLength={6}
      pattern={REGEXP_ONLY_DIGITS}
      aria-label="Verification code"
    >
      <InputOTPGroup>
        <InputOTPSlot index={0} />
        <InputOTPSlot index={1} />
        <InputOTPSlot index={2} />
      </InputOTPGroup>
      <InputOTPSeparator />
      <InputOTPGroup>
        <InputOTPSlot index={3} />
        <InputOTPSlot index={4} />
        <InputOTPSlot index={5} />
      </InputOTPGroup>
    </InputOTP>
  )
}

With a label and an error

tsx
"use client"

import * as React from "react"
import { REGEXP_ONLY_DIGITS } from "input-otp"
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp"

export function VerifyCode({ onVerify }: { onVerify: (code: string) => Promise<boolean> }) {
  const labelId = React.useId()
  const messageId = React.useId()
  const [code, setCode] = React.useState("")
  const [wrong, setWrong] = React.useState(false)

  return (
    <div className="flex flex-col gap-2">
      <span id={labelId} className="text-sm font-medium">
        Verification code
      </span>
      <InputOTP
        maxLength={6}
        pattern={REGEXP_ONLY_DIGITS}
        value={code}
        onChange={(next) => {
          setCode(next)
          setWrong(false)
        }}
        onComplete={async (next) => setWrong(!(await onVerify(next)))}
        aria-labelledby={labelId}
        aria-describedby={messageId}
        aria-invalid={wrong || undefined}
      >
        <InputOTPGroup>
          {Array.from({ length: 6 }, (_, index) => (
            <InputOTPSlot key={index} index={index} />
          ))}
        </InputOTPGroup>
      </InputOTP>
      <p id={messageId} className={wrong ? "text-xs text-destructive" : "text-xs text-muted-foreground"}>
        {wrong ? "That code is wrong. Check the message and try again." : "Enter the 6-digit code from the text message."}
      </p>
    </div>
  )
}

PIN with spacing

tsx
import { REGEXP_ONLY_DIGITS } from "input-otp"
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp"

export function PinField() {
  return (
    <InputOTP maxLength={4} pattern={REGEXP_ONLY_DIGITS} name="pin" aria-label="PIN">
      {Array.from({ length: 4 }, (_, index) => (
        <InputOTPGroup key={index}>
          <InputOTPSlot index={index} />
        </InputOTPGroup>
      ))}
    </InputOTP>
  )
}