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

  1. One source of truth: a string of digits. The six inputs only display it.
  2. Every input path (typing, paste, iOS autofill) goes through that string.
  3. No gaps. Focus always lands on the first empty cell.
  4. Backspace on an empty cell steps back.
  5. Typing into a filled cell replaces its digit.
  6. 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. Leave type as text: type="number" adds spinner arrows, accepts e, 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 are off.
  • Each cell gets its own label, and the wrapper is role="group" with aria-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' : 'Source:https://dev.to/teodoravadani/an-otp-input-that-feels-native-autofill-paste-across-six-boxes-and-a-shake-on-error-3noj

Related posts