@use 'sprucecss/scss/spruce' as *;
.otp-input {
@include layout-stack('m');
text-align: center;
&__legend {
font-size: font-size('h4');
margin-inline: auto;
}
&__hint,
&__resend {
color: color('muted');
}
&__digits {
display: flex;
gap: spacer('xs');
justify-content: center;
}
&__digit {
aspect-ratio: 1;
font-family: config('font-family-mono', $typography);
font-size: font-size('h3');
font-weight: 700;
inline-size: 3.25rem;
padding: 0;
text-align: center;
}
&__resend {
align-items: center;
display: flex;
flex-wrap: wrap;
justify-content: center;
}
}
<form class="otp-input" onsubmit="return false;">
<fieldset>
<legend class="otp-input__legend">Enter the 6-digit code</legend>
<p class="otp-input__hint" id="otp-hint">We sent it to j•••@example.com.</p>
<div class="otp-input__digits" data-otp aria-describedby="otp-hint">
<input class="form-control otp-input__digit" type="text" inputmode="numeric" pattern="[0-9]" maxlength="1" autocomplete="one-time-code" aria-label="Digit 1 of 6" required>
<input class="form-control otp-input__digit" type="text" inputmode="numeric" pattern="[0-9]" maxlength="1" autocomplete="off" aria-label="Digit 2 of 6" required>
<input class="form-control otp-input__digit" type="text" inputmode="numeric" pattern="[0-9]" maxlength="1" autocomplete="off" aria-label="Digit 3 of 6" required>
<input class="form-control otp-input__digit" type="text" inputmode="numeric" pattern="[0-9]" maxlength="1" autocomplete="off" aria-label="Digit 4 of 6" required>
<input class="form-control otp-input__digit" type="text" inputmode="numeric" pattern="[0-9]" maxlength="1" autocomplete="off" aria-label="Digit 5 of 6" required>
<input class="form-control otp-input__digit" type="text" inputmode="numeric" pattern="[0-9]" maxlength="1" autocomplete="off" aria-label="Digit 6 of 6" required>
</div>
</fieldset>
<button class="btn btn--primary btn--block" type="submit">Verify</button>
<p class="otp-input__resend">Didn’t get it? <button class="btn btn--link btn--sm" type="button">Resend code</button></p>
</form>
(() => {
document.querySelectorAll('[data-otp]').forEach((group) => {
const inputs = [...group.querySelectorAll('input')];
const fill = (value, start = 0) => {
[...value.replace(/\D/g, '')].slice(0, inputs.length - start).forEach((digit, index) => {
inputs[start + index].value = digit;
});
const next = inputs.find((input) => !input.value) ?? inputs[inputs.length - 1];
next.focus();
};
inputs.forEach((input, index) => {
input.addEventListener('input', () => {
if (input.value.length > 1) {
fill(input.value, index);
return;
}
input.value = input.value.replace(/\D/g, '');
if (input.value && inputs[index + 1]) {
inputs[index + 1].focus();
}
});
input.addEventListener('keydown', (event) => {
if (event.key === 'Backspace' && !input.value && inputs[index - 1]) {
inputs[index - 1].focus();
} else if (event.key === 'ArrowLeft' && inputs[index - 1]) {
inputs[index - 1].focus();
} else if (event.key === 'ArrowRight' && inputs[index + 1]) {
inputs[index + 1].focus();
}
});
input.addEventListener('paste', (event) => {
event.preventDefault();
fill(event.clipboardData.getData('text'), index);
});
input.addEventListener('focus', () => input.select());
});
});
})();