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'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>
</>
);
}