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.
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
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, ornullif emptyisActive— whether this slot currently has focushasFakeCaret— 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:
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.