# Code boxes where a keyboard can't correct a wrong digit

Source: https://easeweb.dev/learn/code-box-keys
Topics: Forms > Split code boxes can be filled and corrected with a keyboard (WCAG 2.1.1)
The fix: Let the keyboard go back to a digit already typed. Either ask for the code in one text field, or keep the boxes and move on to the next box on the input event instead of keyup, ideally with Backspace and the arrow keys moving between the boxes and a pasted code spread across them.
Test with: keyboard, screen reader, mouse
References: https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html https://developer.mozilla.org/en-US/docs/Web/API/Element/keyup_event https://developer.mozilla.org/en-US/docs/Web/API/Element/input_event https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/autocomplete https://design-system.service.gov.uk/patterns/confirm-a-phone-number/

This is a recreated teaching example, not a finding about a named client. The code is a starting pattern: test it in your own product. No completed assistive-technology test is claimed here.

## What happens

A shop asks for a second step when people sign in. It sends a six-digit code by text message and shows six boxes, one per digit. Each box moves on to the next as soon as it is full, so the code can be typed in one go without pressing Tab. The message says "Your Larkspur code is 481902". Someone types 4, then 8, then 7 by mistake, and focus is already in the fourth box.

They press Backspace. Nothing happens: the fourth box is empty, so there is nothing to delete. They press Shift+Tab to go back: focus reaches the third box and jumps straight on to the fourth again. The left arrow key does nothing either. With a mouse, the slip takes a second: click the 7, delete it, type 1. From the keyboard, no key reaches the 7. What is left is to send a code they know is wrong, or to leave the page and start the sign-in again.

## Why it fails

Success criterion 2.1.1 Keyboard, level A, asks that everything a page lets people do can be done from a keyboard. Correcting a digit is something this page lets a mouse do, by clicking the box, so the keyboard must be able to do it too.

The script causes it by listening for `keyup`. A key acts when it goes down, and a `keyup` handler runs when it comes up, on whatever has focus by then. Shift+Tab moves focus to the third box as the key goes down. When Tab comes up, its `keyup` arrives at the third box, which is full, so the script sends focus on to the fourth. Every key does the same, so no key can stay on a box that holds a digit. Backspace and the arrow keys act inside the box that has focus, which is empty, and nothing in the script takes them back. Tab forward still leaves the boxes, so this is not a keyboard trap: it is the way back that is closed. We saw the same in Chromium, Firefox and Safari's engine.

Boxes that move on at the `input` event instead, as in [one-time code boxes that won't take a pasted code](/learn/one-time-codes), don't fail this way, even with no Backspace handling. The `input` event fires only when the value changes, so Shift+Tab stays on the third box, the browser selects its digit as Tab arrives, and typing 1 replaces the 7. The topic's other forms fail for other reasons: boxes drawn as `div` elements and filled by a key handler on the page have none of a field's keys unless the script adds each one.

## Who is affected

Sign-in codes often run out within minutes, and a few wrong codes can lock the account, so a digit that can't be corrected costs more than a retype.

## What automation and AI miss

Every box has a label, so axe and other scanners pass the page; nothing in the markup says when the script moves focus. EaseWeb's audit types two digits into the first boxes of a row of four or more `maxlength="1"` fields and presses Shift+Tab, and reports `ew:code-boxes-keyboard` when focus is sent straight back to the third box. It types only two digits, never a whole code, which might send itself. It doesn't check boxes that aren't input elements. Moving on at `keyup` and comparing the value's length with `maxlength` is a way auto-advance has long been written in forum answers and plugins, and it works for anyone who types the code right first time, so a test that types it right finds nothing.

## Before: boxes that move on when a key comes up

```html
<p id="code-label">Enter the 6-digit code we sent to your phone</p>
<div class="code-boxes" role="group" aria-labelledby="code-label">
  <input name="code-1" inputmode="numeric" maxlength="1" aria-label="Digit 1 of 6">
  <input name="code-2" inputmode="numeric" maxlength="1" aria-label="Digit 2 of 6">
  <input name="code-3" inputmode="numeric" maxlength="1" aria-label="Digit 3 of 6">
  <input name="code-4" inputmode="numeric" maxlength="1" aria-label="Digit 4 of 6">
  <input name="code-5" inputmode="numeric" maxlength="1" aria-label="Digit 5 of 6">
  <input name="code-6" inputmode="numeric" maxlength="1" aria-label="Digit 6 of 6">
</div>
<button type="submit">Continue</button>
```

```js
const boxes = [...document.querySelectorAll('.code-boxes input')];
// When a box is full, move on to the next one.
boxes.forEach((box, i) => box.addEventListener('keyup', () => {
  if (box.value.length === box.maxLength && boxes[i + 1]) boxes[i + 1].focus();
}));
```

The handler runs for every key that comes up, not only for a typed digit. When Shift+Tab reaches a full box, the Tab key's `keyup` arrives there and moves focus on again, so the keyboard can never stay on a digit to change it.

## After: one field for the whole code

```html
<label for="code">Security code</label>
<p id="code-hint">Enter the 6-digit code we sent to your phone.</p>
<input id="code" name="code" type="text" inputmode="numeric"
  autocomplete="one-time-code" spellcheck="false" aria-describedby="code-hint">
<button type="submit">Continue</button>
```

```js
// No script: typing, Backspace, the arrow keys and paste are the browser's own.
```

In one text field, every editing key works as it does in any field: Backspace deletes the 7, the arrow keys move the cursor, and Shift+Tab goes back to whatever came before. Nothing moves focus, so nothing can move it the wrong way. The field also takes a pasted code whole, and `autocomplete="one-time-code"` lets a browser that supports it offer the code from a new message.

## After: move on at input, not at keyup

```html
<p id="code-label">Enter the 6-digit code we sent to your phone</p>
<div class="code-boxes" role="group" aria-labelledby="code-label">
  <input name="code-1" inputmode="numeric" maxlength="1" aria-label="Digit 1 of 6">
  <input name="code-2" inputmode="numeric" maxlength="1" aria-label="Digit 2 of 6">
  <input name="code-3" inputmode="numeric" maxlength="1" aria-label="Digit 3 of 6">
  <input name="code-4" inputmode="numeric" maxlength="1" aria-label="Digit 4 of 6">
  <input name="code-5" inputmode="numeric" maxlength="1" aria-label="Digit 5 of 6">
  <input name="code-6" inputmode="numeric" maxlength="1" aria-label="Digit 6 of 6">
</div>
<button type="submit">Continue</button>
```

```js
const boxes = [...document.querySelectorAll('.code-boxes input')];
// When a digit goes in, move on to the next box.
boxes.forEach((box, i) => box.addEventListener('input', () => {
  if (box.value && boxes[i + 1]) boxes[i + 1].focus();
}));
```

The smallest change: one event name. The `input` event fires when the value changes, not when a key comes up, so Tab, Shift and the arrow keys no longer move focus. Shift+Tab stays on the third box, the browser selects the 7 as Tab arrives, and typing 1 replaces it and moves on. Two things stay as they were. Backspace in the empty fourth box still does nothing, which people expect to work; and each box still takes one character, so a pasted code keeps only its first digit, which fails 3.3.8, as [one-time code boxes that won't take a pasted code](/learn/one-time-codes) explains.

## After: boxes that move with Backspace and the arrow keys

```html
<p id="code-label">Enter the 6-digit code we sent to your phone</p>
<div class="code-boxes" role="group" aria-labelledby="code-label">
  <input name="code-1" inputmode="numeric" autocomplete="one-time-code" aria-label="Digit 1 of 6">
  <input name="code-2" inputmode="numeric" aria-label="Digit 2 of 6">
  <input name="code-3" inputmode="numeric" aria-label="Digit 3 of 6">
  <input name="code-4" inputmode="numeric" aria-label="Digit 4 of 6">
  <input name="code-5" inputmode="numeric" aria-label="Digit 5 of 6">
  <input name="code-6" inputmode="numeric" aria-label="Digit 6 of 6">
</div>
<button type="submit">Continue</button>
```

```js
const boxes = [...document.querySelectorAll('.code-boxes input')];
boxes.forEach((box, i) => {
  // A typed digit fills this box and moves on; a pasted, autofilled
  // or dictated code is spread over this box and the ones after it.
  box.addEventListener('input', (event) => {
    const digits = (event.data ?? box.value).replace(/\D/g, '').slice(0, boxes.length - i);
    box.value = '';
    [...digits].forEach((digit, n) => { boxes[i + n].value = digit; });
    if (digits) boxes[Math.min(i + digits.length, boxes.length - 1)].focus();
  });
  // Arriving in a box selects its digit, ready to be typed over.
  box.addEventListener('focus', () => box.select());
  box.addEventListener('keydown', (event) => {
    // Backspace in an empty box deletes the digit before it.
    if (event.key === 'Backspace' && !box.value && i > 0) {
      event.preventDefault();
      boxes[i - 1].value = '';
      boxes[i - 1].focus();
    }
    // The arrow keys move between the boxes.
    if (event.key === 'ArrowLeft' && i > 0) {
      event.preventDefault();
      boxes[i - 1].focus();
    }
    if (event.key === 'ArrowRight' && boxes[i + 1]) {
      event.preventDefault();
      boxes[i + 1].focus();
    }
  });
});
```

The boxes now take every key a single field would. Backspace in the empty fourth box deletes the 7 and goes back to it; the left arrow goes back without deleting; Shift+Tab still works; and arriving in a box by key selects its digit, and typing replaces the digit after a click too, since the script keeps only the digit just typed. The script moves on only at the `input` event. With no `maxlength`, a pasted code reaches the script whole and is spread across the boxes, so the same boxes also pass 3.3.8. We tested these keys, a click and a paste in Chromium, and the keys and a click in Firefox and Safari's engine as well.

## Which option to use

All three let the keyboard correct a digit. Use the one field: it does the job with no script, every editing key and paste work because the browser handles them, and there is nothing to keep working. Changing `keyup` to `input` is the quickest repair for boxes already live, but it leaves the paste failure and a Backspace that doesn't go back, so treat it as a first step. Keep boxes only when the design can't change, and then give them all the keys, as in the last option, and say beside them that each box moves on to the next (see Implementation decisions).

## Implementation decisions

Move focus on from the `input` event, which fires only when the value changes, and never from a `keyup` or `keydown` handler that acts on every key, Tab, Shift and the arrow keys included. A check such as "is the box full?" doesn't help, since a box that is full and has just been reached by Shift+Tab is exactly the case that goes wrong.

Browsers select a field's contents when Tab or Shift+Tab reaches it, which is why typing over a digit works in the smaller fix. A click leaves the cursor instead, as does a script's `focus()` in Chromium and Firefox; with `maxlength="1"`, a digit typed beside another is then refused. The last option selects the digit on focus, and its `input` handler keeps only the digit just typed, so typing replaces the digit however the box was reached, even in Safari's engine, where a click clears the selection again.

The two box fixes still move focus by themselves, and moving focus is a change of context under 3.2.2 On Input. The Understanding document's own example of split phone fields that move on by themselves passes because the form describes that behaviour at the start, so write it into the instruction above the boxes, such as "Each box moves on to the next as you type." One field moves nothing, which is one more reason to prefer it.

Let people send the code themselves, with the button or Enter. A form that sends the code the moment the last box fills changes the page without being asked, as [dropdowns and fields that change the page on their own](/learn/context-changes) describes, and a mistyped digit becomes a failed attempt before anyone could correct it.

A Japanese input method can type digits at full width (０ to ９). `\D` removes those, so a box that keeps only 0 to 9 can end up empty with no reason given; convert them first.

## Verify the fix

1. Put the mouse aside. Type the code with a wrong third digit, press Shift+Tab, and type the right digit: it replaces the wrong one, and focus stays where you put it until you type.
2. From the first box, press Tab until focus reaches Continue, then Shift+Tab back to the first box: every box is reached once, in order, both ways.
3. With an on-screen keyboard, such as Windows' On-Screen Keyboard or the macOS Accessibility Keyboard, type a code with one wrong digit and correct it.
4. With a screen reader, type the code and go back with Shift+Tab: each box is read with its label and its digit, and focus doesn't move on by itself.

Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat after the sign-in flow or its code component changes.

## Limits

This is a starting pattern, not a guarantee for every code entry. The example is a sign-in code; a PIN, a gift card number or a booking reference asked for in boxes needs the same test. Pasting the whole code is covered in [one-time code boxes that won't take a pasted code](/learn/one-time-codes). Boxes built from elements that aren't inputs need every key added by script, and a screen reader may not treat them as fields at all; this guide doesn't cover them in code. Test the whole sign-in with the keyboards, switches and screen readers your users rely on.

---

Get this guide in your coding agent: MCP https://easeweb.dev/mcp (setup at https://easeweb.dev/services/developers)
