Input OTP

Accepts a one-time password one character per slot, with paste and keyboard support.

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

export default function InputOTPDemo() {
  return (
    <InputOTP length={6} aria-label="Verification code">
      <InputOTPGroup>
        <InputOTPSlot />
        <InputOTPSlot />
        <InputOTPSlot />
      </InputOTPGroup>
      <InputOTPSeparator />
      <InputOTPGroup>
        <InputOTPSlot />
        <InputOTPSlot />
        <InputOTPSlot />
      </InputOTPGroup>
    </InputOTP>
  )
}

Installation

Install the following dependencies:

pnpm add @base-ui/react @stylexjs/stylex clsx lucide-react

Set up the token layer and the StyleX compiler, if you have not already. See Installation, and paste states.ts from the same page. A slot's field surface, its focus ring and its invalid ring come from it, and so does the dim a disabled field takes. The field also needs interop.css, which sizes the separator's glyph.

Paste field-state.ts and state-styles.ts from the same installation page.

Copy and paste the following code into your project.

components/ui/input-otp.tsx
"use client"

import type { CSSProperties } from "react"
import { stateStyles } from "@/lib/state-styles"
import { useRender } from "@base-ui/react/use-render"
import { createContext, use } from "react"
import { OTPField } from "@base-ui/react/otp-field"
import * as stylex from "@stylexjs/stylex"
import type { StyleXStyles } from "@stylexjs/stylex"
import { clsx } from "clsx"
import { Minus } from "lucide-react"

import { FieldStateContext } from "@/lib/field-state"
import { states } from "@/theme/states"
import {
  color,
  duration,
  ease,
  layer,
  leading,
  radius,
  shadow,
  space,
  text,
} from "@/theme/tokens.stylex"

const InputOTPContext = createContext<{ invalid: boolean } | null>(null)

const InputOTPGroupContext = createContext<boolean>(false)

const styles = stylex.create({
  root: {
    display: "flex",
    alignItems: "center",
    gap: space.s2,
  },
  group: {
    display: "flex",
    alignItems: "center",
    borderRadius: radius.lg,
    boxShadow: shadow.field,
  },
  slot: {
    width: space.s8,
    height: space.s8,
    minWidth: 0,
    textAlign: "center",
    color: color.foreground,
    fontSize: text.sm,
    lineHeight: leading.sm,
    borderStyle: "solid",
    borderWidth: 1,
    borderRadius: radius.lg,
    position: "relative",
    zIndex: { default: null, ":focus-visible": layer.raised },
    transitionProperty: "color, background-color, border-color, box-shadow",
    transitionDuration: duration.color,
    transitionTimingFunction: ease.standard,
  },
  slotJoined: {
    borderInlineStartWidth: { default: 0, ":first-child": 1 },
    borderStartStartRadius: { default: 0, ":first-child": radius.lg },
    borderEndStartRadius: { default: 0, ":first-child": radius.lg },
    borderStartEndRadius: { default: 0, ":last-child": radius.lg },
    borderEndEndRadius: { default: 0, ":last-child": radius.lg },
    boxShadow: { default: "none", ":focus-visible": shadow.fieldFocus },
  },
  separator: {
    display: "flex",
    alignItems: "center",
    alignSelf: "center",
    color: color.mutedForeground,
    "--carbon-icon-size": space.s4,
  },
})

const inputOTPStyles = {
  base: styles.root,
  disabled: states.disabled,
  group: styles.group,
  slot: [styles.slot, states.focusRing, states.fieldSurface],
  slotJoined: styles.slotJoined,
  slotInvalid: states.invalidRing,
  separator: styles.separator,
}

type Overrides = { xstyle?: StyleXStyles; className?: string }

type InputOTPProps = Omit<OTPField.Root.Props, "className" | "style"> &
  Overrides & {
    style?: CSSProperties
    invalid?: boolean
  }

function InputOTP({
  invalid = false,
  disabled,
  xstyle,
  className,
  style,
  ...props
}: InputOTPProps) {
  return (
    <InputOTPContext value={{ invalid }}>
      <OTPField.Root
        data-slot="input-otp"
        disabled={disabled}
        {...props}
        {...stateStyles((state) => [styles.root, state.disabled && states.disabled, xstyle], {
          className,
          style,
        })}
      />
    </InputOTPContext>
  )
}

function InputOTPGroup({
  render,
  ref,
  style,
  xstyle,
  className,
  ...props
}: useRender.ComponentProps<"div"> & Overrides) {
  const sx = stylex.props(styles.group, xstyle)

  return (
    <InputOTPGroupContext value>
      {useRender({
        defaultTagName: "div",
        render,
        ref,
        props: {
          "data-slot": "input-otp-group",
          ...sx,
          ...props,
          className: clsx(sx.className, className),
          style: { ...sx.style, ...style },
        },
      })}
    </InputOTPGroupContext>
  )
}

type InputOTPSlotProps = Omit<OTPField.Input.Props, "className" | "style"> &
  Overrides & { style?: CSSProperties }

function InputOTPSlot({
  "aria-invalid": ariaInvalid,
  style,
  xstyle,
  className,
  ...props
}: InputOTPSlotProps) {
  const fieldState = use(FieldStateContext)
  const joined = use(InputOTPGroupContext)
  const invalid =
    (use(InputOTPContext)?.invalid ?? false) || (ariaInvalid != null && ariaInvalid !== false)

  return (
    <OTPField.Input
      data-slot="input-otp-slot"
      {...props}
      aria-invalid={invalid || undefined}
      {...stateStyles(
        (state) => [
          styles.slot,
          states.focusRing,
          states.fieldSurface,
          joined && styles.slotJoined,
          (state.valid === false || fieldState?.valid === false || invalid) && states.invalidRing,
          xstyle,
        ],
        { className, style },
      )}
    />
  )
}

type InputOTPSeparatorProps = Omit<OTPField.Separator.Props, "className"> & Overrides

function InputOTPSeparator({ xstyle, className, children, ...props }: InputOTPSeparatorProps) {
  const sx = stylex.props(styles.separator, xstyle)

  return (
    <OTPField.Separator
      data-slot="input-otp-separator"
      {...sx}
      {...props}
      className={clsx("carbon-icons", sx.className, className)}
    >
      {children ?? <Minus />}
    </OTPField.Separator>
  )
}

export { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot, inputOTPStyles }

Update the import paths to match your project setup.

Usage

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

Composition

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

A slot takes no index. It reads its position from where it renders, so the slots take their order from the markup, and length on the root is the only count.

Basic

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

export default function InputOTPBasic() {
  return (
    <InputOTP length={4} aria-label="PIN">
      <InputOTPGroup>
        <InputOTPSlot />
        <InputOTPSlot />
        <InputOTPSlot />
        <InputOTPSlot />
      </InputOTPGroup>
    </InputOTP>
  )
}

Separator

Put InputOTPSeparator between two groups. It renders a dash; pass a child to use a different glyph.

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

export default function InputOTPSeparatorDemo() {
  return (
    <InputOTP length={6} aria-label="Verification code">
      <InputOTPGroup>
        <InputOTPSlot />
        <InputOTPSlot />
      </InputOTPGroup>
      <InputOTPSeparator>
        <Dot />
      </InputOTPSeparator>
      <InputOTPGroup>
        <InputOTPSlot />
        <InputOTPSlot />
      </InputOTPGroup>
      <InputOTPSeparator>
        <Dot />
      </InputOTPSeparator>
      <InputOTPGroup>
        <InputOTPSlot />
        <InputOTPSlot />
      </InputOTPGroup>
    </InputOTP>
  )
}

Validation type

Use validationType to choose what the field accepts. It also picks the virtual keyboard, which inputMode overrides.

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

const types = ["numeric", "alpha", "alphanumeric"] as const

export default function InputOTPValidation() {
  return (
    <VStack gap={4}>
      {types.map((validationType) => (
        <VStack key={validationType} gap={2}>
          <Label>{validationType}</Label>
          <InputOTP length={4} validationType={validationType} aria-label={validationType}>
            <InputOTPGroup>
              <InputOTPSlot />
              <InputOTPSlot />
              <InputOTPSlot />
              <InputOTPSlot />
            </InputOTPGroup>
          </InputOTP>
        </VStack>
      ))}
    </VStack>
  )
}

Mask

Use the mask prop to obscure the code on a shared screen.

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

export default function InputOTPMask() {
  return (
    <InputOTP length={6} mask defaultValue="428" aria-label="Verification code">
      <InputOTPGroup>
        <InputOTPSlot />
        <InputOTPSlot />
        <InputOTPSlot />
        <InputOTPSlot />
        <InputOTPSlot />
        <InputOTPSlot />
      </InputOTPGroup>
    </InputOTP>
  )
}

Disabled

Set disabled on Field to disable its control and dim its label, or on a bare control directly.

import { VStack } from "@/components/ui/stack"
import { Field, FieldLabel } from "@/components/ui/field"
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp"

function BareDisabled() {
  return (
    <InputOTP length={4} disabled defaultValue="8140" aria-label="Verification code">
      <InputOTPGroup>
        <InputOTPSlot />
        <InputOTPSlot />
        <InputOTPSlot />
        <InputOTPSlot />
      </InputOTPGroup>
    </InputOTP>
  )
}

export default function DisabledExample() {
  return (
    <VStack gap={5}>
      <BareDisabled />
      <Field disabled>
        <FieldLabel>Verification code</FieldLabel>
        <InputOTP length={4} defaultValue="8140">
          <InputOTPGroup>
            {[0, 1, 2, 3].map((i) => (
              <InputOTPSlot key={i} />
            ))}
          </InputOTPGroup>
        </InputOTP>
      </Field>
    </VStack>
  )
}

Controlled

Use value and onValueChange to control the field. The second argument carries a reason: input-change, input-clear, input-paste or keyboard.

empty
"use client"

import { useState } from "react"
import * as stylex from "@stylexjs/stylex"
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp"
import { VStack } from "@/components/ui/stack"
import { color, font, leading, text } from "@/theme/tokens.stylex"

const styles = stylex.create({
  readout: {
    color: color.mutedForeground,
    fontFamily: font.mono,
    fontSize: text.sm,
    lineHeight: leading.sm,
  },
})

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

  return (
    <VStack gap={3} align="start">
      <InputOTP length={4} value={value} onValueChange={setValue} aria-label="Verification code">
        <InputOTPGroup>
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
        </InputOTPGroup>
      </InputOTP>
      <span {...stylex.props(styles.readout)}>{value === "" ? "empty" : value}</span>
    </VStack>
  )
}

Complete

onValueComplete fires once every slot is filled, after the value has been applied. Set autoSubmit to submit the owning form at the same moment.

Waiting for 4 digits
"use client"

import { useState } from "react"
import * as stylex from "@stylexjs/stylex"
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp"
import { VStack } from "@/components/ui/stack"
import { color, leading, text } from "@/theme/tokens.stylex"

const styles = stylex.create({
  status: { color: color.mutedForeground, fontSize: text.sm, lineHeight: leading.sm },
})

export default function InputOTPComplete() {
  const [status, setStatus] = useState("Waiting for 4 digits")

  return (
    <VStack gap={3} align="start">
      <InputOTP
        length={4}
        onValueComplete={(value) => setStatus(`Verifying ${value}`)}
        aria-label="Verification code"
      >
        <InputOTPGroup>
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
          <InputOTPSlot />
        </InputOTPGroup>
      </InputOTP>
      <span aria-live="polite" {...stylex.props(styles.status)}>
        {status}
      </span>
    </VStack>
  )
}

Invalid

Set invalid on Field to style its control, or aria-invalid on a bare control. The examples show both.

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

export default function InputOTPInvalid() {
  return (
    <VStack gap={5}>
      <VStack gap={2}>
        <InputOTP
          length={4}
          defaultValue="1234"
          aria-label="Verification code"
          aria-describedby="otp-bare-error"
        >
          <InputOTPGroup>
            {[0, 1, 2, 3].map((i) => (
              <InputOTPSlot key={i} aria-invalid />
            ))}
          </InputOTPGroup>
        </InputOTP>
        <FieldError id="otp-bare-error">That code has expired. Request a new one.</FieldError>
      </VStack>
      <Field invalid>
        <FieldLabel>Verification code</FieldLabel>
        <InputOTP length={4} defaultValue="1234">
          <InputOTPGroup>
            {[0, 1, 2, 3].map((i) => (
              <InputOTPSlot key={i} />
            ))}
          </InputOTPGroup>
        </InputOTP>
        <FieldError>That code has expired. Request a new one.</FieldError>
      </Field>
    </VStack>
  )
}

This is Base UI's OTPField, not shadcn's input-otp wrapper. Every slot is a real <input>, so the browser handles the caret, the text selection and the mobile keyboard. There is no fake blinking caret to render and no hidden input sitting over the slots.

API reference

Pass render to replace a part's element or compose it with another component.

Every part wraps the Base UI OTPField part of the same name and accepts its props. See the Base UI reference. Each part also takes xstyle and className.

InputOTP

PropTypeDefault
lengthnumberrequired
valuestring—
defaultValuestring—
onValueChange(value: string, details) => void—
onValueComplete(value: string, details) => void—
onValueInvalid(value: string, details) => void—
validationType"numeric" | "alpha" | "alphanumeric" | "none""numeric"
maskbooleanfalse
autoSubmitbooleanfalse
invalidbooleanfalse
disabledbooleanfalse
readOnlybooleanfalse
requiredbooleanfalse
namestring—
autoCompletestring"one-time-code"
normalizeValue(value: string) => string—

length is required. The root clamps the value, detects completion and renders the hidden validation input from it, all before the slots hydrate.

Set invalid on a bare OTP. Inside Field, slots inherit validity without that prop.

InputOTPGroup

A row of slots. The group squares its slots' inner corners and drops their shared borders, so a slot rendered outside a group stays a box of its own.

InputOTPSlot

One character. Takes every <input> attribute and Base UI's render. Its aria-invalid also accepts an explicit invalid state on a bare slot.

InputOTPSeparator

Base UI's Separator, carrying role="separator". The default child is a lucide Minus.

inputOTPStyles

The style maps behind the component. Spread them onto any element.

KeyWhat it is
baseThe root row and its gap
disabledThe dimmed, non-interactive state
groupA run of slots, carrying the drop once
slotOne slot: box, type, field surface, stacking order
slotJoinedA member's squared corners, shared border, no drop
slotInvalidThe destructive border and ring
separatorThe glyph between two groups

slot is three styles, in merge order: the slot's own, the shared states.focusRing and states.fieldSurface. slotInvalid is invalidRing and disabled is disabled, both from states in theme/states.ts. Set a slot border of your own through --carbon-border-color rather than borderColor. A flat borderColor replaces the property with all of its conditions, so the ring loses its focus colour.

Data attributes

AttributeOnEffect
data-slot="input-otp"the rootTargets the field from a parent
data-slot="input-otp-group"a groupTargets a group from a parent
data-slot="input-otp-slot"a slotTargets a slot from a parent
data-slot="input-otp-separator"a separatorTargets a separator from a parent
data-completethe rootPresent once every slot is filled
data-filledroot, slotPresent when the value, or that slot, has a character
data-disabled, data-readonly, data-requiredroot, slotBase UI writes them