A six digit verification code field inside a sign-in card. The boxes are drawn from one real text input, so typing, pasting, backspace and phone autofill all behave the way people expect. A ring slides from box to box as the code is entered, each digit pops into place, and when the sixth lands every box turns green with a short ripple and a confirmation line. Until someone focuses the field it types a sample code by itself so the preview shows the whole sequence. On phones the boxes share the width evenly and open the number keypad.
// Adapted from Eldora UI (https://github.com/karthikmudunuri/eldoraui/blob/main/apps/www/registry/eldoraui/clerk-otp.tsx)
// Copyright (c) Mudunuri Bhaskara Karthikeya Varma. MIT License: https://we.inc/components/licenses
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { Check, ShieldCheck } from "lucide-react";
const LENGTH = 6;
const DEMO_CODE = "480197";
const phoneEnding = "42";
export default function OneTimeCodeInputWithSelfTypingDemoAndVerifiedState() {
const reduce = useReducedMotion();
const inputRef = useRef<HTMLInputElement>(null);
const [code, setCode] = useState("");
const [demo, setDemo] = useState(true);
const [focused, setFocused] = useState(false);
const done = code.length === LENGTH;
// The demo types a code by itself until the visitor focuses the field.
useEffect(() => {
if (!demo) return;
if (reduce) {
setCode(DEMO_CODE);
return;
}
const timer = setTimeout(
() => setCode((c) => (c.length >= LENGTH ? "" : DEMO_CODE.slice(0, c.length + 1))),
done ? 2200 : code.length === 0 ? 700 : 380
);
return () => clearTimeout(timer);
}, [demo, code, done, reduce]);
const takeOver = () => {
if (demo) {
setDemo(false);
setCode("");
}
};
const restart = () => {
setCode("");
setDemo(false);
inputRef.current?.focus();
};
const active = done ? -1 : code.length;
const showCaret = demo || focused;
return (
<section className="bg-gradient-to-b from-indigo-50 to-white px-4 py-16 sm:px-6 sm:py-24">
<style>{`
@keyframes one-time-code-input-with-self-typing-demo-and-verified-state-caret {
0%, 55% { opacity: 1; }
56%, 100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.one-time-code-input-with-self-typing-demo-and-verified-state-caret { animation: none !important; }
}
`}</style>
<div className="mx-auto w-full max-w-md rounded-3xl border border-slate-200 bg-white p-6 shadow-xl shadow-indigo-950/5 sm:p-9">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-indigo-600 text-white">
<ShieldCheck className="h-6 w-6" aria-hidden="true" />
</div>
<h2 className="mt-6 text-2xl font-semibold tracking-tight text-slate-900">Check your phone</h2>
<p className="mt-2 text-base leading-relaxed text-slate-600">
We sent a six digit code to the number ending in {phoneEnding}. It expires in ten minutes.
</p>
<div className="relative mt-8">
<label htmlFor="one-time-code-field" className="sr-only">
Six digit verification code
</label>
{/* One real input sits invisibly over the boxes, so paste, autofill and screen readers all work. */}
<input
ref={inputRef}
id="one-time-code-field"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={LENGTH}
value={demo ? "" : code}
onFocus={() => {
takeOver();
setFocused(true);
}}
onBlur={() => setFocused(false)}
onChange={(e) => setCode(e.target.value.replace(/\D/g, "").slice(0, LENGTH))}
className="peer absolute inset-0 z-10 h-full w-full cursor-text rounded-xl opacity-0"
/>
<div className="flex justify-between gap-2 rounded-xl peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-600 peer-focus-visible:ring-offset-4" aria-hidden="true">
{Array.from({ length: LENGTH }, (_, i) => {
const isActive = showCaret && i === active;
return (
<div
key={i}
className={`relative flex h-14 flex-1 items-center justify-center rounded-xl border text-2xl font-semibold tabular-nums transition-colors duration-300 sm:h-16 ${
done ? "border-emerald-500 bg-emerald-50 text-emerald-700" : isActive ? "border-indigo-600 bg-white text-slate-900" : "border-slate-200 bg-slate-50 text-slate-900"
} ${i === 2 ? "mr-2 sm:mr-3" : ""}`}
>
{isActive && (
<motion.span
layoutId="one-time-code-ring"
className="absolute -inset-px rounded-xl border-2 border-indigo-600 shadow-[inset_0_0_14px_rgba(79,70,229,0.28)]"
transition={{ type: "spring", stiffness: 520, damping: 38 }}
/>
)}
<AnimatePresence mode="popLayout" initial={false}>
{code[i] ? (
<motion.span
key={`${i}-${code[i]}`}
initial={reduce ? false : { opacity: 0, y: 10, scale: 0.6, filter: "blur(4px)" }}
animate={{ opacity: 1, y: 0, scale: 1, filter: "blur(0px)" }}
exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6, transition: { duration: 0.12 } }}
transition={{ type: "spring", stiffness: 480, damping: 26 }}
>
{code[i]}
</motion.span>
) : isActive ? (
<span
key="caret"
className="one-time-code-input-with-self-typing-demo-and-verified-state-caret h-6 w-0.5 rounded-full bg-indigo-600"
style={{ animation: "one-time-code-input-with-self-typing-demo-and-verified-state-caret 1s steps(1) infinite" }}
/>
) : null}
</AnimatePresence>
{done && !reduce && (
<motion.span
className="absolute inset-0 rounded-xl border-2 border-emerald-400"
initial={{ opacity: 0.9, scale: 1 }}
animate={{ opacity: 0, scale: 1.28 }}
transition={{ duration: 0.6, delay: i * 0.05, ease: "easeOut" }}
/>
)}
</div>
);
})}
</div>
</div>
<div className="mt-6 flex min-h-[44px] items-center" aria-live="polite">
<AnimatePresence mode="wait" initial={false}>
{done ? (
<motion.p
key="ok"
initial={reduce ? false : { opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className="flex items-center gap-2 text-sm font-semibold text-emerald-700"
>
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-emerald-600 text-white">
<Check className="h-3.5 w-3.5" strokeWidth={3} aria-hidden="true" />
</span>
Code accepted. Signing you in.
</motion.p>
) : (
<motion.p key="hint" initial={reduce ? false : { opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="text-sm text-slate-600">
{demo ? "Tap the boxes to type your own code." : "Type or paste the code. Any six digits work here."}
</motion.p>
)}
</AnimatePresence>
</div>
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 border-t border-slate-200 pt-5">
<p className="text-sm text-slate-600">Nothing arrived?</p>
<button
type="button"
onClick={restart}
className="inline-flex h-10 items-center rounded-lg px-3 text-sm font-semibold text-indigo-700 transition-colors hover:bg-indigo-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-600"
>
Clear and send a new code
</button>
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/one-time-code-input-with-self-typing-demo-and-verified-state.json
Six inputs need custom code to move focus forward, handle backspace across boxes and split a pasted code, and each of those tends to break on some phone keyboard. One transparent input laid over the boxes gets all of that from the browser for free, and the boxes only display its characters.
Because the real field is a normal text input, pasting puts the whole string into it. The `onChange` handler strips anything that is not a digit and trims to the code length, so pasting a code with spaces or a dash still fills the boxes.
It stops the moment the field receives focus. To remove it entirely, start with `useState(false)` for `demo` and delete the effect that types `DEMO_CODE`.
Add an `error` state, set it when your server rejects the code, and use it in the box class list in place of the emerald classes, for example `border-rose-500 bg-rose-50`. Clear the code so the person can retype.
No. The ring is an absolutely positioned element that framer-motion moves between boxes with a shared `layoutId`. The boxes themselves never change size.
Adapted from Clerk OTP in Eldora UI, used under its open-source licence. Licences and credits
Related: Dark Sign-In Form with Show Password Toggle, Multi-Step Signup Form with Step Indicator, Search Field with Grouped Results Dropdown.
More inputs or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.