Skip to the content

ComponentsForm

Input OTP

A one-time code entered one character per box: typing moves on, Backspace moves back, and a pasted code fills every box.

Import

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

Also install @base-ui/react: pnpm add @base-ui/react

Usage

InputOTP is Base UI's OTP Field with shadcn's input-otp parts and the field look. It needs the @base-ui/react peer package, which only products that import @booleanpress/ui/input-otp install. maxLength is the number of characters; render one InputOTPSlot for each, with its index from 0.

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

export function VerifyCode() {
  return (
    <>
      <Label htmlFor="code">Verification code</Label>
      <InputOTP id="code" maxLength={6}>
        <InputOTPGroup>
          {Array.from({ length: 6 }, (_, index) => (
            <InputOTPSlot key={index} index={index} />
          ))}
        </InputOTPGroup>
      </InputOTP>
    </>
  )
}

It is uncontrolled with defaultValue, or controlled with value and onValueChange; onValueComplete is called once every box is filled. By default it takes digits only, as most codes are: the boxes carry inputmode="numeric", so phones open the number pad, and the first box autocomplete="one-time-code", so they offer the code from a text message. validationType="alphanumeric" takes letters and digits on the full keyboard, "alpha" letters only and "none" anything. Unlike shadcn's, there is no pattern regular expression: use validationType, or normalizeValue to change what is typed (for example to upper case). mask hides the characters as a password field does. size is sm (28 px), default (35 px) or lg (42 px) and variant="filled" fills the boxes grey; both default to the provider's controlSize and fieldVariant. Split a long code with InputOTPSeparator between two InputOTPGroups. With name, the code is submitted with its form, and a reset of the form puts an uncontrolled field back to its defaultValue; a partly filled code fails the form's check, so the form submits once every box is filled (or none is, unless required). autoSubmit submits the form once it is complete.

Examples

Basic

Six boxes under a label; the first box takes the label's name.

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

export default function InputOTPBasic() {
  return (
    <div className="flex flex-col items-center gap-2">
      <Label htmlFor="otp-basic">Verification code</Label>
      <InputOTP id="otp-basic" maxLength={6}>
        <InputOTPGroup>
          {Array.from({ length: 6 }, (_, index) => (
            <InputOTPSlot key={index} index={index} />
          ))}
        </InputOTPGroup>
      </InputOTP>
    </div>
  )
}

Controlled

value and onValueChange hold the code in state; Reset empties it.

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

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

  return (
    <div className="flex flex-col items-center gap-4">
      <div className="flex flex-col items-center gap-2">
        <Label htmlFor="otp-controlled">Two-step code</Label>
        <InputOTP id="otp-controlled" maxLength={4} value={value} onValueChange={setValue}>
          <InputOTPGroup>
            {Array.from({ length: 4 }, (_, index) => (
              <InputOTPSlot key={index} index={index} />
            ))}
          </InputOTPGroup>
        </InputOTP>
      </div>
      <div className="flex items-center gap-3 text-sm/normal text-muted-foreground">
        <span>
          Value: <output className="font-medium text-foreground">{value || "empty"}</output>
        </span>
        <Button size="sm" variant="secondary" onClick={() => setValue("")}>
          Reset
        </Button>
      </div>
    </div>
  )
}

Mask

mask hides each character as it is typed.

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

export default function InputOTPMask() {
  return (
    <div className="flex flex-col items-center gap-2">
      <Label htmlFor="otp-mask">Account PIN</Label>
      <InputOTP id="otp-mask" maxLength={4} mask validationType="numeric">
        <InputOTPGroup>
          {Array.from({ length: 4 }, (_, index) => (
            <InputOTPSlot key={index} index={index} />
          ))}
        </InputOTPGroup>
      </InputOTP>
    </div>
  )
}

Integer only

Digits only, the default (validationType="numeric"): letters are refused and phones open the number pad.

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

export default function InputOTPIntegerOnly() {
  return (
    <div className="flex flex-col items-center gap-2">
      <Label htmlFor="otp-integer">Code from your authenticator app</Label>
      <InputOTP id="otp-integer" maxLength={6} validationType="numeric">
        <InputOTPGroup>
          {Array.from({ length: 6 }, (_, index) => (
            <InputOTPSlot key={index} index={index} />
          ))}
        </InputOTPGroup>
      </InputOTP>
    </div>
  )
}

Letters and digits

validationType="alphanumeric" takes letters too, on the full keyboard; normalizeValue writes them in capitals.

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

export default function InputOTPAlphanumeric() {
  return (
    <div className="flex flex-col items-center gap-2">
      <Label htmlFor="otp-alphanumeric">Recovery code</Label>
      <InputOTP
        id="otp-alphanumeric"
        maxLength={6}
        validationType="alphanumeric"
        normalizeValue={(value) => value.toUpperCase()}
      >
        <InputOTPGroup>
          {Array.from({ length: 6 }, (_, index) => (
            <InputOTPSlot key={index} index={index} />
          ))}
        </InputOTPGroup>
      </InputOTP>
    </div>
  )
}

With separator

Two groups of three with InputOTPSeparator between them; the code is still one value.

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

export default function InputOTPWithSeparator() {
  return (
    <div className="flex flex-col items-center gap-2">
      <Label htmlFor="otp-separator">Recovery code</Label>
      <InputOTP id="otp-separator" maxLength={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>
  )
}

Sizes

sm boxes are 28 px, default 35 px and lg 42 px.

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

const SIZES = [
  { size: "sm", label: "Small code" },
  { size: "default", label: "Default code" },
  { size: "lg", label: "Large code" },
] as const

export default function InputOTPSizes() {
  return (
    <div className="flex flex-col items-center gap-3">
      {SIZES.map(({ size, label }) => (
        <InputOTP key={size} maxLength={4} size={size} aria-label={label}>
          <InputOTPGroup>
            {Array.from({ length: 4 }, (_, index) => (
              <InputOTPSlot key={index} index={index} />
            ))}
          </InputOTPGroup>
        </InputOTP>
      ))}
    </div>
  )
}

Filled

variant="filled" fills the boxes with the grey --field-filled.

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

export default function InputOTPFilled() {
  return (
    <div className="flex flex-col items-center gap-2">
      <Label htmlFor="otp-filled">Verification code</Label>
      <InputOTP id="otp-filled" maxLength={4} variant="filled">
        <InputOTPGroup>
          {Array.from({ length: 4 }, (_, index) => (
            <InputOTPSlot key={index} index={index} />
          ))}
        </InputOTPGroup>
      </InputOTP>
    </div>
  )
}

Disabled

disabled greys every box and takes the field out of the tab order.

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

export default function InputOTPDisabled() {
  return (
    <div className="flex flex-col items-center gap-2">
      <Label htmlFor="otp-disabled">Verification code</Label>
      <InputOTP id="otp-disabled" maxLength={4} disabled>
        <InputOTPGroup>
          {Array.from({ length: 4 }, (_, index) => (
            <InputOTPSlot key={index} index={index} />
          ))}
        </InputOTPGroup>
      </InputOTP>
    </div>
  )
}

Invalid

aria-invalid draws the error edge on every box; aria-describedby reads the message with the field.

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

export default function InputOTPInvalid() {
  return (
    <div className="flex flex-col items-center gap-2">
      <Label htmlFor="otp-invalid">Verification code</Label>
      <InputOTP id="otp-invalid" maxLength={6} defaultValue="482913" aria-invalid aria-describedby="otp-invalid-error">
        <InputOTPGroup>
          {Array.from({ length: 6 }, (_, index) => (
            <InputOTPSlot key={index} index={index} />
          ))}
        </InputOTPGroup>
      </InputOTP>
      <p id="otp-invalid-error" className="text-xs/normal text-destructive-strong">
        This code has expired. Ask for a new one.
      </p>
    </div>
  )
}

Sample

A "Verify your email" card: large boxes for digits, a resend link and a button that waits for the whole code.

import { useState } from "react"
import { Button } from "@booleanpress/ui/button"
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@booleanpress/ui/input-otp"

export default function InputOTPSample() {
  const [code, setCode] = useState("")

  return (
    <div className="mx-auto flex w-full max-w-xs flex-col gap-4 rounded-xl border bg-card p-6 shadow-sm">
      <div className="flex flex-col gap-1">
        <h3 id="verify-title" className="text-lg/normal font-semibold">
          Verify your email
        </h3>
        <p className="text-sm/normal text-muted-foreground">Enter the 6-digit code we sent to admin@example.com.</p>
      </div>
      <InputOTP
        maxLength={6}
        size="lg"
        validationType="numeric"
        value={code}
        onValueChange={setCode}
        aria-labelledby="verify-title"
        className="justify-between"
      >
        <InputOTPGroup className="w-full justify-between">
          {Array.from({ length: 6 }, (_, index) => (
            <InputOTPSlot key={index} index={index} />
          ))}
        </InputOTPGroup>
      </InputOTP>
      <div className="flex items-center justify-between gap-2 text-sm/normal">
        <span className="text-muted-foreground">Didn’t receive it?</span>
        <Button variant="link" className="h-auto p-0">
          Send again
        </Button>
      </div>
      <Button disabled={code.length < 6}>Verify</Button>
    </div>
  )
}

Accessibility

Semantics
A div with role="group" holds one native input per character, with autocomplete="one-time-code" on the first, so phones offer the code from a text message. InputOTPSeparator is a role="separator". A hidden input carries the whole value for forms.
Labels
Name the field with a Label whose htmlFor is the InputOTP's id: it names the group and the first box. The other boxes are named by their position, "Character 2 of 6", from the provider's otpCharacter string. Without a visible label, give InputOTP an aria-label, or aria-labelledby pointing at a heading. Put an error message in aria-describedby.
Focus
The field is one tab stop: Tab enters at the first empty box and the next Tab leaves the field. Each box shows focus by turning its edge --ring.
Known limits
  • There is no fake caret: each box is a real input with the browser's own caret.
  • Each box is 36 × 35 px at the default size, 28 × 28 px small: both meet WCAG 2.5.8's 24 px.

Keyboard

Keyboard
KeyBehaviour
0–9Fills the focused box and moves to the next one. Letters are ignored unless validationType takes them (alphanumeric, alpha).
BackspaceEmpties the focused box, or the previous one when it is empty, and moves back.
DeleteRemoves the focused box's character; the ones after it move back.
←Moves to the previous box (the next one in right-to-left).
→Moves to the next box (the previous one in right-to-left).
HomeMoves to the first box.
EndMoves to the box after the last character.
CtrlVPastes a code across the boxes from the focused one; spaces and refused characters are dropped.
TabLeaves the field; the boxes are one tab stop.

API

InputOTP

Renders Base UI OTPField.Root and passes it every other prop.

InputOTP props
PropTypeDefaultDescription
maxLengthrequirednumberThe number of characters, one box each.
autoCompletestringone-time-codeThe input autocomplete attribute. Applied to the first slot and hidden validation input.
autoSubmitbooleanfalseWhether to submit the owning form when the OTP becomes complete.
classNamestring
defaultValuestringThe uncontrolled OTP value when the component is initially rendered.
disabledbooleanfalseWhether the component should ignore user interaction.
formstringA string specifying the form element with which the hidden input is associated. This string's value must match the id of a form element in the same document.
idstringThe id of the first input element. Subsequent inputs derive their ids from it ({id}-2, {id}-3, and so on).
inputMode"search" | "text" | "none" | "tel" | "url" | "email" | "numeric" | "decimal"The virtual keyboard hint applied to the slot inputs and hidden validation input. Built-in validation modes provide sensible defaults, but you can override them when needed.
maskbooleanfalseWhether the slot inputs should mask entered characters. Pass type directly to individual <OTPField.Input> parts to use a custom input type.
namestringIdentifies the field when a form is submitted.
normalizeValue((value: string) => string)Function that normalizes the OTP value after whitespace and validationType filtering. It runs whenever OTP Field normalizes a value, including initial/default values, controlled values, and user edits. The returned value is filtered by validationType again, then clamped to length. It should be idempotent because OTP Field may normalize the same value more than once while handling edits, storing state, and rendering controlled or uncontrolled values. Non-idempotent normalizers can compound across those normalization passes. Characters rejected while normalizing typed or pasted text are reported through onValueInvalid.
onValueChange((value: string, eventDetails: OTPFieldRootChangeEventDetails) => void)Callback fired when the OTP value changes. The eventDetails.reason indicates what triggered the change: - 'input-change' for typing or autofill - 'input-clear' when a character is removed by text input - 'input-paste' for paste interactions - 'keyboard' for keyboard interactions that change the value
onValueComplete((value: string, eventDetails: OTPFieldRootCompleteEventDetails) => void)Callback function that is fired when the OTP value becomes complete, or when a complete value is pasted while the OTP is already complete. When the value changes, it runs later than onValueChange, after the internal value update is applied. If a complete pasted value matches the current value, onValueChange does not fire. If autoSubmit is enabled, it runs immediately before the owning form is submitted.
onValueInvalid((value: string, eventDetails: OTPFieldRootInvalidEventDetails) => void)Callback fired when entered text contains characters that are rejected by validation or normalization before the OTP value updates. The value argument is the attempted user-entered string before normalization.
readOnlybooleanfalseWhether the user should be unable to change the field value.
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, OTPFieldRootState>Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a ReactElement or a function that returns the element to render.
requiredbooleanfalseWhether the user must enter a value before submitting a form.
size"default" | "sm" | "lg"The boxes' size: 28, 35 or 42 px tall.
styleCSSProperties | ((state: OTPFieldRootState) => CSSProperties)Style applied to the element, or a function that returns a style object based on the component's state.
validationType"none" | "numeric" | "alpha" | "alphanumeric"numericThe type of input validation to apply to the OTP value.
valuestringThe OTP value.
variant"default" | "filled"filled gives the boxes the grey field fill.

InputOTPGroup

Renders a div and passes it every other prop.

InputOTPSeparator

Renders Base UI OTPField.Separator and passes it every other prop.

InputOTPSeparator props
PropTypeDefaultDescription
classNamestring
orientation"horizontal" | "vertical"'horizontal'The orientation of the separator.
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, SeparatorState>Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a ReactElement or a function that returns the element to render.
styleCSSProperties | ((state: SeparatorState) => CSSProperties)Style applied to the element, or a function that returns a style object based on the component's state.

InputOTPSlot

Renders Base UI OTPField.Input and passes it every other prop.

InputOTPSlot props
PropTypeDefaultDescription
indexrequirednumberThe box's position, from 0.
classNamestring
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>, OTPFieldInputState>Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a ReactElement or a function that returns the element to render.
styleCSSProperties | ((state: OTPFieldInputState) => CSSProperties)Style applied to the element, or a function that returns a style object based on the component's state.

Every part takes className, merged with its defaults by cn(), and ref, which reaches the element it renders.

Data attributes: data-slot="input-otp" (InputOTP), data-slot="input-otp-group" (InputOTPGroup), data-slot="input-otp-separator" (InputOTPSeparator), data-slot="input-otp-slot" (InputOTPSlot), and data-size, data-variant.

Provider strings: otpCharacter (BooleanUIProvider's strings).

Theming

Each box is a field: --field for the fill (--field-filled with variant="filled"), --control for the edge (--control-hover under the pointer), --ring for focus, --invalid for the error edge, and --field-disabled with --field-disabled-foreground when disabled. The separator is --muted-foreground.

The tokens its classes read. Change their values in your theme, and it follows.

Theme tokens
TokenUsed for
--controlborder
--control-hoverborder
--fieldbackground
--field-disabledbackground
--field-disabled-foregroundtext
--field-filledbackground
--foregroundtext
--invalidborder
--muted-foregroundtext
--primarybackground
--primary-foregroundtext
--ringborder