UIForms and Inputsmotion/react

OtpInput

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

6 digits

Customize

9 props
6
3

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add otp-input

Writes 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.

example.tsx
1import { OtpInput } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <OtpInput />6  );7}

Props

PropTypeDefault
length

Number of characters in the code (1 to 12).

number6
value

Controlled value.

stringundefined
defaultValue

Initial value when uncontrolled.

string''
onValueChange

Called with the sanitized value on every change.

(value: string) => voidundefined
onComplete

Called when every slot is filled.

(value: string) => voidundefined
mode

Accepted characters; alphanumeric input is upper-cased.

'numeric' | 'alphanumeric''numeric'
groupSize

Insert a dash separator after every N slots; 0 disables grouping.

number3
mask

Render dots instead of the typed characters.

booleanfalse
label

Visible label and accessible name.

string'Verification code'
size

Slot size preset.

'sm' | 'md' | 'lg''md'
accentColor

Color of the active slot ring and caret.

string'#6366f1'
invalid

Red error state with a short shake; sets aria-invalid.

booleanfalse
disabled

Disable the field.

booleanfalse
className

Additional class merged onto the root element.

string''
style

Inline styles merged onto the root element.

React.CSSPropertiesundefined

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

components/ui/otp-input.tsx
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}