Skip to content
Uxelith
Sign in
Freev1.0.0Forms

OTP Input

A one-time-code field drawn as boxes, built on a single real input: the focus ring glides box to box, a wrong code shakes, a right one lights up in a wave. Paste, SMS autofill, PIN masking and screen readers just work.

pnpm dlx shadcn add …/r/otp-input.json
Licence
MIT
Packages
3
Updated
9 Oct 2026
Live

Installation

  1. Run this in a project set up with shadcn/ui:

    $ pnpm dlx shadcn@latest add https://uxelith.com/r/otp-input.json
  2. It adds these packages for you:

    clsxtailwind-mergemotion
  3. The source lands in your components folder as otp-input.tsx. It's yours: change anything.

Usage

example.tsx
"use client"

import { OtpInput } from "@/components/otp-input"

export function Verify() {
  return (
    <OtpInput
      label="Verification code"
      separators={[3]}
      onComplete={(code) => console.log("verify", code)}
    />
  )
}

Source

One file, 192 lines, nothing hidden. Read it before you install it.

otp-input.tsx
"use client";

import { AnimatePresence, motion, useAnimate, useReducedMotion } from "motion/react";
import * as React from "react";

import { cn } from "@/lib/utils";

export interface OtpInputProps
  extends Omit<React.ComponentProps<"input">, "value" | "defaultValue" | "onChange" | "maxLength" | "pattern" | "type" | "size"> {
  /** Number of characters. */
  length?: number;
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  /** Called once every slot is filled. */
  onComplete?: (value: string) => void;
  /** `numeric` shows the number pad on phones; `alphanumeric` accepts A–Z too (upper-cased). */
  mode?: "numeric" | "alphanumeric";
  /** Draw a dash after these slot positions, e.g. `[3]` for 123–456. */
  separators?: number[];
  /** Accessible name, e.g. "Verification code". */
  label: string;
  /** Marks the code wrong: red boxes and a shake. */
  invalid?: boolean;
  /** Marks the code accepted: the boxes light up in a wave. */
  success?: boolean;
  /** Show dots instead of the characters, e.g. for a PIN. */
  mask?: boolean;
}

// Motion presets (ADR-010).
const SPRING_SNAPPY = { type: "spring", visualDuration: 0.2, bounce: 0 } as const;

/**
 * A one-time-code field drawn as separate boxes.
 *
 * It is a single real `<input>` under the boxes, so everything that works in
 * a text field works here: paste, SMS autofill (`autocomplete="one-time-code"`),
 * password managers, undo, screen readers reading the whole code, and
 * selecting it all. Invalid characters are dropped as you type or paste.
 *
 * The focus ring glides from box to box, characters settle in as they're
 * typed, `invalid` shakes the row and `success` lights it up in a wave —
 * all without movement under `prefers-reduced-motion`.
 */
export function OtpInput({
  length = 6,
  value: valueProp,
  defaultValue = "",
  onValueChange,
  onComplete,
  mode = "numeric",
  separators = [],
  label,
  invalid = false,
  success = false,
  mask = false,
  disabled,
  className,
  onFocus,
  onBlur,
  onSelect,
  ...props
}: OtpInputProps) {
  const ringId = React.useId();
  const reduceMotion = useReducedMotion();
  const [scope, animate] = useAnimate<HTMLDivElement>();
  const inputRef = React.useRef<HTMLInputElement>(null);
  const [uncontrolled, setUncontrolled] = React.useState(defaultValue);
  const value = (valueProp ?? uncontrolled).slice(0, length);
  const [focused, setFocused] = React.useState(false);
  const [caret, setCaret] = React.useState(value.length);

  const clean = (raw: string) =>
    (mode === "numeric" ? raw.replace(/\D/g, "") : raw.toUpperCase().replace(/[^A-Z0-9]/g, "")).slice(0, length);

  const syncCaret = () => {
    const input = inputRef.current;
    if (input) setCaret(Math.min(input.selectionStart ?? input.value.length, length - 1));
  };

  const active = focused ? Math.min(caret, value.length, length - 1) : -1;

  // Shake when the code turns out wrong.
  React.useEffect(() => {
    if (!invalid || reduceMotion || !scope.current) return;
    void animate(scope.current, { x: [0, -8, 7, -5, 3, 0] }, { duration: 0.4, ease: [0.22, 1, 0.36, 1] });
  }, [invalid, reduceMotion, animate, scope]);

  return (
    <div
      ref={scope}
      className={cn("relative inline-flex items-center gap-2", disabled && "opacity-50", className)}
      onPointerDown={(e) => {
        // Clicking any box focuses the real field at the end of what's typed.
        if (e.target === inputRef.current) return;
        e.preventDefault();
        inputRef.current?.focus();
        inputRef.current?.setSelectionRange(value.length, value.length);
        syncCaret();
      }}
    >
      <input
        ref={inputRef}
        // Masked codes stay hidden from screen readers and screenshots too.
        type={mask ? "password" : "text"}
        aria-label={label}
        aria-invalid={invalid || undefined}
        autoComplete="one-time-code"
        inputMode={mode === "numeric" ? "numeric" : "text"}
        autoCapitalize="characters"
        spellCheck={false}
        // No maxLength: the browser would cut "123 456" to "123 45" before we strip the space.
        disabled={disabled}
        value={value}
        onChange={(e) => {
          const next = clean(e.target.value);
          if (valueProp === undefined) setUncontrolled(next);
          onValueChange?.(next);
          if (next.length === length && value.length !== length) onComplete?.(next);
          requestAnimationFrame(syncCaret);
        }}
        onFocus={(e) => {
          onFocus?.(e);
          setFocused(true);
          syncCaret();
        }}
        onBlur={(e) => {
          onBlur?.(e);
          setFocused(false);
        }}
        onSelect={(e) => {
          onSelect?.(e);
          syncCaret();
        }}
        // Covers the boxes so taps and long-press paste land on the real field; invisible itself.
        className="absolute inset-0 z-10 w-full cursor-text bg-transparent text-transparent caret-transparent opacity-0 outline-none selection:bg-transparent disabled:cursor-not-allowed"
        {...props}
      />
      {Array.from({ length }, (_, i) => (
        <React.Fragment key={i}>
          <motion.div
            aria-hidden="true"
            data-active={i === active || undefined}
            data-filled={i < value.length || undefined}
            animate={success && !reduceMotion ? { scale: [1, 1.08, 1] } : { scale: 1 }}
            // Keyframes need a tween; springs only take two values.
            transition={success ? { duration: 0.4, ease: [0.22, 1, 0.36, 1], delay: i * 0.04 } : SPRING_SNAPPY}
            className={cn(
              "relative flex size-12 items-center justify-center rounded-lg border bg-card font-mono text-lg font-medium tabular-nums text-foreground",
              "transition-[border-color,color,background-color] duration-200 motion-reduce:transition-none",
              invalid ? "border-destructive/70 text-destructive" : success ? "border-[var(--otp-success,var(--primary))] text-[var(--otp-success,var(--primary))]" : "border-input",
            )}
          >
            {i === active && (
              <motion.span
                layoutId={`${ringId}-ring`}
                transition={reduceMotion ? { duration: 0 } : SPRING_SNAPPY}
                className={cn(
                  "absolute -inset-px rounded-lg border",
                  invalid
                    ? "border-destructive shadow-[0_0_0_3px_color-mix(in_oklab,var(--destructive)_25%,transparent)]"
                    : "border-ring shadow-[0_0_0_3px_color-mix(in_oklab,var(--ring)_25%,transparent)]",
                )}
              />
            )}
            {/* Characters there on first render (defaultValue) don't animate, so server and client agree. */}
            <AnimatePresence initial={false}>
              {value[i] !== undefined && (
                <motion.span
                  key={`${i}-${value[i]}`}
                  initial={{ opacity: 0, y: 6, scale: 0.8, filter: "blur(2px)" }}
                  animate={{ opacity: 1, y: 0, scale: 1, filter: "blur(0px)" }}
                  exit={{ opacity: 0, transition: { duration: 0 } }}
                  transition={reduceMotion ? { duration: 0 } : SPRING_SNAPPY}
                >
                  {mask ? "•" : value[i]}
                </motion.span>
              )}
            </AnimatePresence>
            {i === active && i >= value.length && (
              <span className="absolute h-5 w-px animate-[ux-caret-blink_1.1s_steps(1)_infinite] bg-foreground motion-reduce:animate-none" />
            )}
          </motion.div>
          {separators.includes(i + 1) && i < length - 1 && (
            <span aria-hidden="true" className="h-px w-3 rounded-full bg-muted-foreground/60" />
          )}
        </React.Fragment>
      ))}
    </div>
  );
}

Props

PropTypeDefaultDescription
labelstring—Accessible name, e.g. "Verification code".
lengthnumber6Number of characters.
value / defaultValuestring— / ""Controlled or uncontrolled value.
onValueChange(value: string) => void—Called on every change.
onComplete(value: string) => void—Called once all slots are filled.
mode"numeric" | "alphanumeric""numeric"Numeric shows the number pad on phones.
separatorsnumber[][]Draw a dash after these positions.
invalidbooleanfalseMarks the code wrong: red boxes and a shake.
successbooleanfalseMarks the code accepted: the boxes light up in a wave.
maskbooleanfalseDots instead of characters, and a password field underneath — for PINs.

Accessibility

  • One real <input> with autocomplete="one-time-code": SMS autofill, paste and password managers work.
  • Screen readers read the whole code as one field; the boxes are aria-hidden.
  • Invalid characters are dropped while typing or pasting.
  • mask makes the field a password input, so the code isn't read aloud or shown.
  • Under prefers-reduced-motion there is no shake, wave or glide, and the caret doesn't blink.

Changelog

  1. v1.0.0Latest

    First release.

More in Forms

All 10 in Forms
  • FormsFree

    Animated Input

    A text field whose label floats out of the way, with examples that take turns while it's empty, an error that shakes once, a tick for a valid value and sent text that drifts away.

  • FormsPro

    Date Picker

    Single dates and ranges in a keyboard-first calendar, with one-tap presets ("Last 7 days"), months that slide the way you go, min/max, unavailable days and ISO strings for forms.

  • FormsPro

    File Dropzone

    Drop, paste or browse for files: the zone lights up as a file crosses the page and warns before a wrong type lands; files show thumbnails and upload with live, cancellable progress.