Skip to content
Plinth

Original by Plinth · MIT

Forms

OTP Input

Six boxes that advance as you type, walk backwards on backspace, and take a pasted code whole.

#otp#input#form#verification#keyboard

Install

npx shadcn@latest add https://plinthui.com/r/otp-input.json
Open in v0
'use client';

import { useRef, useState } from 'react';

const css = `
.otp__set { margin: 0; padding: 0; border: 0; }
.otp__legend {
  padding: 0 0 12px;
  font: 500 12px/1 ui-sans-serif, system-ui, sans-serif;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.42);
}

.otp__boxes { display: flex; gap: 9px; }

.otp__box {
  width: 44px;
  height: 54px;
  padding: 0;
  border: 1px solid rgba(255, 255, 255, 0.16);
  border-radius: 11px;
  background: rgba(255, 255, 255, 0.05);
  color: #f3f0e9;
  font: 600 22px/1 ui-sans-serif, system-ui, sans-serif;
  text-align: center;
  /* Tabular, so a 1 and a 7 occupy the same width and the row does not twitch
     as it fills. */
  font-variant-numeric: tabular-nums;
  caret-color: #ffca7a;
  transition: border-color 160ms ease, background-color 160ms ease, transform 160ms ease;
}
.otp__box:focus {
  outline: none;
  border-color: #ffca7a;
  background: rgba(255, 202, 122, 0.1);
  transform: translateY(-2px);
}
.otp__box[data-filled="1"] { border-color: rgba(255, 255, 255, 0.34); }

.otp[data-complete="1"] .otp__box {
  border-color: #7ad9a1;
  background: rgba(122, 217, 161, 0.12);
}

.otp__status {
  margin: 14px 0 0;
  min-height: 1em;
  font: 500 13px/1 ui-sans-serif, system-ui, sans-serif;
  color: #7ad9a1;
}

@media (prefers-reduced-motion: reduce) {
  .otp__box { transition: none; }
  .otp__box:focus { transform: none; }
}
`;

export function OtpInput({
  length = 6,
  onComplete,
}: {
  length?: number;
  onComplete?: (code: string) => void;
}) {
  const [digits, setDigits] = useState<string[]>(() => Array(length).fill(''));
  const boxes = useRef<(HTMLInputElement | null)[]>([]);

  const complete = digits.every(Boolean);

  function commit(next: string[], focusIndex: number) {
    setDigits(next);
    boxes.current[Math.min(focusIndex, length - 1)]?.focus();
    if (next.every(Boolean)) onComplete?.(next.join(''));
  }

  return (
    <>
      <style>{css}</style>
      <form
        className="otp"
        autoComplete="off"
        data-complete={complete ? '1' : undefined}
        onSubmit={e => e.preventDefault()}
      >
        <fieldset className="otp__set">
          <legend className="otp__legend">Verification code</legend>
          <div className="otp__boxes">
            {digits.map((digit, i) => (
              <input
                key={i}
                ref={el => {
                  boxes.current[i] = el;
                }}
                className="otp__box"
                // Not type="number": that brings spinners, accepts "e" and "-",
                // and reports an empty value for partial input.
                inputMode="numeric"
                autoComplete={i === 0 ? 'one-time-code' : undefined}
                maxLength={1}
                aria-label={`Digit ${i + 1} of ${length}`}
                data-filled={digit ? '1' : undefined}
                value={digit}
                onFocus={e => e.currentTarget.select()}
                onChange={e => {
                  const v = e.target.value.replace(/\D/g, '').slice(0, 1);
                  const next = [...digits];
                  next[i] = v;
                  commit(next, v ? i + 1 : i);
                }}
                onKeyDown={e => {
                  if (e.key === 'Backspace' && !digits[i] && i > 0) {
                    // Step back and clear, so holding backspace walks the row
                    // instead of stalling on an already-empty box.
                    e.preventDefault();
                    const next = [...digits];
                    next[i - 1] = '';
                    commit(next, i - 1);
                  }
                  if (e.key === 'ArrowLeft' && i > 0) {
                    e.preventDefault();
                    boxes.current[i - 1]?.focus();
                  }
                  if (e.key === 'ArrowRight' && i < length - 1) {
                    e.preventDefault();
                    boxes.current[i + 1]?.focus();
                  }
                }}
                onPaste={e => {
                  // Pasting the whole code into any box has to fill the row —
                  // it is how people actually enter these, from a message.
                  e.preventDefault();
                  const chars = e.clipboardData.getData('text').replace(/\D/g, '').split('');
                  if (!chars.length) return;
                  const next = [...digits];
                  chars.forEach((c, n) => {
                    if (i + n < length) next[i + n] = c;
                  });
                  commit(next, i + chars.length);
                }}
              />
            ))}
          </div>
        </fieldset>
        <p className="otp__status" role="status" aria-live="polite">
          {complete ? 'Code complete' : ''}
        </p>
      </form>
    </>
  );
}

More in Forms

Similar tags