OTP

A one-time-password / PIN input from @clerk/headless. It is a headless primitive: it owns the value (a single string), splits it across per-character Input slots, and handles focus movement, keyboard editing, and paste distribution, but ships no styles — you bring your own CSS by targeting the data-* attributes each part emits.

Example

The demo below is intentionally unstyled — it renders the raw primitive so you can see its behavior. Type digits and focus advances slot to slot; Backspace walks back; arrow keys (plus Home/End and Ctrl/Cmd variants) move between slots; and pasting a full code distributes it across the slots. Focus can't skip past the first empty slot, so an empty field always focuses the first slot.

Usage

The value lives in Otp.Root. Read the slots with the useOtp hook and render one Otp.Input per slot, passing each its index:

import { Otp } from '@clerk/headless/otp';

function VerifyCode() {
  return (
    <Otp.Root
      length={6}
      aria-label='Verification code'
      onComplete={code => submit(code)}
    >
      <Slots />
    </Otp.Root>
  );
}

function Slots() {
  const { slots } = Otp.useOtp();
  return slots.map(slot => (
    <Otp.Input
      key={slot.index}
      index={slot.index}
    />
  ));
}

Controlled

const [code, setCode] = useState('');

<Otp.Root
  length={6}
  value={code}
  onValueChange={setCode}
>
  <Slots />
</Otp.Root>;

Inside a form

Pass name to submit the combined value through a hidden input:

<form action={verify}>
  <Otp.Root
    length={6}
    name='code'
  >
    <Slots />
  </Otp.Root>
  <button type='submit'>Verify</button>
</form>

Masked / alphanumeric

<Otp.Root
  length={4}
  mask
  pattern='alphanumeric'
>
  <Slots />
</Otp.Root>

Parts

PartDefault ElementDescription
Otp.Root<div>Owns the value + focus, provides context, submits the value
Otp.Input<input>A single character slot; render one per useOtp().slots entry

Both parts accept a render prop for polymorphic rendering and standard HTML attributes for their default element. Otp.Input (and the useOtp hook) throw if used outside Otp.Root.

Otp.useOtp() is a hook (not a component) that returns { value, length, disabled, complete, slots, activeIndex, clear, focus } for reading the value and driving custom UI. Each slots entry is { index, char, isActive, isFilled }. It must be called inside Otp.Root.

Props

Otp.Root

PropTypeDefaultDescription
lengthnumber— (required)Number of slots (characters) in the code
valuestringControlled value
defaultValuestring''Initial value (uncontrolled)
onValueChange(value: string) => voidCalled with the full value on every change
onComplete(value: string) => voidCalled once every slot is filled
pattern'numeric' | 'alpha' | 'alphanumeric''numeric'Allowed characters; others are stripped
maskbooleanfalseRender slots as password inputs
namestringSubmit the value via a hidden input under this name
disabledbooleanfalseDisable every slot

Otp.Input

PropTypeDefaultDescription
indexnumber— (required)The slot's 0-based position (from useOtp().slots)

Styling

Each part emits data-* attributes you can target with any CSS solution:

AttributeApplies ToDescription
data-emptyRootPresent when no character has been entered
data-completeRootPresent when every slot is filled
data-disabledRoot, InputPresent when disabled
data-activeInputPresent when the slot holds focus
data-filledInputPresent when the slot holds a character
.cl-otp-root {
  display: flex;
  gap: 8px;
}
.cl-otp-input {
  width: 40px;
  height: 48px;
  text-align: center;
  border: 1px solid var(--color-border);
  border-radius: 8px;
}
.cl-otp-input[data-active] {
  border-color: var(--color-accent);
}

Otp.Root is a role="group"; give it an aria-label (or aria-labelledby) describing the code. Each Otp.Input gets a default aria-label of "Character N of M" (overridable), and the slots use a roving tab index so Tab enters the group once and leaves in one step. When name is set, the hidden form input is aria-hidden and out of the tab order. Arrow keys follow reading order — under dir="rtl", ArrowLeft/ArrowRight are swapped.