liten

OTP Input

A monochrome one-time-code card topped with a dashed grid whose cells quietly shuffle digits, a wrong code shakes red.

Enter the code

We sent a code to your email, enter it to verify your address.

A monochrome one-time-code card topped with a dashed grid whose cells quietly shuffle digits on their own offbeat timers, the even dashes marching along each line. Type a digit and it resolves into its well: a soft scale and blur crossfade, no drop. The active well wears a neutral ring and a blinking caret. Fill the last slot and the row sweeps verified; a wrong code shakes and clears, red being the one color the design earns, because a wrong code is the one moment color carries meaning. Built with Motion.

Just start typing, or paste a code, and on mobile the SMS autofill suggestion drops straight in. One hidden input drives the whole row, so the keyboard, paste, and one-time-code autofill all work.

Installation

Complete the shared Setup first, then copy the component into components/ui/otp-input.tsx.

Terminal
bun add motion
components/ui/otp-input.tsx
'use client';import * as React from 'react';import { AnimatePresence, motion, useReducedMotion } from 'motion/react';import { cn } from '@/lib/cn';const EASE_OUT = [0.23, 1, 0.32, 1] as const;export type OtpStatus = 'idle' | 'success' | 'error';export type OtpInputProps = {  length?: number;  value?: string;  defaultValue?: string;  onChange?: (value: string) => void;  onComplete?: (value: string) => void;  status?: OtpStatus;  accent?: string;  title?: string;  description?: string;  disabled?: boolean;  autoFocus?: boolean;  'aria-label'?: string;  className?: string;};export function OtpInput({  length = 6,  value: controlled,  defaultValue = '',  onChange,  onComplete,  status = 'idle',  accent = '#a1a1aa',  title = 'Enter the code',  description,  disabled = false,  autoFocus = false,  'aria-label': ariaLabel = 'Verification code',  className,}: OtpInputProps) {  const reduce = useReducedMotion();  const inputRef = React.useRef<HTMLInputElement>(null);  const sanitize = React.useCallback(    (raw: string) => raw.replace(/\D/g, '').slice(0, length),    [length],  );  const isControlled = controlled !== undefined;  const [uncontrolled, setUncontrolled] = React.useState(() =>    sanitize(defaultValue),  );  const value = isControlled ? sanitize(controlled as string) : uncontrolled;  const [focused, setFocused] = React.useState(false);  const [shakeKey, setShakeKey] = React.useState(0);  React.useEffect(() => {    if (status === 'error') setShakeKey((k) => k + 1);  }, [status]);  const pinCaret = React.useCallback(() => {    const el = inputRef.current;    if (!el) return;    const end = el.value.length;    requestAnimationFrame(() => el.setSelectionRange(end, end));  }, []);  const handleChange = (raw: string) => {    const next = sanitize(raw);    if (!isControlled) setUncontrolled(next);    onChange?.(next);    if (next.length === length) onComplete?.(next);    pinCaret();  };  const focus = () => inputRef.current?.focus();  const activeIndex = value.length;  const success = status === 'success';  const error = status === 'error';  const ring = error ? '#f0463a' : accent;  return (    <div      style={{ '--otp-ring': ring } as React.CSSProperties}      className={cn(        'bloom-edge relative w-[300px] overflow-hidden rounded-[14px] px-6 pb-6 pt-0',        'bg-gradient-to-b from-white to-[#f4f4f5] text-neutral-900',        'dark:from-[#1d1d1d] dark:to-[#161716] dark:text-white',        'shadow-[0_1px_2px_0_rgba(0,0,0,0.05),0_14px_36px_-12px_rgba(0,0,0,0.18)]',        'dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.05),0_1px_2px_0_rgba(0,0,0,0.4),0_18px_44px_-14px_rgba(0,0,0,0.7)]',        disabled && 'opacity-60',        className,      )}    >      <NumberGrid reduce={!!reduce} />      <div className="relative -mt-5 text-center">        <h3 className="text-[15px] font-semibold tracking-[-0.02em]">{title}</h3>        {description && (          <p className="mx-auto mt-1.5 max-w-[240px] text-[12.5px] font-medium leading-snug tracking-[-0.01em] text-neutral-500 dark:text-[#929292]">            {description}          </p>        )}      </div>      <div className="relative mt-6">        <input          ref={inputRef}          value={value}          onChange={(e) => handleChange(e.target.value)}          onFocus={() => {            setFocused(true);            pinCaret();          }}          onBlur={() => setFocused(false)}          onClick={pinCaret}          disabled={disabled}          autoFocus={autoFocus}          inputMode="numeric"          autoComplete="one-time-code"          pattern="\d*"          maxLength={length}          aria-label={ariaLabel}          className="absolute inset-0 z-10 w-full cursor-text bg-transparent text-transparent caret-transparent outline-none selection:bg-transparent disabled:cursor-not-allowed"        />        <motion.div          key={shakeKey}          animate={            error && !reduce ? { x: [0, -7, 7, -5, 5, -3, 3, 0] } : { x: 0 }          }          transition={{ duration: 0.4, ease: EASE_OUT }}          className="flex justify-center gap-2"        >          {Array.from({ length }).map((_, i) => {            const char = value[i];            const isActive = focused && i === activeIndex && !disabled;            return (              <Slot                key={i}                index={i}                char={char}                active={isActive}                success={success}                reduce={!!reduce}                onPointerDown={(e) => {                  e.preventDefault();                  focus();                }}              />            );          })}        </motion.div>      </div>    </div>  );}const COLS = 7;const ROWS = 4;const CELLS = COLS * ROWS;const DASH = '4 4';const base = (o: number | undefined) => o ?? 0.2;const flash = (o: number | undefined) => Math.min(0.85, (o ?? 0.2) * 2.4);function NumberGrid({ reduce }: { reduce: boolean }) {  const [digits, setDigits] = React.useState<number[]>([]);  const opacity = React.useRef<number[]>([]);  React.useEffect(() => {    opacity.current = Array.from(      { length: CELLS },      () => 0.1 + Math.pow(Math.random(), 1.4) * 0.42,    );    setDigits(Array.from({ length: CELLS }, () => Math.floor(Math.random() * 10)));  }, []);  React.useEffect(() => {    if (reduce) return;    let timer: ReturnType<typeof setTimeout>;    const tick = () => {      setDigits((d) => {        if (d.length === 0) return d;        const next = [...d];        for (let k = 0; k < 2; k++) {          const i = Math.floor(Math.random() * next.length);          next[i] = Math.floor(Math.random() * 10);        }        return next;      });      timer = setTimeout(tick, 90 + Math.random() * 190);    };    timer = setTimeout(tick, 200);    return () => clearTimeout(timer);  }, [reduce]);  const vLines = Array.from({ length: COLS - 1 }, (_, i) => ((i + 1) / COLS) * 100);  const hLines = Array.from({ length: ROWS - 1 }, (_, i) => ((i + 1) / ROWS) * 100);  return (    <div      aria-hidden      className="pointer-events-none relative -ml-6 h-44 w-[calc(100%+3rem)] select-none text-neutral-900 dark:text-white [mask-image:linear-gradient(to_bottom,black_0%,black_50%,transparent_92%)]"    >      <svg        className="absolute inset-0 h-full w-full text-neutral-400 dark:text-neutral-600"        preserveAspectRatio="none"      >        {vLines.map((x, i) => (          <line            key={`v${i}`}            x1={`${x}%`}            y1="0"            x2={`${x}%`}            y2="100%"            stroke="currentColor"            strokeWidth={1}            strokeDasharray={DASH}            vectorEffect="non-scaling-stroke"          />        ))}        {hLines.map((y, i) => (          <line            key={`h${i}`}            x1="0"            y1={`${y}%`}            x2="100%"            y2={`${y}%`}            stroke="currentColor"            strokeWidth={1}            strokeDasharray={DASH}            vectorEffect="non-scaling-stroke"          />        ))}      </svg>      <div        className="grid h-full w-full"        style={{          gridTemplateColumns: `repeat(${COLS}, 1fr)`,          gridTemplateRows: `repeat(${ROWS}, 1fr)`,        }}      >        {digits.map((d, i) => (          <div key={i} className="relative grid place-items-center">            <AnimatePresence mode="popLayout">              <motion.span                key={d}                initial={                  reduce                    ? { opacity: base(opacity.current[i]) }                    : { opacity: 0, y: 9, filter: 'blur(3px)' }                }                animate={                  reduce                    ? { opacity: base(opacity.current[i]) }                    : {                        opacity: [0, flash(opacity.current[i]), base(opacity.current[i])],                        y: [9, 0, 0],                        filter: ['blur(3px)', 'blur(0px)', 'blur(0px)'],                      }                }                exit={                  reduce                    ? { opacity: 0 }                    : { opacity: 0, y: -9, filter: 'blur(3px)', transition: { duration: 0.16, ease: EASE_OUT } }                }                transition={{ duration: 0.34, ease: EASE_OUT, times: [0, 0.45, 1] }}                className="absolute text-[13px] font-medium tabular-nums"              >                {d}              </motion.span>            </AnimatePresence>          </div>        ))}      </div>    </div>  );}function Slot({  index,  char,  active,  success,  reduce,  onPointerDown,}: {  index: number;  char: string | undefined;  active: boolean;  success: boolean;  reduce: boolean;  onPointerDown: (e: React.PointerEvent) => void;}) {  const filled = char !== undefined;  return (    <div      onPointerDown={onPointerDown}      className={cn(        'bloom-edge relative grid h-12 w-11 place-items-center overflow-hidden rounded-[11px]',        'bg-black/[0.02] dark:bg-black/25',        'shadow-[inset_0_1px_2px_0_rgba(0,0,0,0.04)] dark:shadow-[inset_0_1px_3px_0_rgba(0,0,0,0.5)]',        'transition-[box-shadow,background-color] duration-200',        success &&          'bg-white/[0.02] [box-shadow:0_0_0_1px_rgba(0,0,0,0.2),inset_0_1px_0_0_rgba(255,255,255,0.6)] dark:bg-white/[0.05] dark:[box-shadow:0_0_0_1px_rgba(255,255,255,0.3),inset_0_1px_0_0_rgba(255,255,255,0.08)]',        active &&          !success &&          '[box-shadow:0_0_0_2px_var(--otp-ring),inset_0_1px_2px_0_rgba(0,0,0,0.04)] dark:[box-shadow:0_0_0_2px_var(--otp-ring),inset_0_1px_3px_0_rgba(0,0,0,0.5)]',      )}      style={success && !reduce ? { transitionDelay: `${index * 45}ms` } : undefined}    >      <AnimatePresence mode="popLayout">        {filled && (          <motion.span            key={char}            aria-hidden            initial={              reduce ? { opacity: 0 } : { opacity: 0, scale: 0.78, filter: 'blur(5px)' }            }            animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }}            exit={              reduce                ? { opacity: 0, transition: { duration: 0.1 } }                : {                    opacity: 0,                    scale: 0.9,                    filter: 'blur(4px)',                    transition: { duration: 0.12, ease: EASE_OUT },                  }            }            transition={reduce ? { duration: 0.15 } : { duration: 0.2, ease: EASE_OUT }}            className="relative text-sm font-medium tabular-nums tracking-[-0.01em] text-neutral-900 dark:text-white"          >            {char}          </motion.span>        )}      </AnimatePresence>      {active && !filled && (        <motion.span          aria-hidden          initial={{ opacity: 1 }}          animate={reduce ? { opacity: 0.7 } : { opacity: [1, 1, 0, 0] }}          transition={            reduce              ? { duration: 0.2 }              : { duration: 1, repeat: Infinity, times: [0, 0.5, 0.5, 1], ease: 'linear' }          }          className="h-5 w-px rounded-full bg-[var(--otp-ring)]"        />      )}    </div>  );}

Usage

Example.tsx
import { OtpInput } from '@/components/ui/otp-input';

export default function Example() {
  const [value, setValue] = React.useState('');
  return (
    <OtpInput
      value={value}
      onChange={setValue}
      onComplete={(code) => console.log(code)}
    />
  );
}

Examples

Drive status from your check. On "error" the row shakes red, clear the value after the shake so the user can retry; on "success" it sweeps mint.

Two-factor

Hint: the code is 159847.

Set length for any number of slots and accent for the ring and digit color.

PIN

Enter your 4-digit PIN.

Props

PropTypeDefaultDescription
lengthnumber6Number of digit slots.
valuestring-Controlled value (digits only). Pair with onChange.
defaultValuestring""Uncontrolled starting value.
onChange(value: string) => void-Fired with the current digit string on every change.
onComplete(value: string) => void-Fired once the last slot fills, with the full code.
status'idle' | 'success' | 'error''idle'Drives the accent to mint/red swap and the shake.
accentstring"#a1a1aa"Active ring and filled-digit color.
disabledbooleanfalseDisables the whole control.
autoFocusbooleanfalseAuto-focus the field on mount.
aria-labelstring"Verification code"Accessible label for the field.
classNamestring-Forwarded to the wrapper.
On this page0%