Skip to content

Instantly share code, notes, and snippets.

@martynchamberlin
Last active December 18, 2021 23:04
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save martynchamberlin/a59ebe27c0202fa77ecb87cb9410cca3 to your computer and use it in GitHub Desktop.
Save martynchamberlin/a59ebe27c0202fa77ecb87cb9410cca3 to your computer and use it in GitHub Desktop.
A six-input 2FA form that behaves as Apple’s

I needed to build a 2FA confirmation screen and I wanted it to behave the way that Apple’s does. Here’s the spec as best as I can tell from Apple’s:

  • Typing a letter on the onkeydown event moves the focus to the next input.
  • You can mash 6 integers and they should all register in place.
  • Backspace removes the current field, or if the current field is empty, it removes the previous field value and focuses the previous field.
  • It should be impossible to enter a non-numeric key, and impossible to enter more than one key per field.
  • Tab key (as well as Shift + Tab) should work as expected.
  • Clipboard paste should be accepted, starting at the focused field.
  • The only key that should be repeatable when holding down is the Backspace.

What this doesn't implement:

  • When a focused input isn't selected, typing does nothing. Hard to tell if this is a feature or just an artifact on Apple's end.
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1" />
</head>
<body>
<div class="wrap">
<input inputmode="decimal" type="number" maxlength="1" />
<input inputmode="decimal" type="number" maxlength="1" />
<input inputmode="decimal" type="number" maxlength="1" />
<input inputmode="decimal" type="number" maxlength="1" />
<input inputmode="decimal" type="number" maxlength="1" />
<input inputmode="decimal" type="number" maxlength="1" />
</div>
<script>
const inputs = [...document.querySelectorAll('input')];
let previousKey = '';
function isNumber(value) {
return typeof value === 'number' && !isNaN(value);
}
function insertNumber(i, pastedData) {
if (!isNumber(parseInt(pastedData))) {
return;
}
let count = i;
while (count < inputs.length && count - i < pastedData.length) {
inputs[count].value = pastedData[count - i];
count++;
}
}
inputs.forEach((input, i) => {
input.addEventListener('keydown', function(e) {
// if coming from predictive texting, let the input event handle it.
if (!event.isTrusted) {
return;
}
const tryingToPaste = previousKey === 'Meta' && e.key === 'v';
if (e.key !== 'Tab' && !tryingToPaste) {
e.preventDefault();
}
previousKey = e.key;
if (e.key === 'Backspace') {
if (input.value) {
input.value = '';
} else if (i > 0) {
inputs[i - 1].focus();
inputs[i - 1].value = '';
}
} else if (e.repeat) {
return;
}
const value = parseInt(e.key);
if (isNumber(value)) {
input.value = e.key;
if (i + 1 < inputs.length) {
inputs[i + 1].focus();
} else {
input.select();
}
}
});
input.addEventListener('focus', function(e) {
input.select();
index = i;
});
input.addEventListener('paste', function(e) {
e.preventDefault();
const clipboardData = e.clipboardData || window.clipboardData;
insertNumber(i, clipboardData.getData('text'));
});
// Handle predictive text on mobile. Because we preventDefault() onkeydown, mobile predictive text should
// be the only thing causing the input event to occur
input.addEventListener('input', function(event) {
// On a physical iPhone, the input event occurs once for every digit in the predictive text, so this will
// fire a total of 6 times before this conditional is true. On an iOS simulator in contrast, only a single
// input event fires when tapping a predictive text, and in that scenario this conditional is not necessary.
if (event.target.value.length === inputs.length) {
insertNumber(i, event.target.value);
}
});
});
</script>
<style>
.wrap {
align-items: center;
display: flex;
gap: 5px;
justify-content: center;
margin: 200px auto 0;
}
input {
border-radius: 10px;
border: 1px solid #ddd;
font-size: 20px;
height: 50px;
text-align: center;
width: 50px;
-moz-appearance: textfield;
}
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
margin: 0;
-webkit-appearance: none;
}
</style>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment