hypermedia.lol

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:

The email, dramatized. The real one has more logo. Copy the code. You need it next.

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:

Authorization required
System: Sweet Maria's Green Coffee  ·  Clearance: Buy beans
Enter 6-digit launch code Shadow field · what the server gets code="" The boxes have no name. A script joins them into this hidden field on every keystroke. That script is running right now. Standby Incomplete Armed
Built the way these usually are: six boxes, auto-advance, a hidden field, and the JavaScript to hold it together. It's live so you can feel it.
The sign-in screen. Paste the code and watch what happens. Then type two digits and press Backspace twice.

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?

What it gets right
  • 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.
What it breaks
  • 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.

What you see 5
Held together by input →keydown →paste →focus →blur →
What the server gets <input type="hidden" name="code" value="5">

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 */ });
});
How it's built. The usual shape, hand-rolled everywhere. Do not ship this. Every line is a place Backspace can break.

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.

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:

  1. Paste your code. Watch how it has to be split up and distributed.
  2. Type two digits, press Backspace twice. Note which box you land in.
  3. 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:

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.

Authorization required
System: Sweet Maria's Green Coffee  ·  Clearance: Buy beans
What the server gets code=561579 Whatever you typed. The input has a name. There is nothing to join, and nothing here is JavaScript. Standby Incomplete Armed
Same console. Same drama. One <input>. No script.
What should have been waiting for you. Paste the same code.

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>
The fix. The entire control. No script. Every attribute is doing a job the script used to.

What the six boxes break

Every item is a thing the browser already did, on the left, until it was replaced.

Paste One digit lands. Five vanish. All six land. You'd need a paste handler that splits the string and distributes it. Code that exists purely to undo the damage.
Backspace Stops dead in an empty box. Deletes the last digit. You'd need a keydown handler. Then another for arrow keys. Then another for typing over a filled box. Most implementations get one right.
Autofill Gets one digit and a shrug. One tap fills it from SMS or email. Safari and Chrome look for a single field marked 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.
Screen reader "edit text, blank. edit text, blank. edit text, blank. edit text, blank. edit text, blank. edit text, blank." "Enter code, edit text, required." That's a real transcript of what most six-box implementations announce, because they don't label the boxes. Someone using a screen reader now has a strictly harder time buying coffee than before the redesign.
Password manager Finds six anonymous fields. Finds the field. It's looking for something to fill. You gave it a puzzle.
The server 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;
}
Don't. Four declarations, three bugs. The last digit clips and the placeholder starts somewhere the digits don't.

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;
}
Do. Content-box, left-aligned, and a width that is just the arithmetic: six characters plus six half-em gaps.

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.


  1. react-otp-input, src/index.tsx: 274 lines, one input per digit, Backspace and paste re-implemented in handlers, autoComplete: 'off' on every box. ↩

  2. input-otp, packages/input-otp/src: input.tsx is 598 lines; use-pwm-badge.tsx is 169 lines spent detecting 1Password, LastPass, Dashlane and Bitwarden by the DOM they inject, to make room for a badge on an invisible input. ↩

  3. Google's own guidance, SMS OTP form best practices: a single <input> with type="text", inputmode="numeric", autocomplete="one-time-code" and a pattern. It also warns off type="number", whose spinner buttons can strip leading zeros from a code. ↩

  4. 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. ↩

  5. 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. ↩

  6. 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. ↩