Build a Robust React OTP Input Verification Component

Build an accessible, mobile-friendly React OTP verification component with great UX, paste handling, and security—complete with TypeScript code.

ASOasis
9 min read
Build a Robust React OTP Input Verification Component

Image used for representation purposes only.

Overview

One‑time passwords (OTPs) are a common second factor for signing in, confirming sensitive actions, or completing payments. A great OTP input component improves completion rates, reduces user errors, and stays accessible across devices. In this article, you’ll build a production‑grade React OTP input verification component with strong UX, robust keyboard/paste behavior, accessibility, and security considerations.

UX principles for OTP inputs

An effective OTP component should:

  • Keep users in flow: auto‑advance focus as digits are typed; backspace moves left.
  • Be forgiving: accept pastes with spaces or hyphens; ignore non‑digits for numeric codes.
  • Be mobile‑friendly: expose a numeric keypad with inputMode=“numeric” and pattern.
  • Be accessible: work with screen readers, keyboard only, and high‑contrast modes.
  • Communicate clearly: show error/valid states and provide helpful labels.
  • Respect privacy and security: don’t log codes, and consider masking based on risk.

Core requirements

We’ll target a 6‑digit numeric OTP, but make the component configurable:

  • length: number of characters (default 6)
  • numeric‑only vs alphanumeric
  • onChange and onComplete callbacks
  • disabled and error states
  • paste handling for multi‑character input
  • full keyboard support (arrows, home/end, backspace/delete)
  • accessibility via ARIA

Component API design

We’ll build a controlled component to keep state predictable in forms and to allow validation upstream.

Props:

  • value: string (controlled value)
  • onChange(value: string): void
  • onComplete?(value: string): void (fires when all slots are filled)
  • length?: number (default 6)
  • onlyDigits?: boolean (default true)
  • autoFocus?: boolean
  • disabled?: boolean
  • isInvalid?: boolean
  • mask?: boolean (use type=password)
  • className?: string (container)
  • inputClassName?: string (individual slots)
  • ariaLabel?: string (group label)

Implementation

Below is a TypeScript React component with robust behavior and accessibility.

// OTPInput.tsx
import React from 'react';

type OTPInputProps = {
  value: string;
  onChange: (value: string) => void;
  onComplete?: (value: string) => void;
  length?: number;
  onlyDigits?: boolean;
  autoFocus?: boolean;
  disabled?: boolean;
  isInvalid?: boolean;
  mask?: boolean;
  className?: string;
  inputClassName?: string;
  ariaLabel?: string;
};

export const OTPInput: React.FC<OTPInputProps> = ({
  value,
  onChange,
  onComplete,
  length = 6,
  onlyDigits = true,
  autoFocus,
  disabled,
  isInvalid,
  mask,
  className,
  inputClassName,
  ariaLabel = 'One-time code',
}) => {
  const inputsRef = React.useRef<Array<HTMLInputElement | null>>([]);

  // Normalize external value to desired length
  const normalized = (value ?? '').slice(0, length);

  const setAt = (i: number, ch: string) => {
    const arr = normalized.split('');
    arr[i] = ch;
    const next = arr.join('').padEnd(length, '');
    onChange(next);
  };

  const sanitize = (text: string) => {
    const trimmed = text.trim();
    if (onlyDigits) return trimmed.replace(/\D+/g, '');
    return trimmed.replace(/\s+/g, '');
  };

  const focusAt = (i: number) => {
    const el = inputsRef.current[i];
    if (el) el.focus();
  };

  const handleChange = (i: number) => (e: React.ChangeEvent<HTMLInputElement>) => {
    const raw = e.target.value;
    const clean = sanitize(raw);

    // Multi-character input (e.g., paste into a single box)
    if (clean.length > 1) {
      distributeFrom(i, clean);
      return;
    }

    const ch = clean.slice(0, 1);
    setAt(i, ch);

    if (ch && i < length - 1) {
      focusAt(i + 1);
    }
  };

  const distributeFrom = (start: number, text: string) => {
    const clean = sanitize(text).slice(0, length - start);
    if (!clean) return;

    const arr = normalized.split('');
    for (let k = 0; k < clean.length; k++) {
      arr[start + k] = clean[k];
    }
    const next = arr.join('').slice(0, length);
    onChange(next);

    const finalIndex = Math.min(start + clean.length, length - 1);
    focusAt(finalIndex);
  };

  const handleKeyDown = (i: number) => (e: React.KeyboardEvent<HTMLInputElement>) => {
    const key = e.key;

    if (key === 'Backspace') {
      if (!normalized[i]) {
        // move left if current empty
        if (i > 0) {
          setAt(i - 1, '');
          focusAt(i - 1);
        }
      } else {
        // clear current
        setAt(i, '');
      }
      e.preventDefault();
      return;
    }

    if (key === 'Delete') {
      setAt(i, '');
      e.preventDefault();
      return;
    }

    if (key === 'ArrowLeft') {
      if (i > 0) focusAt(i - 1);
      e.preventDefault();
      return;
    }

    if (key === 'ArrowRight') {
      if (i < length - 1) focusAt(i + 1);
      e.preventDefault();
      return;
    }

    if (key === 'Home') {
      focusAt(0);
      e.preventDefault();
      return;
    }

    if (key === 'End') {
      focusAt(length - 1);
      e.preventDefault();
      return;
    }
  };

  const handlePaste = (i: number) => async (e: React.ClipboardEvent<HTMLInputElement>) => {
    const text = e.clipboardData.getData('text');
    if (!text) return;
    e.preventDefault();
    distributeFrom(i, text);
  };

  React.useEffect(() => {
    const filled = normalized.length === length && !normalized.includes('');
    if (filled && onComplete) onComplete(normalized);
  }, [normalized, length, onComplete]);

  return (
    <div
      className={className}
      role="group"
      aria-label={ariaLabel}
      aria-invalid={isInvalid || undefined}
      aria-disabled={disabled || undefined}
    >
      {Array.from({ length }).map((_, i) => {
        const val = normalized[i] ?? '';
        return (
          <input
            key={i}
            ref={(el) => (inputsRef.current[i] = el)}
            type={mask ? 'password' : 'text'}
            inputMode={onlyDigits ? 'numeric' : 'text'}
            pattern={onlyDigits ? '[0-9]*' : undefined}
            autoComplete="one-time-code"
            name={i === 0 ? 'one-time-code' : undefined}
            aria-label={`Digit ${i + 1} of ${length}`}
            className={inputClassName}
            value={val}
            onChange={handleChange(i)}
            onKeyDown={handleKeyDown(i)}
            onPaste={handlePaste(i)}
            disabled={disabled}
            autoFocus={autoFocus && i === 0}
            maxLength={1}
            // Prevent mobile auto-correct
            autoCorrect="off"
            // Better enter key hint on mobile
            enterKeyHint={i === length - 1 ? 'done' : 'next'}
          />
        );
      })}
    </div>
  );
};

Notes:

  • We use type=“text” with inputMode=“numeric” to avoid number spinners and retain better control.
  • autoComplete=“one-time-code” and name=“one-time-code” help mobile systems (especially iOS) suggest codes from SMS.
  • The group role and per‑digit aria‑labels make the widget understandable to assistive tech.

Usage example: verify an OTP

// VerifyCodeForm.tsx
import React from 'react';
import { OTPInput } from './OTPInput';

export function VerifyCodeForm() {
  const [code, setCode] = React.useState(''.padEnd(6, ''));
  const [status, setStatus] = React.useState<'idle' | 'submitting' | 'error' | 'success'>('idle');
  const [error, setError] = React.useState<string | null>(null);
  const [timer, setTimer] = React.useState(60); // resend cooldown in seconds

  React.useEffect(() => {
    const id = setInterval(() => setTimer((t) => (t > 0 ? t - 1 : 0)), 1000);
    return () => clearInterval(id);
  }, []);

  async function submit() {
    setStatus('submitting');
    setError(null);
    try {
      // Replace with your API. Never log OTPs.
      const res = await fetch('/api/verify-otp', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        credentials: 'include', // send cookies/CSRF tokens when appropriate
        body: JSON.stringify({ code: code.replace(/\s/g, '') }),
      });
      if (!res.ok) throw new Error('Invalid or expired code');
      setStatus('success');
    } catch (e: any) {
      setStatus('error');
      setError(e.message);
    }
  }

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        submit();
      }}
      aria-describedby="otp-help"
    >
      <label htmlFor="otp-0" className="block mb-2">Enter the 6‑digit code</label>
      <OTPInput
        value={code}
        onChange={setCode}
        onComplete={() => submit()}
        isInvalid={status === 'error'}
        className="flex gap-2"
        inputClassName="w-12 h-12 text-center text-xl border rounded focus:outline-none focus:ring"
        autoFocus
      />
      <p id="otp-help" className="text-sm text-gray-500 mt-2">We sent a code to your email/phone. It expires in 5 minutes.</p>

      {status === 'error' && <div role="alert" className="text-red-600 mt-2">{error}</div>}

      <button type="submit" disabled={status === 'submitting'} className="btn btn-primary mt-4">
        {status === 'submitting' ? 'Verifying…' : 'Verify'}
      </button>

      <button
        type="button"
        className="btn btn-link mt-2"
        onClick={() => setTimer(60)}
        disabled={timer > 0}
        aria-disabled={timer > 0}
      >
        {timer > 0 ? `Resend in ${timer}s` : 'Resend code'}
      </button>
    </form>
  );
}

Styling tips

  • Use monospaced digits (font-variant-numeric: tabular-nums) to avoid jitter.
  • Provide clear focus states with a visible outline or ring.
  • For error states, use both color and shape (e.g., red border plus icon) for accessibility.
  • Respect user preference: match prefers-color-scheme for dark mode.

Example minimal CSS:

.otp-slot { width: 3rem; height: 3rem; text-align: center; font-size: 1.25rem; }
.otp-slot:focus { outline: 2px solid #2563eb; outline-offset: 2px; }
.otp-slot[aria-invalid="true"] { border-color: #dc2626; }

Accessibility checklist

  • Group container has role=“group” and an informative aria-label.
  • Each input has aria-label like “Digit 1 of 6”.
  • Keyboard: arrow keys navigate; backspace/delete behave as expected.
  • High contrast: borders and focus rings pass contrast guidelines.
  • Error messaging uses role=“alert” and is programmatically associated.

Security and privacy considerations

  • Never log or analytics‑capture the OTP value.
  • Apply rate limiting, lockouts, and short TTLs server‑side.
  • Consider masking digits (mask=true) in high‑risk contexts; otherwise, keep them visible to reduce user errors.
  • Clear the code and inputs on sign‑out or after verification attempts exceed a threshold.
  • Use HTTPS everywhere and include CSRF protections for the verification endpoint.

Edge cases and hardening

  • Pasting formats: Users may paste “123 456” or “123‑456”; sanitize to digits.
  • Composition events (IME): For numeric OTPs this is rare, but avoid interfering with composition by keeping input type=“text”.
  • RTL support: When localizing, ensure visual order and aria labels reflect the correct reading direction.
  • Mixed input devices: Tab order must remain natural; maxLength=1 avoids multi‑character overflow.
  • Disabled state: Ensure aria-disabled and disabled are in sync, and styles communicate the state.

Testing strategy

  • Unit tests: sanitize(), distributeFrom(), keyboard handlers, onComplete firing.
  • Component tests: focus movement on input/backspace/arrow keys; paste behaviors; disabled and error states.
  • Accessibility tests: role and aria attributes; focus trapping not occurring; screen reader labels present.
  • Cross‑browser: iOS Safari (numeric keypad, one‑time‑code suggestion), Android Chrome, desktop browsers.

Advanced: WebOTP API (progressive enhancement)

If you control the SMS format, Chrome on Android can auto‑read the OTP from a specially formatted SMS via the WebOTP API. Use it as a progressive enhancement and always provide a manual path.

// Hook to try WebOTP when supported
export function useWebOTP(enabled: boolean, onFill: (code: string) => void) {
  React.useEffect(() => {
    if (!enabled || typeof window === 'undefined') return;
    // @ts-ignore: WebOTP types are not in lib.dom.d.ts everywhere
    if (!('OTPCredential' in window) || !navigator.credentials) return;

    const ac = new AbortController();
    (async () => {
      try {
        // @ts-ignore
        const cred: any = await navigator.credentials.get({
          otp: { transport: ['sms'] },
          signal: ac.signal,
        });
        if (cred && cred.code) onFill(String(cred.code));
      } catch {
        // silently ignore (permission denied, timeout, etc.)
      }
    })();

    return () => ac.abort();
  }, [enabled, onFill]);
}

Use it in your form:

const [code, setCode] = React.useState(''.padEnd(6, ''));
useWebOTP(true, (c) => setCode(c.padEnd(6, '')));

Important: Always include name=“one-time-code” on one of the inputs and keep manual entry available.

Integrating with form libraries

  • React Hook Form: Register a hidden input bound to the OTP string, or use Controller to wrap OTPInput.
  • Formik: Keep OTP value in Formik state and pass formik.values.otp / setFieldValue.

Example (React Hook Form):

import { useForm, Controller } from 'react-hook-form';

function OtpWithRHF() {
  const { control, handleSubmit } = useForm({ defaultValues: { otp: ''.padEnd(6, '') } });
  return (
    <form onSubmit={handleSubmit((d) => console.log('submit', d))}>
      <Controller
        control={control}
        name="otp"
        render={({ field, fieldState }) => (
          <OTPInput
            value={field.value}
            onChange={field.onChange}
            onComplete={() => { /* optionally submit */ }}
            isInvalid={!!fieldState.error}
            className="flex gap-2"
            inputClassName="otp-slot border rounded"
          />
        )}
      />
      <button type="submit">Verify</button>
    </form>
  );
}

Performance notes

  • The component renders a small fixed number of inputs (typically 4–8), so controlled mode is perfectly fine.
  • Avoid unnecessary re‑creation of handlers by memoizing if profiling shows hotspots; the sample above is usually sufficient.

Final checklist

  • Auto‑advance, backspace, arrow key navigation
  • Paste distribution with sanitization
  • Numeric keypad on mobile
  • Accessible labels and error messaging
  • onComplete fires when filled
  • Secure handling of OTP (no logging)
  • Optional WebOTP enhancement

Conclusion

With clear UX rules, careful event handling, and accessibility baked in, your React OTP input can be both delightful and dependable. Use the component above as a foundation, then adapt styling, masking, and length to your product’s needs. The small details—like forgiving paste behavior and crisp focus management—make a big difference in verification success rates and overall trust.

Related Posts