A one-time-code field rendered as separate slots: characters spring into place as you type, the active slot glows with a blinking caret, pasting fills every slot at once, and a completed code flashes green.
Preview
Customize
9 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add otp-inputWrites components/ui/otp-input.tsx and components/styles/otp-input.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { OtpInput } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <OtpInput />6 );7}Props
Accessibility
Backed by a single native <input> (autocomplete="one-time-code", numeric inputMode) labelled by a visible <label> and described by a screen-reader hint with the expected length, so autofill, paste and screen readers see one field instead of N. The visual slots are aria-hidden. Typing auto-advances, Backspace steps back, Left/Right/Home/End move between slots and typing over a filled slot replaces it. invalid sets aria-invalid. Under prefers-reduced-motion characters fade without springing, and the caret blink, completion glow and error shake are disabled.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import { AnimatePresence, motion, useReducedMotion } from "motion/react";5import styles from "../styles/otp-input.module.css";6 7export interface OtpInputProps {8 /** Number of characters in the code */9 length?: number;10 /** Controlled value */11 value?: string;12 /** Initial value when uncontrolled */13 defaultValue?: string;14 /** Called with the sanitized value on every change */15 onValueChange?: (value: string) => void;16 /** Called once every slot is filled */17 onComplete?: (value: string) => void;18 /** Accepted characters */19 mode?: "numeric" | "alphanumeric";20 /** Insert a separator after every N slots (0 disables) */21 groupSize?: number;22 /** Render dots instead of the typed characters */23 mask?: boolean;24 /** Visible label above the slots (also the accessible name) */25 label?: string;26 /** Slot size preset */27 size?: "sm" | "md" | "lg";28 /** Accent color of the active slot, caret and completion glow */29 accentColor?: string;30 /** Show the error state */31 invalid?: boolean;32 /** Disable the field */33 disabled?: boolean;34 /** Optional className merged onto the root */35 className?: string;36 /** Optional inline styles merged onto the root */37 style?: React.CSSProperties;38}39 40function sanitize(raw: string, mode: "numeric" | "alphanumeric", length: number): string {41 const clean = mode === "numeric" ? raw.replace(/\D/g, "") : raw.replace(/[^a-z0-9]/gi, "").toUpperCase();42 return clean.slice(0, length);43}44 45export function OtpInput({46 length = 6,47 value: valueProp,48 defaultValue = "",49 onValueChange,50 onComplete,51 mode = "numeric",52 groupSize = 3,53 mask = false,54 label = "Verification code",55 size = "md",56 accentColor = "#6366f1",57 invalid = false,58 disabled = false,59 className = "",60 style,61}: OtpInputProps) {62 const reduce = useReducedMotion();63 const count = Math.max(1, Math.min(12, Math.round(length)));64 const [inner, setInner] = React.useState(() => sanitize(defaultValue, mode, count));65 const controlled = valueProp !== undefined;66 const value = controlled ? sanitize(valueProp, mode, count) : inner;67 const [focused, setFocused] = React.useState(false);68 const [cursor, setCursor] = React.useState(0);69 const inputRef = React.useRef<HTMLInputElement>(null);70 const id = React.useId();71 const complete = value.length === count;72 const active = Math.min(cursor, value.length, count - 1);73 74 const commit = (raw: string) => {75 const next = sanitize(raw, mode, count);76 if (next === value) return;77 if (!controlled) setInner(next);78 onValueChange?.(next);79 if (next.length === count) onComplete?.(next);80 };81 82 const syncCursor = () => {83 const el = inputRef.current;84 if (el) setCursor(el.selectionStart ?? el.value.length);85 };86 87 const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {88 const el = e.currentTarget;89 if (e.key === "ArrowLeft" || e.key === "ArrowRight" || e.key === "Home" || e.key === "End") {90 e.preventDefault();91 const pos = e.key === "Home" ? 0 : e.key === "End" ? value.length : Math.max(0, Math.min(value.length, active + (e.key === "ArrowLeft" ? -1 : 1)));92 el.setSelectionRange(pos, pos);93 setCursor(pos);94 } else if (e.key.length === 1 && !e.metaKey && !e.ctrlKey && el.selectionStart === el.selectionEnd && active < value.length) {95 // Typing over an existing slot replaces it and advances.96 e.preventDefault();97 const ch = sanitize(e.key, mode, 1);98 if (!ch) return;99 commit(value.slice(0, active) + ch + value.slice(active + 1));100 const pos = Math.min(active + 1, count);101 requestAnimationFrame(() => el.setSelectionRange(pos, pos));102 setCursor(pos);103 } else if (e.key === "Backspace" && el.selectionStart === el.selectionEnd && active < value.length && value.length === count) {104 // On a full code, Backspace clears the highlighted slot and everything after it.105 e.preventDefault();106 commit(value.slice(0, active));107 setCursor(active);108 }109 };110 111 const groups = groupSize > 0 && groupSize < count ? groupSize : 0;112 const pop = reduce ? { duration: 0.08 } : { type: "spring" as const, stiffness: 600, damping: 28 };113 114 return (115 <div116 className={`${styles.root} ${className}`.trim()}117 data-size={size}118 data-invalid={invalid ? "" : undefined}119 data-complete={complete && !invalid ? "" : undefined}120 data-disabled={disabled ? "" : undefined}121 style={{ "--mf-otp-accent": accentColor, ...style } as React.CSSProperties}122 >123 <label htmlFor={id} className={styles.label}>124 {label}125 </label>126 <span id={`${id}-hint`} className={styles.srOnly}>127 {`${count} ${mode === "numeric" ? "digits" : "characters"}`}128 </span>129 <div className={styles.slots}>130 <input131 ref={inputRef}132 id={id}133 className={styles.input}134 value={value}135 maxLength={count}136 disabled={disabled}137 inputMode={mode === "numeric" ? "numeric" : "text"}138 autoComplete="one-time-code"139 autoCapitalize="characters"140 spellCheck={false}141 aria-invalid={invalid || undefined}142 aria-describedby={`${id}-hint`}143 onChange={(e) => {144 commit(e.target.value);145 setCursor(sanitize(e.target.value, mode, count).length);146 }}147 onSelect={syncCursor}148 onKeyDown={onKeyDown}149 onFocus={() => {150 setFocused(true);151 setCursor(value.length);152 }}153 onBlur={() => setFocused(false)}154 />155 {Array.from({ length: count }, (_, i) => {156 const ch = value[i];157 const isActive = focused && i === active;158 return (159 <React.Fragment key={i}>160 {groups > 0 && i > 0 && i % groups === 0 && <span aria-hidden="true" className={styles.sep} />}161 <div aria-hidden="true" className={styles.slot} data-active={isActive ? "" : undefined} data-filled={ch ? "" : undefined}>162 <AnimatePresence initial={false}>163 {ch && (164 <motion.span165 key={ch + i}166 className={styles.char}167 initial={{ opacity: 0, y: reduce ? 0 : 8, scale: reduce ? 1 : 0.6 }}168 animate={{ opacity: 1, y: 0, scale: 1 }}169 exit={{ opacity: 0, scale: reduce ? 1 : 0.6, transition: { duration: 0.1 } }}170 transition={pop}171 >172 {mask ? "•" : ch}173 </motion.span>174 )}175 </AnimatePresence>176 {isActive && !ch && <span className={styles.caret} />}177 </div>178 </React.Fragment>179 );180 })}181 </div>182 </div>183 );184}