Free kit: 3 animated pieces + 8 AI prompts → https://qodesign.dev/free?utm_source=devto&utm_medium=tutorial&utm_campaign=otp_input&utm_content=top
Six boxes for a one-time code look nice, and most of them are annoying to use. Paste goes into the first box only. Backspace deletes nothing. Tapping box four of an empty code leaves you typing into box four. And when the phone offers the code from Messages, where the digits end up depends on how the inputs were set up.
People notice because they use the native version of this every day. If an AI tool wrote your OTP screen, run it against the checklist near the end. Here is how the code cells in our Step-Up OTP Gate behave like one field, with excerpts from the shipped React component (StepUpGate.tsx) and a single-file version at the end.
The rules
- One source of truth: a string of digits. The six inputs only display it.
- Every input path (typing, paste, iOS autofill) goes through that string.
- No gaps. Focus always lands on the first empty cell.
- Backspace on an empty cell steps back.
- Typing into a filled cell replaces its digit.
- When the string is full, submit. Wrong code: shake, clear, back to cell 1.
1. The input attributes
<input
className="cell"
inputMode="numeric"
pattern="[0-9]*"
maxLength={1}
autoComplete={i ? 'off' : 'one-time-code'}
aria-label={`Digit ${i + 1} of ${length}`}
/>
-
inputMode="numeric"opens the number pad on phones. Leavetypeas text:type="number"adds spinner arrows, acceptse, and is built for quantities, not codes. -
pattern="[0-9]*"is a hint some older iOS versions used to pick the number pad. -
autoComplete="one-time-code"is the attribute that makes the phone offer the code from an SMS above the keyboard. The component puts it on the first cell only, since that's where the code starts; the other five areoff. - Each cell gets its own label, and the wrapper is
role="group"witharia-label="6-digit code", so a screen reader announces the group and then "Digit 1 of 6".
2. One string, many ways in
The component keeps the digits in a ref (typed.current), not in the inputs. Typing goes through onInput:
const onInput = (i: number, v: string) => {
const d = v.replace(/\D/g, '');
if (d.length > 1) typed.current = (typed.current.slice(0, i) + d).slice(0, length);
else { const a = typed.current.split(''); a[i] = d; typed.current = a.join('').slice(0, length); }
setMsg('');
if (typed.current.length === length && autoSubmit) submit(); else focusCell(typed.current.length);
apply(t.get());
};
The d.length > 1 branch is the important one. Autofill, some Android keyboards and drag-and-drop can deliver several digits in one input event, and maxLength isn't something to rely on for those. The branch takes all of them and spreads them from that cell onwards, so the rest of the boxes fill in.
Paste gets its own handler, because a pasted code often carries spaces or dashes ("482 913", "482-913"):
onPaste={(e) => {
const v = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, length);
if (v) { e.preventDefault(); typed.current = v; apply(t.get());
if (v.length === length && autoSubmit) submit(); else focusCell(v.length); }
}}
The handler strips everything that isn't a digit, takes the first six, and calls preventDefault so the browser doesn't also put the raw text into the cell. It works from any cell, since a paste replaces the whole code.
3. Typing over a filled cell
With maxLength={1}, typing into a cell that already holds a digit does nothing. The browser refuses the second character. People expect the new digit to replace the old one, so the component clears the cell just before the input lands:
onBeforeInput={(e) => {
// a digit typed into a filled cell replaces it (maxLength 1 would otherwise swallow the key)
const c = e.currentTarget, ne = e.nativeEvent as InputEvent;
if (ne.inputType === 'insertText' && c.value && c.selectionStart === c.selectionEnd) c.value = '';
}}
The selectionStart === selectionEnd check leaves the case where the digit is selected to the browser, which already replaces a selection.
4. Focus and backspace
Two small handlers do most of the "feels native" work:
onFocus={(e) => { if (i > typed.current.length) focusCell(typed.current.length); else e.currentTarget.select(); }}
onKeyDown={(e) => {
if (e.key === 'Backspace' && !e.currentTarget.value && i > 0) {
typed.current = typed.current.slice(0, i - 1); apply(t.get()); focusCell(i - 1); e.preventDefault();
}
if (e.key === 'ArrowLeft') focusCell(i - 1);
if (e.key === 'ArrowRight') focusCell(i + 1);
}}
- No gaps: if you tap cell 5 while only two digits are filled, focus jumps to cell 3. The code can never be "48__13".
- Select on focus: tapping a filled cell selects its digit, so the next key replaces it.
- Backspace on an empty cell removes the previous digit and moves there. Holding backspace clears the whole code, the way it does in one text field.
Add ::selection { background: transparent } on the cells, or every focus flashes a blue highlight.
5. Waiting for the server
Once the sixth digit lands, submit runs. A spinning arc goes around each cell while the code is checked. The component waits for both the server and a minimum animation time:
play(K.spin0, K.slide0); // the arcs spin while the code is checked
const wait = new Promise((res) => setTimeout(res, (K.slide0 - K.spin0) * 1000));
const [ok] = await Promise.all([
Promise.resolve(onVerify ? onVerify(typed.current) : typed.current === autofillCode),
wait,
]);
Promise.all with a timer means a fast server doesn't make the success state flicker in after 40 ms, and a slow one keeps the arcs spinning until it answers.
The arc is a conic gradient cut down to a ring with a mask:
/* the verifying arc: a conic gradient clipped to the cell's border ring, spun from JS */
.arc {
position: absolute; z-index: 2; inset: -3px; border-radius: 25px; padding: 4px;
opacity: 0; pointer-events: none;
background: conic-gradient(from var(--a, 0deg),
transparent 0deg, transparent 200deg, rgba(198,244,50,.15) 240deg,
var(--volt) 315deg, var(--arc-hot) 345deg, transparent 360deg);
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0);
}
How the mask works: two solid layers, one the size of the content box and one the full box. exclude keeps only the part where they don't overlap, which is the padding ring. So padding: 4px is the ring's thickness. The gradient goes from transparent to lime to a hot near-white tip, so it reads as a light running around the border.
JS spins it by writing the custom property --a each frame, starting each cell 70 ms after the one before:
const s0 = K.spin0 + i * K.spinStep; // spinStep: 0.07 s
const ang = ((tt - s0) * 420 + i * 25) % 360; // 420 degrees per second
a.style.setProperty('--a', ang.toFixed(1) + 'deg');
A second copy with filter: blur(7px) underneath gives it a glow.
6. The shake that replays
On a wrong code the cells shake and turn orange-red. The trap: a CSS animation on a class only plays when the class is added. Enter two wrong codes in a row and the second one doesn't shake, because the class never left.
} else {
t.set(0.2);
setErr(false); requestAnimationFrame(() => setErr(true));
setMsg('That code didn’t match. Try again.');
setTimeout(() => { setErr(false); typed.current = ''; cellRefs.current.forEach((c) => { if (c) c.value = ''; }); apply(t.get()); focusCell(0); }, 700);
}
Remove the class, add it back on the next frame, and the animation restarts every time. After 700 ms the code clears and focus goes back to the first cell, ready for the next try.
@keyframes shake { 0%,100%{transform:translateX(0)} 20%{transform:translateX(-8px)}
40%{transform:translateX(7px)} 60%{transform:translateX(-5px)} 80%{transform:translateX(3px)} }
.cells.err { animation: shake .42s var(--ease) }
@media (prefers-reduced-motion: reduce) { .cells.err { animation: none } }
Each swing is smaller than the one before (8, 7, 5, 3 px), so it settles instead of buzzing. With Reduce Motion on there's no shake. The red border and the message still say what happened, and the message sits in an aria-live="assertive" region so screen readers announce it.
The single-file version
Plain HTML, CSS and JS, no build step, MIT. Same rules, same arc, same shake. The demo code is 482913; anything else shows the error:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>OTP input: paste, autofill, shake on error</title>
<!-- MIT licence. Teaching version of the code cells from qodesign's Step-Up OTP Gate (qodesign.dev). -->
<style>
:root { --ink:#0A0A0D; --panel:#111218; --line:#262A36; --text:#F4F4F6; --muted:#8A8FA3; --lime:#C6FF3D; --error:#FF6A3D }
* { box-sizing:border-box; margin:0 }
body { min-height:100vh; display:grid; place-items:center; background:var(--ink); color:var(--text); font-family:system-ui,sans-serif; padding:16px }
.card { width:min(440px,100%); padding:28px 24px; border-radius:28px; background:var(--panel); border:1px solid var(--line); text-align:center }
h1 { font-size:1.375rem; margin-bottom:6px } p { color:var(--muted); font-size:.9375rem }
.cells { display:flex; justify-content:center; gap:10px; margin:24px 0 8px }
.cw { position:relative }
.cell { width:52px; height:64px; border-radius:18px; border:1px solid var(--line); background:#0d0e13; color:var(--text);
font:600 26px ui-monospace,monospace; text-align:center; caret-color:transparent; outline:none; transition:border-color .15s, box-shadow .15s }
.cell:focus { border-color:var(--lime); box-shadow:0 0 0 4px rgba(198,255,61,.15) }
.cell::selection { background:transparent }
/* the verifying arc: a conic gradient clipped to the border ring, spun from JS through --a */
.arc { position:absolute; inset:-3px; border-radius:21px; padding:3px; opacity:0; pointer-events:none;
background:conic-gradient(from var(--a,0deg), transparent 0deg 200deg, rgba(198,255,61,.15) 240deg, var(--lime) 315deg, #F2FFC8 345deg, transparent 360deg);
-webkit-mask:linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); -webkit-mask-composite:xor;
mask:linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0) }
.cells.err .cell { border-color:var(--error); box-shadow:0 0 0 5px rgba(255,106,61,.16) }
@keyframes shake { 0%,100%{transform:translateX(0)} 20%{transform:translateX(-8px)} 40%{transform:translateX(7px)} 60%{transform:translateX(-5px)} 80%{transform:translateX(3px)} }
.cells.err { animation:shake .42s cubic-bezier(.2,.8,.2,1) }
.cells.ok .cell { border-color:var(--lime); color:var(--lime) }
@media (prefers-reduced-motion: reduce) { .cells.err { animation:none } }
.msg { min-height:20px; font-size:.875rem; color:var(--error) } .msg.ok { color:var(--lime) }
.hint { margin-top:16px; font-size:.8125rem; color:var(--muted) } .hint b { font-family:ui-monospace,monospace; color:var(--text) }
@media (max-width:400px) { .cells { gap:6px } .cell { width:44px; height:56px; font-size:22px } }
</style>
</head>
<body>
<form class="card" id="form" autocomplete="on">
<h1 id="t">Enter the code</h1>
<p id="d">We sent 6 digits to your phone.</p>
<div class="cells" id="cells" role="group" aria-labelledby="t" aria-describedby="d"></div>
<div class="msg" id="msg" aria-live="assertive"></div>
<p class="hint">Demo: paste or type <b>482913</b>. Anything else shows the error.</p>
</form>
<script>
const LEN = 6, CODE = '482913';
const cellsEl = document.getElementById('cells'), msg = document.getElementById('msg');
const reduce = matchMedia('(prefers-reduced-motion: reduce)');
let typed = '', busy = false;
cellsEl.innerHTML = Array.from({ length: LEN }, (_, i) =>
`<span class="cw"><input class="cell" inputmode="numeric" pattern="[0-9]*" maxlength="1"
autocomplete="${i ? 'off' : 'one-time-code'}" aria-label="Digit ${i + 1} of ${LEN}"><span class="arc" aria-hidden="true"></span></span>`).join('');
const cells = [...cellsEl.querySelectorAll('.cell')], arcs = [...cellsEl.querySelectorAll('.arc')];
const focusCell = (i) => cells[Math.max(0, Math.min(LEN - 1, i))].focus({ preventScroll: true });
const render = () => cells.forEach((c, i) => { c.value = typed[i] || ''; });
function setCode(next) { // one place that owns the code
typed = next.replace(/\D/g, '').slice(0, LEN);
render(); msg.textContent = '';
if (typed.length === LEN) submit(); else focusCell(typed.length);
}
cells.forEach((cell, i) => {
// a digit typed into a filled cell replaces it (maxlength=1 would swallow the key)
cell.addEventListener('beforeinput', (e) => {
if (e.inputType === 'insertText' && cell.value && cell.selectionStart === cell.selectionEnd) cell.value = '';
});
cell.addEventListener('input', () => {
const d = cell.value.replace(/\D/g, '');
if (d.length > 1) return setCode(typed.slice(0, i) + d); // iOS autofill drops all 6 digits in cell 0
const a = typed.split(''); a[i] = d; setCode(a.join(''));
});
cell.addEventListener('paste', (e) => {
const v = e.clipboardData.getData('text').replace(/\D/g, '');
if (v) { e.preventDefault(); setCode(v); }
});
cell.addEventListener('keydown', (e) => {
if (e.key === 'Backspace' && !cell.value && i > 0) { e.preventDefault(); typed = typed.slice(0, i - 1); render(); focusCell(i - 1); }
if (e.key === 'ArrowLeft') focusCell(i - 1);
if (e.key === 'ArrowRight') focusCell(i + 1);
});
// no gaps: clicking cell 5 of an empty code puts you in cell 1
cell.addEventListener('focus', () => { if (i > typed.length) focusCell(typed.length); else cell.select(); });
});
function spinArcs(ms) { // one arc per cell, each starting a little later
return new Promise((done) => {
const t0 = performance.now();
(function tick(now) {
const t = (now - t0) / 1000;
arcs.forEach((a, i) => {
const local = t - i * 0.08;
a.style.opacity = local > 0 ? '1' : '0';
a.style.setProperty('--a', (reduce.matches ? 0 : local * 420) + 'deg');
});
if (now - t0 < ms) requestAnimationFrame(tick);
else { arcs.forEach((a) => (a.style.opacity = '0')); done(); }
})(t0);
});
}
const verify = (code) => new Promise((r) => setTimeout(() => r(code === CODE), 300)); // your server call
async function submit() {
if (busy) return; busy = true;
document.activeElement.blur();
const [ok] = await Promise.all([verify(typed), spinArcs(1100)]); // never shorter than the animation
busy = false;
if (ok) {
cellsEl.classList.add('ok'); msg.className = 'msg ok'; msg.textContent = 'Verified.';
setTimeout(() => { cellsEl.classList.remove('ok'); msg.className = 'msg'; setCode(''); }, 2400);
return;
}
cellsEl.classList.remove('err');
requestAnimationFrame(() => cellsEl.classList.add('err')); // re-add on the next frame so the shake replays
msg.textContent = 'That code didn\u2019t match. Try again.';
setTimeout(() => { cellsEl.classList.remove('err'); typed = ''; render(); focusCell(0); }, 700);
}
document.getElementById('form').addEventListener('submit', (e) => e.preventDefault());
focusCell(0);
</script>
</body>
</html>
In the demo all three ways in (typing, paste and autofill) end up in one function:
function setCode(next) { // one place that owns the code
typed = next.replace(/\D/g, '').slice(0, LEN);
render(); msg.textContent = '';
if (typed.length === LEN) submit(); else focusCell(typed.length);
}
The inputs only display the code. setCode is the one place that changes it.
Testing it
A checklist worth running before you ship:
- Paste "482 913" and "482-913" into cell 1, and again into cell 4.
- Type four digits, tap cell 2, type a new digit. Only cell 2 should change.
- Tap cell 6 with an empty code. You should land in cell 1.
- Hold backspace from a full code. Everything clears and focus ends in cell 1.
- Enter a wrong code twice in a row. It should shake both times.
- Turn on Reduce Motion. No shake, but the red border and the message still show.
- With VoiceOver or TalkBack: the group is announced, each cell says its position, and the error is read out.
- On a real phone, send yourself an SMS with a code and check that the suggestion fills all six cells.
Optional: Android Web OTP
The component doesn't do this, but on Chrome for Android you can read the code from the SMS automatically. The SMS's last line has to be @your-domain.com #482913:
if ('OTPCredential' in window) {
const ac = new AbortController();
navigator.credentials.get({ otp: { transport: ['sms'] }, signal: ac.signal })
.then((otp) => otp && setCode(otp.code))
.catch(() => {});
}
Feed the result into the same setCode, and it behaves like a paste.
About me, and the links. I'm Teodor, and I make animated UI components at qodesign. The OTP cells in this post come from one of them, so this post is partly about my own product. The demo above is MIT: take it. The full Step-Up OTP Gate adds the six cells merging into one and becoming a check mark, the blurred dashboard that unlocks behind it, the resend countdown, a React + TypeScript + Tailwind + Framer Motion version and a PROMPT.md for AI coding tools. You can get it on qodesign.dev. Free components are MIT in github.com/teodoravadani/qodesign-free.
Using Cursor or Claude Code? Run npx skills add teodoravadani/qodesign-free once in your project. It adds the repo's agent skill, so your agent knows how to install the free pieces and follows the same motion rules.
What else do your OTP inputs get wrong? I'm collecting the edge cases.
I wrote this post with help from an AI assistant. The code excerpts are copied from the shipped component, and the demo was run and checked in a browser before publishing.
Top comments (0)