One-time-code boxes where each digit pops in, with paste and phone autofill built in.
Customize
Usage
import { CodeInput } from "@/components/motionkit/code-input";
<CodeInput onComplete={(code) => verify(code)} />Motion
The exact curves this component runs, plotted with Motion's own maths.
Digit pop
A character is typed
Works in the App Router as is. Requires Tailwind CSS v4.
npm install motionSave as components/motionkit/code-input.tsx.
"use client";
import { useId, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type CodeInputProps = {
/** Number of characters. */
length?: number;
/** Called with the full code once every box is filled. */
onComplete?: (code: string) => void;
/** Accessible name. */
label?: string;
/** Allow letters as well as digits. */
alphanumeric?: boolean;
className?: string;
};
export function CodeInput({
length = 6,
onComplete,
label = "Verification code",
alphanumeric = false,
className = "",
}: CodeInputProps) {
const [value, setValue] = useState("");
const [focused, setFocused] = useState(false);
const reduceMotion = useReducedMotion();
const id = useId();
function handleChange(next: string) {
const clean = (alphanumeric ? next.replace(/[^a-z0-9]/gi, "").toUpperCase() : next.replace(/\D/g, "")).slice(0, length);
setValue(clean);
if (clean.length === length) onComplete?.(clean);
}
return (
<div className={`relative inline-block ${className}`}>
{/*
One real input sits invisibly over the boxes, so typing, deleting, pasting and
phone autofill ("one-time-code") all work natively. The boxes are only a view.
*/}
<input
id={id}
aria-label={label}
value={value}
onChange={(event) => handleChange(event.target.value)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
inputMode={alphanumeric ? "text" : "numeric"}
autoComplete="one-time-code"
maxLength={length}
spellCheck={false}
className="absolute inset-0 z-10 w-full cursor-text bg-transparent text-transparent caret-transparent outline-none selection:bg-transparent"
/>
<div aria-hidden className="flex gap-2">
{Array.from({ length }, (_, index) => {
const char = value[index];
const isActive = focused && (index === value.length || (index === length - 1 && value.length === length));
return (
<div
key={index}
className={`relative flex h-12 w-10 items-center justify-center rounded-lg border bg-[#141B1B] font-mono text-lg text-[#F6F1E8] transition-colors ${
isActive ? "border-[#5FAEB4] ring-2 ring-[#5FAEB4]/25" : "border-white/10"
}`}
>
<AnimatePresence mode="popLayout" initial={false}>
{char && (
<motion.span
key={char + index}
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.8 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, transition: { duration: 0.1 } }}
transition={reduceMotion ? { duration: 0.1 } : { type: "spring", bounce: 0.4, duration: 0.3 }}
>
{char}
</motion.span>
)}
</AnimatePresence>
{isActive && !char && (
<motion.span
className="h-5 w-px bg-[#5FAEB4]"
animate={reduceMotion ? undefined : { opacity: [1, 0, 1] }}
transition={{ duration: 1, repeat: Infinity, ease: "easeInOut" }}
/>
)}
</div>
);
})}
</div>
</div>
);
}
| Prop | Type | Default | Description |
|---|---|---|---|
| length | number | 6 | Number of characters. |
| onComplete | (code) => void | — | Called once every box is filled. |
| label | string | "Verification code" | Accessible name. |
| alphanumeric | boolean | false | Allow letters as well as digits. |