By the end of this you'll be able to delete a few hundred lines of JavaScript, replace them with one HTML attribute, and know what those lines were breaking for everyone who wasn't you. It starts with coffee.
I go to buy green coffee from Sweet Maria's. They don't do passwords. They email me a magic code and I type it in. It arrives looking something like this:
I copy the code and go back to the tab, where I'm supposed to paste it into this. Somebody did a lot of work to an input field to get six little slots:
One digit landed and five vanished. Backspace walked me back a box and left the
digit behind. The lamp never went green. And on my phone, the keyboard never
offered me the code, because every box has autocomplete="off".
That's how the popular libraries ship it.
That's a six-digit code to buy coffee, and it fails a paste. So what did they build?
- It looks like a code. Six slots, six digits. Nobody wonders what to type.
- Auto-advance feels fast when you type by hand.
- It reads as serious, which is why every auth screen has copied it.
- Paste. Backspace. Autofill. The screen reader. The password manager.
- The server, which now reads a hidden field a script has to keep honest.
- Every one of those was working before the boxes showed up.
How it's built
Six puppets over one hidden field, held together by event handlers. Here it is, and here's the shape of the code.
Almost every one of these works the same way: a hidden "shadow" field holds the real value, and the six visible boxes are puppets. JavaScript watches every keystroke, joins the boxes into the shadow, and shuttles focus around to fake a single field. It's the shape of the code you're signing up for:
// The usual shape. Do not ship this. Every line is a place backspace can break.
boxes.forEach((box, i) => {
box.addEventListener("input", () => {
shadow.value = boxes.map(b => b.value).join("");
if (box.value && boxes[i + 1]) boxes[i + 1].focus();
});
box.addEventListener("keydown", (e) => {
if (e.key === "Backspace" && !box.value && boxes[i - 1]) boxes[i - 1].focus();
});
box.addEventListener("paste", (e) => { /* split the clipboard, distribute, pray */ });
});
Backspace is where it always goes wrong, because the browser's backspace only
knows about the box it's in. Moving back, deleting the previous digit, or doing both all has to be
re-invented in that keydown handler. Get it
slightly wrong and you land in the box before but don't delete, or delete but
don't move, or jump two. Every implementation is slightly wrong in a different
way, and nobody tests it on a phone with autocorrect on.
None of that code exists in the fix.
That sketch is the shape of the most-downloaded implementation on the internet.
The most popular library builds it this way
react-otp-input: one input per digit, 274 lines, and autoComplete: 'off' hardcoded on every box.
react-otp-input is the canonical React version, at hundreds of thousands of downloads a week. Go break it yourself on the live demo:
- Paste your code. Watch how it has to be split up and distributed.
- Type two digits, press Backspace twice. Note which box you land in.
- Open it on your phone and get a real code texted to you. Wait for the keyboard to offer it. It won't.
Step 3 isn't a bug they'll fix. Read the source:
one <input> per digit, Backspace and arrow keys re-implemented in a keydown
handler, paste re-implemented in a paste handler, and every box rendered with
autoComplete: 'off'. The library that people reach for to build this
deliberately disables the one browser feature that makes a login code fast on
a phone, because with six fields there was nothing else it could do.
It's a competent implementation of a bad idea.1 274 lines to reproduce one input, minus the attribute that mattered most.
Even the best library needs 767 lines to hide one input
input-otp, the one shadcn/ui ships, keeps one real <input> and paints it invisible. Then it has to fake everything the browser stopped drawing.
You've felt the bad version. Now the good one. input-otp is the best OTP component in the React ecosystem, and it gets the big thing right: it renders one real text input, makes it transparent, and draws the boxes on top. Its own README says six separate inputs lose "SMS autofill, screen reader support, partial paste, undo, and half the keyboard."
Now look at what it costs to hide an input and keep it working. From the source:
- 598 lines in the core component. The browser's caret is invisible now, so there's a fake one. Selection is invisible, so
selectionStartis tracked by hand andsetSelectionRangeis called to keep it honest. ThreeResizeObservers keep the painted boxes lined up with the real field underneath. Six special cases for iOS and Safari. - 169 more lines in a hook whose only job is password managers. 1Password, LastPass, Dashlane and Bitwarden each inject a badge into the input. The input is invisible, so the badge lands in a clipped region. The hook sniffs each manager by the DOM it injects, and grows the field forty pixels to make room.
That's the best implementation. 767 lines, four password managers detected by selector, a caret drawn by hand, all so one input can look like six boxes. Every line of it is repair work for one decision: hiding the input.
You don't have to hide the input. Keep going.
The best case for the boxes
Steelman first. If one input is going to win, it should win against the strongest version of six.
"The boxes tell you it's six digits." They do, and that's a real
advantage. A blank field is a question; six slots are an answer. But the shape
is paint. placeholder="______" shows the length, maxlength="6" enforces it,
and a label can say "6-digit code" in words. input-otp is the existence proof:
one real input, six painted slots, and nobody can tell.2
"Auto-advance is faster." It feels faster. Count keystrokes. One field, six digits: six keystrokes, done. Six boxes, six digits: six keystrokes, and auto-advance exists so it isn't twelve. It's a fix for a cost the boxes introduced. The fast path is one tap, with the code offered from your messages, and that's the path the boxes close.3
"Segmented inputs test better." Some guides say so, for codes up to eight
digits.4 Read the same guide's checklist for doing it properly: distribute a
paste across the boxes, walk Backspace backwards, put
autocomplete="one-time-code" on the first box and split whatever lands there,
wrap it all in <fieldset> and <legend>, label every box, announce errors with
aria-live. Six requirements, each re-implementing something one input does by
default. Do all six and you've rebuilt input-otp. Skip the first and you've
failed WCAG.5
"We handle autofill on the first box." Some do. The browser drops all six
digits into box one, maxlength truncates to a single digit, and a handler races
to catch the value before that happens and spread it out. It's the shadow field
again: intercept the browser so you can redo what it did. It works until it
doesn't, and when it doesn't, the person on the other end is trying to buy
coffee.
"Accessibility can be handled." It can, at the cost above. But the floor is written down now. WCAG 2.2 treats an authentication step that makes you transcribe something as a cognitive function test, allowed only if paste works and password managers can fill the field.5 Six boxes that eat a paste fail Level AA. One input passes by doing nothing.
So the strongest case for the boxes reduces to the look. You can keep the look.
What should have been waiting for you
One input. Every attribute is doing a job the script used to.
<input>. No script.All six digits land. Backspace deletes the last one. On a phone, the browser offers the code straight from your email. Submit it with four digits and the error you get is the browser's.
<label for="code">Enter code</label>
<input
id="code"
name="code"
type="text"
inputmode="numeric"
pattern="[0-9]{6}"
maxlength="6"
size="6"
placeholder="______"
autocomplete="one-time-code"
title="Six digits from your email"
required>
What the six boxes break
Every item is a thing the browser already did, on the left, until it was replaced.
paste handler that splits the string and distributes it. Code that exists purely to undo the damage.
keydown handler. Then another for arrow keys. Then another for typing over a filled box. Most implementations get one right.
autocomplete="one-time-code". Six fields can't receive that suggestion, so the fancy version is slower on the one device where typing hurts most.
code1 through code6, reassembled.
params[:code]
You're now parsing your own UI on the backend.
Add it up: a few hundred lines of JavaScript, a support burden, and a pile of accessibility regressions, all to make one textbox worse. Nobody decided to do that. Nobody asked, either.
Each attribute buys back one thing from that list
No JavaScript. This is what the attributes are for.
| Attribute | What it buys |
|---|---|
pattern="[0-9]{6}" + required |
Validation. The browser refuses to submit and shows its own message. title is the text in that bubble. |
maxlength="6" |
Stops at six. The job all that per-box focus juggling was doing. |
autocomplete="one-time-code" |
The one everybody leaves off. It's the signal that makes SMS and email autofill work.6 |
inputmode="numeric" |
Number pad on mobile without lying about the type. Not type="number": a code isn't a quantity, and you'd inherit spinners and 5e6.3 |
size="6" |
Width, in characters. Enough on its own; the spaced-out look below is optional. |
placeholder="______" |
Six underscores. Shows the shape. Only visible while empty, so it's a hint, not a progress bar. |
The spaced-out look, without the bugs
I tried the clever version first. It shipped three bugs. Two of them were one line.
input {
letter-spacing: 0.5em;
text-indent: 0.5em;
width: calc(6ch + 3em);
text-align: center;
}
The arithmetic was never the problem. box-sizing: border-box, set globally on
most sites, makes width include the padding and border, so the content gets
squeezed and the last digit clips. Centering text that carries a trailing
letter-space puts the placeholder and the value on different origins. The
text-indent was a patch for the centering, which was itself a mistake.
Fix the box model and the alignment and the same idea works:
input[name="code"] {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 2.4rem;
letter-spacing: 0.5em;
text-align: left;
box-sizing: content-box;
width: calc(6ch + 3em);
padding: 0.45rem 0.75rem;
}
content-box makes width mean the content again. 6ch is six characters in
a monospace font, where ______ and 561579 are the same width by definition,
and 3em is the six gaps of 0.5em that letter-spacing adds after each one.
Left-aligned, the placeholder and the digits start at the same point. That's
the whole trick, and it's four lines.
If you don't want the spacing, delete the letter-spacing and width lines and
let size="6" do it.
It was never a launch code
Boxes are a paint job. Paint them on a control that works.
The six boxes look serious. That's the whole appeal. But you can have the look on top of one input. A monospace font and some padding get you most of it, and you keep paste, backspace, autofill, the password manager, and the screen reader.
Sweet Maria's, if you ever read this: that's the whole fix. Delete a few hundred
lines of JavaScript, ship one <input>, and I'll get back to buying coffee a
little faster.
-
react-otp-input,
src/index.tsx: 274 lines, one input per digit, Backspace and paste re-implemented in handlers,autoComplete: 'off'on every box. ↩ -
input-otp,
packages/input-otp/src:input.tsxis 598 lines;use-pwm-badge.tsxis 169 lines spent detecting 1Password, LastPass, Dashlane and Bitwarden by the DOM they inject, to make room for a badge on an invisible input. ↩ -
Google's own guidance, SMS OTP form best practices: a single
<input>withtype="text",inputmode="numeric",autocomplete="one-time-code"and apattern. It also warns offtype="number", whose spinner buttons can strip leading zeros from a code. ↩ -
Code Confirmation Pattern at uxpatterns.dev argues for segmented inputs, then lists everything you must rebuild for them to work. The checklist is the argument against. ↩
-
WCAG 2.2, Success Criterion 3.3.8 Accessible Authentication (Minimum), Level AA. The Understanding document lists preventing copy and paste as a failure, and requires that browsers and third-party password managers be able to fill the field. ↩
-
Safari has offered the code from an incoming SMS to a field marked
autocomplete="one-time-code"since Safari 12 on iOS, iPadOS and macOS. Chrome, Opera and Vivaldi on Android do it programmatically with the WebOTP API, which needs HTTPS, a script, and an SMS ending in@yourdomain #code. Both are built for one field. ↩