HextaUI

Input OTP

One-time code slots that take typing, paste and SMS autofill, with an opt-in animation that cascades codes in and a status for verifying.

Type or paste 123456 to pass. Anything else fails.

"use client"

import * as React from "react"

import {
  InputOTP,
  InputOTPGroup,
  InputOTPSeparator,
  InputOTPSlot,
  type InputOTPStatus,
} from "@/components/ui/input-otp"
import { Label } from "@/components/ui/label"

export function InputOTPDemo() {
  const [value, setValue] = React.useState("")
  const [status, setStatus] = React.useState<InputOTPStatus>("idle")
  const timer = React.useRef<ReturnType<typeof setTimeout>>(undefined)

  React.useEffect(() => () => clearTimeout(timer.current), [])

  function verify(code: string) {
    setStatus("loading")
    clearTimeout(timer.current)
    timer.current = setTimeout(() => {
      if (code === "123456") {
        setStatus("success")
        return
      }
      setStatus("error")
      timer.current = setTimeout(() => {
        setValue("")
        setStatus("idle")
      }, 900)
    }, 1200)
  }

  return (
    <div className="flex max-w-full min-w-0 flex-col items-center gap-3">
      <Label htmlFor="input-otp-demo">Verification code</Label>
      <InputOTP
        id="input-otp-demo"
        length={6}
        variant="separate"
        animated
        status={status}
        value={value}
        onValueChange={(next) => {
          setValue(next)
          setStatus("idle")
        }}
        onValueComplete={verify}
        aria-describedby="input-otp-demo-hint"
      >
        <InputOTPGroup>
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
        </InputOTPGroup>
        <InputOTPSeparator />
        <InputOTPGroup>
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
        </InputOTPGroup>
      </InputOTP>
      <p id="input-otp-demo-hint" className="text-sm text-muted-foreground">
        Type or paste 123456 to pass. Anything else fails.
      </p>
    </div>
  )
}
pnpm dlx shadcn@latest add https://hextaui.com/r/input-otp.json

Adds the component, the HextaUI theme tokens and any HextaUI components it depends on.

import {
  InputOTP,
  InputOTPGroup,
  InputOTPSeparator,
  InputOTPSlot,
} from "@/components/ui/input-otp"
<label htmlFor="code">Verification code</label>
<InputOTP id="code" length={6}>
  <InputOTPGroup>
    <InputOTPSlot />
    <InputOTPSlot />
    <InputOTPSlot />
  </InputOTPGroup>
  <InputOTPSeparator />
  <InputOTPGroup>
    <InputOTPSlot />
    <InputOTPSlot />
    <InputOTPSlot />
  </InputOTPGroup>
</InputOTP>

Render one <InputOTPSlot /> per character and set length to the same number. Slots find their position on their own, so there is no index prop to keep in sync.

InputOTP
├── InputOTPGroup
│   └── InputOTPSlot
├── InputOTPSeparator
└── InputOTPGroup
    └── InputOTPSlot

Joined

The default look. Each <InputOTPGroup /> joins its slots into one strip with shared edges.

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

export function InputOTPBasic() {
  return (
    <div className="flex max-w-full min-w-0 flex-col items-start gap-2">
      <Label htmlFor="input-otp-basic">Verification code</Label>
      <InputOTP id="input-otp-basic" length={6}>
        <InputOTPGroup>
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
        </InputOTPGroup>
        <InputOTPSeparator />
        <InputOTPGroup>
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
        </InputOTPGroup>
      </InputOTP>
    </div>
  )
}

Separate

variant="separate" gives every slot its own rounded box with a gap between them.

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

export function InputOTPSeparate() {
  return (
    <InputOTP length={6} variant="separate" aria-label="Verification code">
      <InputOTPGroup>
        <InputOTPSlot />
        <InputOTPSlot />
        <InputOTPSlot />
        <InputOTPSlot />
        <InputOTPSlot />
        <InputOTPSlot />
      </InputOTPGroup>
    </InputOTP>
  )
}

Sizes

sm, default and lg match the input and button heights. On touch screens every size grows to at least 44px with a 16px font.

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

export function InputOTPSizes() {
  return (
    <div className="flex max-w-full min-w-0 flex-col items-start gap-4">
      <InputOTP length={4} size="sm" aria-label="Small code">
        <InputOTPGroup>
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
        </InputOTPGroup>
      </InputOTP>
      <InputOTP length={4} aria-label="Default code">
        <InputOTPGroup>
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
        </InputOTPGroup>
      </InputOTP>
      <InputOTP length={4} size="lg" aria-label="Large code">
        <InputOTPGroup>
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
        </InputOTPGroup>
      </InputOTP>
    </div>
  )
}

Animated

animated is off by default. With it, typed characters rise in, deleted ones sink out while the rest slide over, and a whole code from autofill, paste or your own state cascades in slot by slot. Press Fill code to see the cascade.

"use client"

import * as React from "react"

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

export function InputOTPAnimated() {
  const [value, setValue] = React.useState("")

  return (
    <div className="flex max-w-full min-w-0 flex-col items-center gap-4">
      <InputOTP
        length={6}
        variant="separate"
        animated
        value={value}
        onValueChange={setValue}
        aria-label="Verification code"
      >
        <InputOTPGroup>
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
        </InputOTPGroup>
        <InputOTPSeparator />
        <InputOTPGroup>
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
        </InputOTPGroup>
      </InputOTP>
      <div className="flex gap-2">
        <Button variant="outline" size="sm" onClick={() => setValue("482913")}>
          Fill code
        </Button>
        <Button variant="ghost" size="sm" onClick={() => setValue("")}>
          Clear
        </Button>
      </div>
    </div>
  )
}

Status

status shows the result of checking the code. loading locks the slots and marks the field busy, error marks every slot invalid and success turns the edges green. Each one is announced. With animated, loading runs a wave, error shakes once and success pops the characters.

Verifying code
"use client"

import * as React from "react"

import {
  InputOTP,
  InputOTPGroup,
  InputOTPSlot,
  type InputOTPStatus,
} from "@/components/ui/input-otp"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

const statuses: InputOTPStatus[] = ["idle", "loading", "success", "error"]

export function InputOTPStatusExample() {
  const [status, setStatus] = React.useState<InputOTPStatus>("loading")

  return (
    <div className="flex max-w-full min-w-0 flex-col items-center gap-4">
      <InputOTP
        length={6}
        variant="separate"
        animated
        status={status}
        defaultValue="381904"
        aria-label="Verification code"
      >
        <InputOTPGroup>
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
        </InputOTPGroup>
      </InputOTP>
      <ToggleGroup
        aria-label="Status"
        size="sm"
        value={[status]}
        onValueChange={(next) => {
          if (next[0]) {
            setStatus(next[0] as InputOTPStatus)
          }
        }}
      >
        {statuses.map((item) => (
          <ToggleGroupItem key={item} value={item}>
            {item}
          </ToggleGroupItem>
        ))}
      </ToggleGroup>
    </div>
  )
}

Controlled

Pass value and onValueChange. The value is always the filtered code, never longer than length.

Enter your code.

"use client"

import * as React from "react"

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

export function InputOTPControlled() {
  const [value, setValue] = React.useState("")

  return (
    <div className="flex max-w-full min-w-0 flex-col items-center gap-3">
      <InputOTP
        length={6}
        value={value}
        onValueChange={setValue}
        aria-label="Verification code"
      >
        <InputOTPGroup>
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
        </InputOTPGroup>
      </InputOTP>
      <p className="text-sm text-muted-foreground tabular-nums">
        {value === "" ? "Enter your code." : `You entered: ${value}`}
      </p>
    </div>
  )
}

Form

With a name, the code is submitted with the form. autoSubmit submits as soon as the last slot fills, so an autofilled code signs people in without another tap.

"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import {
  InputOTP,
  InputOTPGroup,
  InputOTPSlot,
} from "@/components/ui/input-otp"
import { Label } from "@/components/ui/label"

export function InputOTPForm() {
  const [submitted, setSubmitted] = React.useState<string>()

  return (
    <form
      className="flex max-w-full min-w-0 flex-col items-start gap-3"
      onSubmit={(event) => {
        event.preventDefault()
        setSubmitted(String(new FormData(event.currentTarget).get("code")))
      }}
    >
      <Label htmlFor="input-otp-form">Sign-in code</Label>
      <InputOTP id="input-otp-form" name="code" length={6} required autoSubmit>
        <InputOTPGroup>
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
        </InputOTPGroup>
      </InputOTP>
      <div className="flex items-center gap-3">
        <Button type="submit" size="sm">
          Continue
        </Button>
        <p role="status" className="text-sm text-muted-foreground tabular-nums">
          {submitted ? `Submitted ${submitted}` : null}
        </p>
      </div>
    </form>
  )
}

With Field

Inside a <Field /> the label, description and error are linked for you. Enter anything but 000000 to see the error.

We sent it to [email protected].

"use client"

import * as React from "react"

import {
  Field,
  FieldDescription,
  FieldError,
  FieldLabel,
} from "@/components/ui/field"
import {
  InputOTP,
  InputOTPGroup,
  InputOTPSeparator,
  InputOTPSlot,
} from "@/components/ui/input-otp"

export function InputOTPField() {
  const [value, setValue] = React.useState("")
  const [error, setError] = React.useState<string>()

  return (
    <Field invalid={error !== undefined} className="w-fit">
      <FieldLabel>Verification code</FieldLabel>
      <InputOTP
        length={6}
        value={value}
        onValueChange={(next) => {
          setValue(next)
          setError(undefined)
        }}
        onValueComplete={(code) => {
          if (code !== "000000") {
            setError("That code has expired. Request a new one.")
          }
        }}
      >
        <InputOTPGroup>
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
        </InputOTPGroup>
        <InputOTPSeparator />
        <InputOTPGroup>
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
        </InputOTPGroup>
      </InputOTP>
      <FieldDescription>We sent it to [email protected].</FieldDescription>
      <FieldError errors={error ? [{ message: error }] : []} />
    </Field>
  )
}

Invalid

aria-invalid on the root marks every slot. Link the message with aria-describedby.

That code doesn’t match. Check the latest message.

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

export function InputOTPInvalid() {
  return (
    <div className="flex max-w-full min-w-0 flex-col items-start gap-2">
      <InputOTP
        length={6}
        defaultValue="111111"
        aria-invalid
        aria-label="Verification code"
        aria-describedby="input-otp-invalid-error"
      >
        <InputOTPGroup>
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
        </InputOTPGroup>
      </InputOTP>
      <p id="input-otp-invalid-error" className="text-sm text-destructive">
        That code doesn’t match. Check the latest message.
      </p>
    </div>
  )
}

Letters and numbers

validationType="alphanumeric" accepts recovery and invite codes, and normalizeValue upper-cases them as they are typed or pasted.

"use client"

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

export function InputOTPAlphanumeric() {
  return (
    <InputOTP
      length={8}
      validationType="alphanumeric"
      normalizeValue={(value) => value.toUpperCase()}
      aria-label="Recovery code"
    >
      <InputOTPGroup>
        <InputOTPSlot />
        <InputOTPSlot />
        <InputOTPSlot />
        <InputOTPSlot />
      </InputOTPGroup>
      <InputOTPSeparator />
      <InputOTPGroup>
        <InputOTPSlot />
        <InputOTPSlot />
        <InputOTPSlot />
        <InputOTPSlot />
      </InputOTPGroup>
    </InputOTP>
  )
}

Masked

mask hides each character, for PINs. Turn autofill off with autoComplete="off" when the value is not a one-time code.

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

export function InputOTPMasked() {
  return (
    <InputOTP
      length={4}
      mask
      animated
      variant="separate"
      autoComplete="off"
      aria-label="PIN"
    >
      <InputOTPGroup>
        <InputOTPSlot />
        <InputOTPSlot />
        <InputOTPSlot />
        <InputOTPSlot />
      </InputOTPGroup>
    </InputOTP>
  )
}

Custom separator

Group the slots any way you like and pass your own icon to <InputOTPSeparator />.

import { IconPointFilled } from "@tabler/icons-react"

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

export function InputOTPCustomSeparator() {
  return (
    <InputOTP length={6} variant="separate" aria-label="Pairing code">
      <InputOTPGroup>
        <InputOTPSlot />
        <InputOTPSlot />
      </InputOTPGroup>
      <InputOTPSeparator>
        <IconPointFilled aria-hidden="true" />
      </InputOTPSeparator>
      <InputOTPGroup>
        <InputOTPSlot />
        <InputOTPSlot />
      </InputOTPGroup>
      <InputOTPSeparator>
        <IconPointFilled aria-hidden="true" />
      </InputOTPSeparator>
      <InputOTPGroup>
        <InputOTPSlot />
        <InputOTPSlot />
      </InputOTPGroup>
    </InputOTP>
  )
}

Disabled

A disabled field can’t be focused or edited.

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

export function InputOTPDisabled() {
  return (
    <InputOTP
      length={6}
      defaultValue="12"
      disabled
      aria-label="Verification code"
    >
      <InputOTPGroup>
        <InputOTPSlot />
        <InputOTPSlot />
        <InputOTPSlot />
        <InputOTPSlot />
        <InputOTPSlot />
        <InputOTPSlot />
      </InputOTPGroup>
    </InputOTP>
  )
}

Right to left

Slots fill from the right and the arrow keys follow what you see. Give slots after the first a translated aria-label. Set dir="ltr" on the field to keep a code left to right in a right-to-left page.

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

export function InputOTPRtl() {
  return (
    <div
      dir="rtl"
      className="flex max-w-full min-w-0 flex-col items-start gap-2"
    >
      <Label htmlFor="input-otp-rtl">رمز التحقق</Label>
      <InputOTP id="input-otp-rtl" length={6} animated>
        <InputOTPGroup>
          <InputOTPSlot />
          <InputOTPSlot aria-label="الخانة ٢ من ٦" />
          <InputOTPSlot aria-label="الخانة ٣ من ٦" />
        </InputOTPGroup>
        <InputOTPSeparator />
        <InputOTPGroup>
          <InputOTPSlot aria-label="الخانة ٤ من ٦" />
          <InputOTPSlot aria-label="الخانة ٥ من ٦" />
          <InputOTPSlot aria-label="الخانة ٦ من ٦" />
        </InputOTPGroup>
      </InputOTP>
    </div>
  )
}
KeyAction
TabMoves focus into the field, to the first empty slot, and out again. Only one slot is in the tab order.
←→Moves to the previous or next slot, in visual order in right-to-left layouts.
Home↑Moves to the first slot.
End↓Moves to the slot after the last character.
BackspaceDeletes the character in the slot, or the one before it when the slot is empty. Later characters move back.
DeleteDeletes the character in the slot and keeps focus there.
CtrlBackspaceClears the whole code. ⌘ Backspace on macOS.
CtrlASelects the whole code (⌘ A on macOS). Backspace or Delete then clears it and returns to the first slot, typing or pasting replaces it, and Ctrl C copies all of it. Any other key or a click ends the selection.
  • Each slot is a real input. The first takes its name from your <label> or aria-label; the others are named "Character 2 of 6" and so on. Pass aria-label on a slot to translate it.
  • The first slot has autocomplete="one-time-code", so iOS and macOS offer codes from Messages and Mail, Android offers SMS codes, and password managers can fill it. A whole code that lands in one slot is spread across all of them. The animated cascade runs for every source, including codes you set from the WebOTP API.
  • Whenever the code becomes empty while a slot has focus, such as after a wrong code is cleared, focus moves back to the first slot so the next attempt starts in the right place.
  • When status is set, a hidden live region next to the field announces it. Change the words with loadingLabel, successLabel and errorLabel.
  • With animated, characters are drawn on a layer hidden from screen readers while the inputs keep the real value. Under reduced motion, characters only fade and the status wave becomes a gentle pulse.

Built on the Base UI OTP field. Every Base UI prop is passed through.

PropTypeDefault
lengthRequired. The number of slots; render the same number of InputOTPSlot parts.
number–
variant
"joined" | "separate""joined"
size
"sm" | "default" | "lg""default"
animatedAnimate characters in and out, cascade multi-character input and animate the status.
booleanfalse
statusThe result of checking the code. Loading makes the slots read-only.
"idle" | "loading" | "success" | "error"–
loadingLabel
string"Verifying code"
successLabel
string"Code verified"
errorLabel
string"Code is incorrect"
value
string–
defaultValue
string–
onValueChange
(value: string, details) => void–
onValueCompleteCalled when the last slot fills.
(value: string, details) => void–
onValueInvalidCalled when typed or pasted characters are rejected.
(value: string, details) => void–
validationType
"numeric" | "alpha" | "alphanumeric" | "none""numeric"
normalizeValueRuns after filtering. Keep it idempotent.
(value: string) => string–
inputModeDefaults from validationType.
string–
autoComplete
string"one-time-code"
autoSubmit
booleanfalse
mask
booleanfalse
aria-invalidMarks every slot invalid.
boolean–
name
string–
form
string–
idGoes on the first slot, so a label's htmlFor points at it.
string–
disabled
booleanfalse
readOnly
booleanfalse
required
booleanfalse
className
string | (state) => string–
render
ReactElement | (props, state) => ReactElement<div>
AttributeDescription
data-slot="input-otp"The root.
data-variant="joined" | "separate"The current variant.
data-sizeThe current size.
data-statusThe status, when one is set.
data-animatedPresent when animated is on.
data-shakePresent while the field shakes after the status turns to error.
data-completePresent when every slot is filled.
data-filledPresent when any slot is filled.
data-focusedPresent while a slot has focus.
data-disabledPresent when disabled.
data-readonlyPresent when read-only, including while loading.
data-requiredPresent when required.
data-invalid / data-valid / data-touched / data-dirtyField state, inside a Field.
data-slot="input-otp-status"The hidden live region, a sibling of the root.

A plain element that lays out a run of slots. In the joined variant its slots share edges.

PropTypeDefault
render
ReactElement | (props, state) => ReactElement<div>
AttributeDescription
data-slot="input-otp-group"Target the group in CSS.

A box holding one input. className goes on the box; every other prop goes on the input.

PropTypeDefault
aria-labelIgnored on the first slot, which uses the label.
string"Character N of M"
classNameThe state has the slot's index, value, filled and the field state.
string | (state) => string–
placeholder
string–
AttributeDescription
data-slot="input-otp-slot"The box.
data-filledPresent when the slot has a character.
data-statusThe root's status, when not idle.
--input-otp-indexThe slot's position, used to stagger the status motion.
data-slot="input-otp-input"The input inside, with Base UI's data-filled, data-focused, data-complete and field attributes.
data-slot="input-otp-char"The drawn character when animated.

A separator with a minus icon. Pass children to use another icon.

PropTypeDefault
orientation
"horizontal" | "vertical""horizontal"
className
string | (state) => string–
render
ReactElement | (props, state) => ReactElement<div>
AttributeDescription
data-slot="input-otp-separator"Target the separator in CSS.

The class names behind a slot and a group (inputOTPGroupVariants). Call them with { variant, size }.