Something new is coming.Join the waitlist

Input OTP

PreviousNext

An animated OTP input. Each digit fades in, the active slot gets a sliding ring indicator, and the empty active slot shows a blinking caret. Built with input-otp and motion.

Enter the 6-digit code we sent to your device.

Installation

npx shadcn@latest add https://ui.srb.codes/r/input-otp.json

Usage

"use client";
 
import * as React from "react";
 
import {
  InputOTP,
  InputOTPGroup,
  InputOTPSeparator,
  InputOTPSlot
} from "@/components/input-otp";
 
export function VerificationField() {
  const [value, setValue] = React.useState("");
 
  return (
    <InputOTP maxLength={6} value={value} onChange={setValue}>
      <InputOTPGroup>
        <InputOTPSlot index={0} />
        <InputOTPSlot index={1} />
        <InputOTPSlot index={2} />
      </InputOTPGroup>
      <InputOTPSeparator />
      <InputOTPGroup>
        <InputOTPSlot index={3} />
        <InputOTPSlot index={4} />
        <InputOTPSlot index={5} />
      </InputOTPGroup>
    </InputOTP>
  );
}

The component is a thin wrapper around input-otp, so anything that library accepts on OTPInputpattern, inputMode, autoFocus, onComplete, controlled and uncontrolled modes — works the same way here. The slot count is driven by maxLength and how many <InputOTPSlot index={…} /> elements you render.

Patterns

Four-digit code, no separator

Drop the separator and lower the maxLength for shorter PINs. The active-ring indicator still slides between adjacent slots because the layoutId is shared inside a group.

<InputOTP maxLength={4} value={value} onChange={setValue}>
  <InputOTPGroup>
    <InputOTPSlot index={0} />
    <InputOTPSlot index={1} />
    <InputOTPSlot index={2} />
    <InputOTPSlot index={3} />
  </InputOTPGroup>
</InputOTP>

Auto-submit on completion

input-otp calls onComplete once the value reaches maxLength. Pair it with useState and a submit effect to fire your verification call without a separate button.

<InputOTP
  maxLength={6}
  value={value}
  onChange={setValue}
  onComplete={(code) => verifyOtp(code)}
>
  <InputOTPGroup>
    {Array.from({ length: 6 }).map((_, i) => (
      <InputOTPSlot key={i} index={i} />
    ))}
  </InputOTPGroup>
</InputOTP>

Validation feedback

Set aria-invalid on the root once verification fails. The slots pick up a destructive border, and the active slot's ring switches to the destructive color so the user can see exactly where the cursor is sitting when they go to retry.

<InputOTP
  maxLength={6}
  value={value}
  onChange={setValue}
  aria-invalid={status === "invalid"}
>
  {/* …slots… */}
</InputOTP>

Numeric-only on mobile

Pass inputMode="numeric" and pattern={REGEXP_ONLY_DIGITS} from input-otp to keep mobile keyboards on the digit pane and reject pasted letters.

import { REGEXP_ONLY_DIGITS } from "input-otp";
 
<InputOTP
  maxLength={6}
  inputMode="numeric"
  pattern={REGEXP_ONLY_DIGITS}
  value={value}
  onChange={setValue}
>
  {/* …slots… */}
</InputOTP>;

Anatomy

<InputOTP>
  <InputOTPGroup>
    <InputOTPSlot />
    <InputOTPSlot />
    <InputOTPSlot />
  </InputOTPGroup>
  <InputOTPSeparator />
  <InputOTPGroup>
    <InputOTPSlot />
    <InputOTPSlot />
    <InputOTPSlot />
  </InputOTPGroup>
</InputOTP>
PartRole
InputOTPRoot. Wraps input-otp's <OTPInput> and forwards every prop. Owns the hidden text input that captures keypresses and clipboard pastes.
InputOTPGroupVisual cluster of slots. Slots inside the same group share an active-ring layoutId, so the ring slides between adjacent slots.
InputOTPSlotOne digit slot. Renders the animated character, the blinking caret on the empty active slot, and the active-ring indicator.
InputOTPSeparatorDecorative dash between groups. Pure presentation — aria-hidden.

Props

InputOTP

Forwards every prop of OTPInput from input-otp. The most useful ones:

PropTypeDefaultDescription
maxLengthnumberTotal number of digits. Must match the count of <InputOTPSlot> you render.
valuestringControlled value. Omit to run uncontrolled (use defaultValue).
onChange(value: string) => voidFires on every keystroke or paste.
onComplete(value: string) => voidFires once value.length === maxLength. Useful for auto-submit.
patternstringRegex string applied to each character. Pair with REGEXP_ONLY_DIGITS for numeric-only.
inputMode"numeric" | "text" | …Hints the on-screen keyboard layout on mobile.
disabledbooleanfalseGreys the slots out (opacity-50) and blocks input.
containerClassNamestringForwarded to the slot container — the flex wrapper that holds the groups and separators.
classNamestringForwarded to the hidden text input.
aria-invalidbooleanWhen true, slots show the destructive border and the active ring switches to the destructive color.

InputOTPSlot

PropTypeDefaultDescription
indexnumberRequired. Position of this slot inside the OTP, zero-indexed. Slot 0 is the leftmost digit.
classNamestringForwarded to the slot's outer <motion.div>. Useful for sizing — the default is h-10 w-9.

All other props of motion.div (initial/animate/transition overrides, refs, native attributes) are forwarded.

InputOTPGroup, InputOTPSeparator

Both forward every native <div> prop. InputOTPSeparator is aria-hidden by default.

How the animations work

Three things animate, all driven by motion:

  1. The digit itself — when a character lands in a slot, <InputOTPAnimatedNumber> mounts a <motion.span> keyed on the new value. AnimatePresence runs the previous span's exit (opacity 1 → 0) and the new span's enter (opacity 0.2 → 1, y: 20 → 0) over 90 ms, so the digit feels like it falls into place.
  2. The active-ring indicator — a <motion.div> with layoutId="indicator" mounts inside whichever slot is active. As focus moves between slots in the same group, motion's shared-layout magic interpolates the ring's position over 120 ms, so it slides instead of teleporting. When multiple slots are active simultaneously (a paste selection range), the layoutId switches to a per-index value so each ring stays put rather than fighting for the same layoutId.
  3. The caret — when the active slot is empty, <FakeCaret> renders a 1-px-wide bar that blinks via the animate-caret-blink keyframes from tw-animate-css. It's wrapped in motion-safe: so users with prefers-reduced-motion don't see it pulse.

The whole component sits inside a <MotionConfig reducedMotion="user">, so the digit fade and ring slide also respect the user's motion preference. With reduced motion on, transitions still happen — they just snap rather than ease.

Theming

The component uses the registry's standard tokens, so it picks up your existing theme without extra setup:

TokenWhere it appears
--backgroundinherited via the slot's neutral surface (parent context)
--mutedslot fill
--muted-foregroundthe blinking caret
--foregroundthe digit color
--borderthe separator dash
--inputthe slot border in its resting state
--ringthe active-slot ring
--destructivethe slot border + active ring when aria-invalid="true"

To change the slot dimensions or radius, override className on <InputOTPSlot> — defaults are h-10 w-9 rounded-[10px]. The active-ring indicator uses rounded-[inherit], so it follows whatever radius you set.

Accessibility

  • The root <OTPInput> from input-otp is a real <input> under the hood, so it works with screen readers, password managers, and SMS autofill (autoComplete="one-time-code").
  • The blinking caret only appears in the empty active slot, and is aria-hidden because the underlying input already announces position changes.
  • MotionConfig reducedMotion="user" ensures the digit fade, ring slide, and caret blink all respect prefers-reduced-motion.
  • Set aria-invalid on the root to surface validation errors visually and to assistive tech in one shot.