Components

Input OTP

One-time code input with per-character slots. Uses the input-otp library. Slot h-9 w-9, rounded-md, focus ring 3px ring/50, aria-invalid switches to destructive border.

Updated Jul 17, 2026 by Leonardo Posada

Anatomy

  1. 1
    Container

    OTP root that manages the hidden input, focus, and paste behavior.

  2. 2
    Group

    Visual cluster of slots. Slots inside share borders (first is rounded-left, last is rounded-right).

  3. 3
    Slot

    One character cell: 36×36, border-input, text-sm font-medium. Focus adds ring ring-ring/50; aria-invalid switches to border-destructive.

  4. 4
    Separator

    Optional visual break between groups (Phosphor Minus, size-4). Purely decorative.

Variants

Four canonical looks. Simple = one group. With separator = 2+2+2 or 3+3 divided by a dash. With spacing = each slot as its own rounded box with a gap. Digits only = uses pattern={REGEXP_ONLY_DIGITS} so keyboards and paste ignore letters.

Simple
<InputOTP maxLength={6}>
  <InputOTPGroup>
    <InputOTPSlot index={0} />
    <InputOTPSlot index={1} />
    <InputOTPSlot index={2} />
    <InputOTPSlot index={3} />
    <InputOTPSlot index={4} />
    <InputOTPSlot index={5} />
  </InputOTPGroup>
</InputOTP>
With separator
<InputOTP 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>
With spacing
{/* Override each slot to be its own rounded box, add gap on the group */}
<InputOTP maxLength={6}>
  <InputOTPGroup className="gap-2">
    {[0, 1, 2, 3, 4, 5].map((i) => (
      <InputOTPSlot key={i} index={i} className="rounded-md border-l" />
    ))}
  </InputOTPGroup>
</InputOTP>
Digits only
import { REGEXP_ONLY_DIGITS } from "input-otp";

<InputOTP maxLength={6} pattern={REGEXP_ONLY_DIGITS}>
  <InputOTPGroup>
    <InputOTPSlot index={0} />
    <InputOTPSlot index={1} />
    <InputOTPSlot index={2} />
    <InputOTPSlot index={3} />
    <InputOTPSlot index={4} />
    <InputOTPSlot index={5} />
  </InputOTPGroup>
</InputOTP>

States

Default / focus (ring ring-ring/50) / filled / disabled (opacity 50 via has-[:disabled]) / error (aria-invalid on the slot switches border to destructive; focused error swaps the ring to destructive/20).

Default
<InputOTP maxLength={4}>
  <InputOTPGroup>
    <InputOTPSlot index={0} />
    <InputOTPSlot index={1} />
    <InputOTPSlot index={2} />
    <InputOTPSlot index={3} />
  </InputOTPGroup>
</InputOTP>
Filled
1
2
3
4
<InputOTP maxLength={4} defaultValue="1234">…</InputOTP>
Disabled
<InputOTP maxLength={4} disabled>…</InputOTP>
Error
1
2

Invalid code. Try again.

{/* aria-invalid on each slot switches border to destructive */}
<InputOTP maxLength={4} defaultValue="12">
  <InputOTPGroup>
    {[0, 1, 2, 3].map((i) => (
      <InputOTPSlot key={i} index={i} aria-invalid />
    ))}
  </InputOTPGroup>
</InputOTP>
<p className="mt-2 text-xs text-destructive">Invalid code. Try again.</p>
Slot shadow is intentionally off
The Figma spec includes shadow/xs on each slot, but Yuno's rule is 'no drop shadow on inputs, selects, textareas or DatePicker triggers'. The kit keeps OTP slots flat (border only) to match the rest of the form primitives.

Recipes

Ready-to-copy compositions covering the most common Yuno usages of this atom.

2FA login

6 digits split 3+3 with a dash separator. Pair with a 'Resend code' button below.

<div className="grid gap-3">
  <Label>Enter your 6-digit code</Label>
  <InputOTP maxLength={6} pattern={REGEXP_ONLY_DIGITS}>
    <InputOTPGroup>
      <InputOTPSlot index={0} />
      <InputOTPSlot index={1} />
      <InputOTPSlot index={2} />
    </InputOTPGroup>
    <InputOTPSeparator />
    <InputOTPGroup>
      <InputOTPSlot index={3} />
      <InputOTPSlot index={4} />
      <InputOTPSlot index={5} />
    </InputOTPGroup>
  </InputOTP>
  <Button variant="link" className="w-fit px-0 text-xs">
    Resend code
  </Button>
</div>
SMS phone verify

6 digits in a single group, digits-only pattern. Auto-submit on complete via onComplete.

<InputOTP
  maxLength={6}
  pattern={REGEXP_ONLY_DIGITS}
  onComplete={(value) => verifyCode(value)}
>
  <InputOTPGroup>
    <InputOTPSlot index={0} />
    <InputOTPSlot index={1} />
    <InputOTPSlot index={2} />
    <InputOTPSlot index={3} />
    <InputOTPSlot index={4} />
    <InputOTPSlot index={5} />
  </InputOTPGroup>
</InputOTP>
Email verify with spacing

6 digits shown as individual spaced boxes. Reads as 'code entry field' instead of a joined pill.

<InputOTP maxLength={6} pattern={REGEXP_ONLY_DIGITS}>
  <InputOTPGroup className="gap-2">
    {[0, 1, 2, 3, 4, 5].map((i) => (
      <InputOTPSlot key={i} index={i} className="rounded-md border-l" />
    ))}
  </InputOTPGroup>
</InputOTP>

Import

Copy this import line at the top of the file where you compose this atom.

Compound API + digits-only regex from the underlying package.
import {
  InputOTP,
  InputOTPGroup,
  InputOTPSeparator,
  InputOTPSlot,
} from "@/components/ui/input-otp";
import { REGEXP_ONLY_DIGITS } from "input-otp";

Props

Everything else from the underlying HTML or Radix primitive is forwarded via ...props.

PropTypeDefaultDescription
maxLengthnumberTotal number of slots. Should equal the sum of all slot indexes.
value / defaultValuestringControlled or uncontrolled OTP value.
onChange(value: string) => voidFires on every keystroke or paste.
onComplete(value: string) => voidFires once maxLength is reached. Use to auto-submit.
patternstring (regex)Restrict allowed characters. Use REGEXP_ONLY_DIGITS for numeric codes.
disabledbooleanfalseDims the whole group via has-[:disabled]:opacity-50.
containerClassNamestringMerged onto the OTP root container.
InputOTPSlot.indexnumberWhich position this slot binds to. Must be unique per slot.
InputOTPSlot.aria-invalidbooleanSwitches the slot border to destructive and elevates it above neighbors. Used for error state.
InputOTPSlot.classNamestringMerged onto the slot. Use 'rounded-md border-l' on each slot for the With spacing variant.

When to use

  • Auth flows: 2FA, email verify, phone verify.
  • Any short fixed-length code (usually 4-8 chars).
  • When the value should be scannable at a glance while typing.

When not to use

  • Regular text input — use Input.
  • Passwords — use Input type='password'.
  • Variable-length codes — use Input.

Usage

Do
  • Use a fixed length (usually 6 slots).
  • Provide a 'Resend code' link right below the input.
  • For numeric codes, pass pattern={REGEXP_ONLY_DIGITS} so mobile keyboards open numeric.
  • Trigger onComplete once the last slot is filled — no extra Submit needed.
Don't
  • Don't split into more than two groups — hurts scannability.
  • Don't allow paste of the wrong length silently — clear or truncate.
  • Don't add a shadow to slots — the kit keeps them flat.

Related

Cross-links to atoms and patterns you may reach for next.

  • InputFor regular typed input (not per-character).
  • FormTo wire the OTP into react-hook-form + zod for validation.
  • ButtonFor the 'Resend code' or 'Verify' CTA below the OTP.