Something new is coming.Join the waitlist
All posts
tent ui team

Building an Animated OTP Input in React: Sliding Ring, Blinking Caret, and Auto-Submit

A step-by-step tutorial for building a polished OTP input in React with per-digit animations, a sliding focus ring, and automatic form submission on fill — using input-otp and Framer Motion.

reactotpframer motionanimationtutorialshadcn ui

OTP inputs are everywhere in modern authentication — email magic links, two-factor auth, SMS codes. The baseline HTML approach (six separate <input> fields wired together with onKeyDown handlers) works, but it is brittle and produces none of the micro-interactions users expect after years of polished mobile apps. The caret should blink. The active slot should have a visible ring. Digits should fade in when typed.

This tutorial shows how to build a fully-animated OTP input in React, using the input-otp library for the logic and Framer Motion for the animations — then shows you how to skip the implementation entirely and install the pre-built version from tent ui.


Dependencies

npm install input-otp motion

input-otp handles the hard parts: single-slot focus management, mobile keyboard compatibility, paste behavior, and the ARIA attributes that make screen readers work correctly. We add Framer Motion for the visual polish.


The structural foundation

input-otp renders a single hidden <input> and exposes a render prop pattern for the visible slots. Each slot knows whether it is active, whether it has a character, and whether it is the fake caret position.

"use client";
 
import { OTPInput, SlotProps } from "input-otp";
 
export function OtpInputBasic() {
  return (
    <OTPInput
      maxLength={6}
      render={({ slots }) => (
        <div className="flex gap-2">
          {slots.map((slot, i) => (
            <Slot key={i} {...slot} />
          ))}
        </div>
      )}
    />
  );
}

The Slot component is where all the animation lives. Each slot receives:

  • char — the typed character, or null if empty
  • isActive — whether this slot currently has focus
  • hasFakeCaret — whether the blinking caret should appear here

Animating the digit

When a character enters a slot, it should appear with a quick fade and slight scale-up to confirm the input was registered. Without animation, digit-by-digit entry feels mechanical — there is no feedback that anything happened.

import { motion, AnimatePresence } from "motion/react";
 
function Slot({ char, isActive, hasFakeCaret }: SlotProps) {
  return (
    <div
      className={[
        "relative flex h-12 w-10 items-center justify-center rounded-md border text-base font-medium",
        "transition-colors duration-150",
        isActive
          ? "border-foreground ring-foreground ring-2 ring-offset-2"
          : "border-input bg-background"
      ].join(" ")}
    >
      <AnimatePresence mode="wait">
        {char ? (
          <motion.span
            key={char}
            initial={{ opacity: 0, scale: 0.85 }}
            animate={{ opacity: 1, scale: 1 }}
            exit={{ opacity: 0, scale: 0.85 }}
            transition={{ type: "spring", stiffness: 500, damping: 30 }}
            className="select-none"
          >
            {char}
          </motion.span>
        ) : null}
      </AnimatePresence>
 
      {hasFakeCaret ? <FakeCaret /> : null}
    </div>
  );
}

The AnimatePresence mode="wait" ensures that when a digit is deleted and a new one typed quickly, the exit animation completes before the new digit animates in. This prevents the two digits from overlapping.


The blinking caret

input-otp tells you which slot should show the fake caret (hasFakeCaret). Implement it as a small vertical bar with a CSS opacity animation — it needs to be a true CSS animation, not a Framer Motion variant, so it can loop indefinitely without JavaScript timers:

function FakeCaret() {
  return (
    <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
      <motion.div
        animate={{ opacity: [1, 0] }}
        transition={{
          duration: 0.8,
          repeat: Infinity,
          repeatType: "reverse",
          ease: "easeInOut"
        }}
        className="bg-foreground h-5 w-px"
      />
    </div>
  );
}

The opacity: [1, 0] keyframe array with repeatType: "reverse" creates the blinking loop without any setInterval — Framer Motion handles the RAF loop.


The sliding active ring

The default approach applies a ring class to the active slot. This works, but the ring jumps discretely between slots. A more polished approach uses a shared layout animation — a single ring element that slides between slots rather than appearing and disappearing:

"use client";
 
import { motion } from "motion/react";
import { OTPInput, SlotProps } from "input-otp";
 
export function AnimatedOtpInput() {
  return (
    <OTPInput
      maxLength={6}
      render={({ slots }) => (
        <div className="flex gap-2">
          {slots.map((slot, i) => (
            <SlotWithRing key={i} index={i} {...slot} />
          ))}
        </div>
      )}
    />
  );
}
 
function SlotWithRing({ char, isActive, hasFakeCaret }: SlotProps) {
  return (
    <div className="relative flex h-12 w-10 items-center justify-center rounded-md border border-input bg-background text-base font-medium">
      {isActive && (
        <motion.div
          layoutId="otp-ring"
          className="absolute inset-0 rounded-md ring-2 ring-foreground ring-offset-2 ring-offset-background"
          transition={{ type: "spring", stiffness: 500, damping: 32 }}
        />
      )}
      <AnimatePresence mode="wait">
        {char ? (
          <motion.span
            key={char}
            initial={{ opacity: 0, scale: 0.85 }}
            animate={{ opacity: 1, scale: 1 }}
            exit={{ opacity: 0, scale: 0.85 }}
            transition={{ type: "spring", stiffness: 500, damping: 30 }}
            className="relative z-10 select-none"
          >
            {char}
          </motion.span>
        ) : null}
      </AnimatePresence>
      {hasFakeCaret ? <FakeCaret /> : null}
    </div>
  );
}

The layoutId="otp-ring" is the key. Framer Motion tracks all elements with the same layoutId and animates between their positions automatically. The ring springs from slot to slot as focus moves — the user sees the focus travel rather than jump, which reads as a more premium interaction.


Auto-submit on fill

OTPInput exposes an onComplete callback that fires when all slots are filled. Wire it to your form submission:

export function OtpForm() {
  const [loading, setLoading] = React.useState(false);
 
  async function handleComplete(value: string) {
    setLoading(true);
    try {
      await verifyOtp(value);
    } finally {
      setLoading(false);
    }
  }
 
  return (
    <OTPInput
      maxLength={6}
      onComplete={handleComplete}
      disabled={loading}
      render={({ slots }) => (
        <div className="flex gap-2">
          {slots.map((slot, i) => (
            <SlotWithRing key={i} {...slot} />
          ))}
        </div>
      )}
    />
  );
}

Auto-submit removes an extra button click from the auth flow. Users who have pasted a code or typed it quickly do not need to hunt for a "Verify" button — the form just proceeds. Add a loading state to prevent double-submission while the network request is in flight.


Grouping digits visually

Six-digit codes are typically formatted as two groups of three (123 456) or one block of six. input-otp supports this with the Separator pattern:

import { OTPInput, OTPInputContext } from "input-otp";
import { REGEXP_ONLY_DIGITS } from "input-otp";
 
<OTPInput
  maxLength={6}
  pattern={REGEXP_ONLY_DIGITS}
  render={({ slots }) => (
    <div className="flex items-center gap-2">
      <div className="flex gap-2">
        {slots.slice(0, 3).map((slot, i) => (
          <SlotWithRing key={i} {...slot} />
        ))}
      </div>
      <span className="text-muted-foreground text-xl">–</span>
      <div className="flex gap-2">
        {slots.slice(3).map((slot, i) => (
          <SlotWithRing key={i + 3} {...slot} />
        ))}
      </div>
    </div>
  )}
/>

The pattern={REGEXP_ONLY_DIGITS} restricts the hidden input to numeric characters only, rejecting letters immediately on mobile keyboards.


Skip the implementation

If you want the production-ready version with the sliding ring, digit animations, fake caret, auto-submit, and digit grouping already wired up — install it directly from tent ui:

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

The component lands in your project as editable source, including the input-otp dependency. See the full documentation and interactive demo at /docs/components/input-otp.

For related components that round out authentication flows, see Password Input (with zxcvbn strength meter and Caps Lock detection) and Animated Save Button for form submission feedback.