← All components

CodeInput

A one-time code as a row of boxes that paste, autofill, and shake once when wrong.

Verification code

The idea

Typing moves on, Backspace in an empty box goes back and clears it, the arrows move without clearing, and a pasted run of characters fills from where you pasted — which is also what arrives when a phone autofills a code from a text message, since the first box asks for one-time-code. A wrong code shakes the row once, sideways, the way a login refuses you, and the message is a live region so it is heard as well as seen.

Install

terminal1 line
npm i mihirmodi-ui motion

The tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.

Usage

CodeInput — usage14 lines
import { CodeInput } from "mihirmodi-ui";

const [error, setError] = useState<string | null>(null);

<CodeInput
  length={6}
  groupAfter={3}
  error={error}
  onChange={() => setError(null)}
  onComplete={async (code) => {
    const ok = await verify(code);
    if (!ok) setError("That code didn't match.");
  }}
/>

Props

PropNotes
lengthnumberHow many boxes.
onComplete(code) => voidOnce every box is filled.
groupAfternumberA dash after this many boxes — 3 for a code read as 123–456.
mode"numeric" | "alphanumeric"What a box accepts. Letters are upper-cased.
errorstring | nullShake and show this. Change the string to shake again.

Source

Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.

packages/ui/src/components/inputs/CodeInput.tsx134 lines
"use client";

import { Fragment, useRef, useState } from "react";
import { LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";

export type CodeInputProps = {
  /** How many characters. */
  length?: number;
  /** Called once every box is filled. */
  onComplete?: (code: string) => void;
  onChange?: (code: string) => void;
  /** Insert a dash after this many boxes — 3 for a code read as "123-456". */
  groupAfter?: number;
  /** Digits only, or letters and digits. */
  mode?: "numeric" | "alphanumeric";
  disabled?: boolean;
  /** Shake once and mark the boxes, when the code was wrong. Change it to shake again. */
  error?: string | null;
  label?: string;
  autoFocus?: boolean;
};

/**
 * A one-time code as a row of boxes, one character each.
 *
 * Typing moves on; Backspace in an empty box goes back and clears it; the arrow
 * keys move without clearing; and a pasted run of characters fills from the box
 * you pasted into — which is also what arrives when the phone autofills a code
 * from a text message, since the first box carries `autocomplete="one-time-code"`.
 * A wrong code shakes the row once, sideways, the way a login field refuses you,
 * and the message under it is a live region so it is heard as well as seen.
 */
export function CodeInput({
  length = 6,
  onComplete,
  onChange,
  groupAfter,
  mode = "numeric",
  disabled,
  error,
  label = "Verification code",
  autoFocus,
}: CodeInputProps) {
  const reduce = useReducedMotion();
  const [chars, setChars] = useState<string[]>(() => Array(length).fill(""));
  const refs = useRef<(HTMLInputElement | null)[]>([]);
  const clean = (v: string) => (mode === "numeric" ? v.replace(/\D/g, "") : v.replace(/[^a-z0-9]/gi, "").toUpperCase());

  const commit = (next: string[]) => {
    setChars(next);
    const code = next.join("");
    onChange?.(code);
    if (code.length === length) onComplete?.(code);
  };

  const onInput = (i: number, raw: string) => {
    const v = clean(raw);
    const next = [...chars];
    if (!v) {
      next[i] = "";
      return commit(next);
    }
    // A paste, or an autofilled code: spread from this box onward.
    v.split("").slice(0, length - i).forEach((c, k) => (next[i + k] = c));
    commit(next);
    refs.current[Math.min(length - 1, i + v.length)]?.focus();
  };

  const onKey = (i: number, e: React.KeyboardEvent<HTMLInputElement>) => {
    if (e.key === "Backspace" && !chars[i] && i > 0) {
      e.preventDefault();
      const next = [...chars];
      next[i - 1] = "";
      commit(next);
      refs.current[i - 1]?.focus();
    } else if (e.key === "ArrowLeft" && i > 0) {
      e.preventDefault();
      refs.current[i - 1]?.focus();
    } else if (e.key === "ArrowRight" && i < length - 1) {
      e.preventDefault();
      refs.current[i + 1]?.focus();
    }
  };

  return (
    <LazyMotion features={domAnimation} strict>
      <fieldset aria-describedby={error ? "code-error" : undefined}>
        <legend className="sr-only">{label}</legend>
        <m.div
          key={error ?? "ok"}
          className="flex items-center justify-center gap-2"
          initial={false}
          animate={error && !reduce ? { x: [0, -7, 6, -4, 3, 0] } : { x: 0 }}
          transition={{ duration: 0.38, ease: "easeOut" }}
        >
          {chars.map((c, i) => (
            <Fragment key={i}>
              {groupAfter && i === groupAfter && (
                <span aria-hidden className="w-3 text-center text-[20px] text-faint">
                  –
                </span>
              )}
              <input
                ref={(el) => {
                  refs.current[i] = el;
                }}
                inputMode={mode === "numeric" ? "numeric" : "text"}
                autoComplete={i === 0 ? "one-time-code" : "off"}
                aria-label={`Character ${i + 1} of ${length}`}
                value={c}
                maxLength={length}
                disabled={disabled}
                autoFocus={autoFocus && i === 0}
                onChange={(e) => onInput(i, e.target.value)}
                onKeyDown={(e) => onKey(i, e)}
                onFocus={(e) => e.target.select()}
                className={`h-12 w-11 rounded-[10px] border-[0.5px] bg-background-100 text-center text-[20px] font-medium tabular-nums text-gray-1000 outline-none transition-[border-color,box-shadow] duration-150 ease-geist focus:border-gray-alpha-600 focus:shadow-[0_0_0_3px_var(--color-gray-alpha-200)] disabled:opacity-50 ${
                  error ? "border-gray-1000" : c ? "border-gray-alpha-600" : "border-gray-alpha-400"
                }`}
              />
            </Fragment>
          ))}
        </m.div>
        <div className="mt-2 min-h-[20px] text-center" aria-live="polite">
          {error && (
            <p id="code-error" className="text-[13px] text-gray-900">
              {error}
            </p>
          )}
        </div>
      </fieldset>
    </LazyMotion>
  );
}