# Input OTP

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

```tsx
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

1. Install the following dependencies:

   ```bash
   npm install @base-ui/react @stylexjs/stylex clsx lucide-react
   ```

2. Set up the token layer and the StyleX compiler, if you have not already. See
   [Installation](https://carbon.style/docs/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.

3. Copy and paste the following code into your project.

   ```tsx title="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 }
   ```

4. Update the import paths to match your project setup.

## Usage

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

```tsx
<InputOTP length={6} aria-label="Verification code">
  <InputOTPGroup>
    <InputOTPSlot />
    <InputOTPSlot />
    <InputOTPSlot />
  </InputOTPGroup>
  <InputOTPSeparator />
  <InputOTPGroup>
    <InputOTPSlot />
    <InputOTPSlot />
    <InputOTPSlot />
  </InputOTPGroup>
</InputOTP>
```

## Composition

```text
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

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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`.

```tsx
"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.

```tsx
"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.

```tsx
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>
  )
}
```

> **Note**
>
> 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](https://base-ui.com/react/components/otp-field).
Each part also takes `xstyle` and `className`.

### InputOTP

| Prop              | Type                                               | Default           |
| ----------------- | -------------------------------------------------- | ----------------- |
| `length`          | `number`                                           | required          |
| `value`           | `string`                                           | —                 |
| `defaultValue`    | `string`                                           | —                 |
| `onValueChange`   | `(value: string, details) => void`                 | —                 |
| `onValueComplete` | `(value: string, details) => void`                 | —                 |
| `onValueInvalid`  | `(value: string, details) => void`                 | —                 |
| `validationType`  | `"numeric" \| "alpha" \| "alphanumeric" \| "none"` | `"numeric"`       |
| `mask`            | `boolean`                                          | `false`           |
| `autoSubmit`      | `boolean`                                          | `false`           |
| `invalid`         | `boolean`                                          | `false`           |
| `disabled`        | `boolean`                                          | `false`           |
| `readOnly`        | `boolean`                                          | `false`           |
| `required`        | `boolean`                                          | `false`           |
| `name`            | `string`                                           | —                 |
| `autoComplete`    | `string`                                           | `"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.

| Key           | What it is                                         |
| ------------- | -------------------------------------------------- |
| `base`        | The root row and its gap                           |
| `disabled`    | The dimmed, non-interactive state                  |
| `group`       | A run of slots, carrying the drop once             |
| `slot`        | One slot: box, type, field surface, stacking order |
| `slotJoined`  | A member's squared corners, shared border, no drop |
| `slotInvalid` | The destructive border and ring                    |
| `separator`   | The 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

| Attribute                                         | On          | Effect                                                |
| ------------------------------------------------- | ----------- | ----------------------------------------------------- |
| `data-slot="input-otp"`                           | the root    | Targets the field from a parent                       |
| `data-slot="input-otp-group"`                     | a group     | Targets a group from a parent                         |
| `data-slot="input-otp-slot"`                      | a slot      | Targets a slot from a parent                          |
| `data-slot="input-otp-separator"`                 | a separator | Targets a separator from a parent                     |
| `data-complete`                                   | the root    | Present once every slot is filled                     |
| `data-filled`                                     | root, slot  | Present when the value, or that slot, has a character |
| `data-disabled`, `data-readonly`, `data-required` | root, slot  | Base UI writes them                                   |
