The other night I had a bit of fun trying to create a single-input one-time-code:
one-time-code is a valid autocomplete
-value, and with a few lines of JS, it let's you fill out the field from a text-message (sms).
More often, it's called OTP, though.
A one-time password (OTP), also known as a one-time PIN, one-time authorization code (OTAC) or dynamic password, is a password that is valid for only one login session or transaction, on a computer system or other digital device.
Back to my example.
It's using very simple markup:
<input
type="text"
autocomplete="one-time-code"
inputmode="numeric"
maxlength="6"
pattern="\d{6}"
>
The CSS is a bit more complex:
:where([autocomplete=one-time-code]) {
--otp-digits: 6; /* length */
--otc-ls: 2ch;
--otc-gap: 1.25;
/* private consts */
--_otp-bgsz: calc(var(--otc-ls) + 1ch);
all: unset;
background: linear-gradient(90deg,
var(--otc-bg, #EEE) calc(var(--otc-gap) * var(--otc-ls)),
transparent 0
) 0 0 / var(--_otp-bgsz) 100%;
caret-color: var(--otc-cc, #333);
clip-path: inset(0% calc(var(--otc-ls) / 2) 0% 0%);
font-family: ui-monospace, monospace;
font-size: var(--otc-fz, 2.5em);
inline-size: calc(var(--otc-digits) * var(--_otp-bgsz));
letter-spacing: var(--otc-ls);
padding-block: var(--otc-pb, 1ch);
padding-inline-start: calc(((var(--otc-ls) - 1ch) / 2) * var(--otc-gap));
}
It's a bunch of stuff simulating 6 fields (from the property --otc-digits
), while — in reality — it's just a single <input>
. The spacing between the "fields" is due to letter-spacing
, and the gray "boxes" are from a linear-gradient
.
It has to use a monospace-font, so the magic value of 1ch
works — same applies to the letter-spacing
. 1ch
equals the width of a zero.
But why?
Have you ever created a OTP-component before?
I'm writing "component", because it's typically a <fieldset>
with six <input>
s and a bunch of JavaScript to detect when you enter- or leave a field etc.
When you fill out the field from the Web OTP API, you need to split the value, and fill-in six fields instead of one.
With a single input it's much simpler:
navigator.credentials.get({
otp: {transport:['sms']}
})
.then(otp => input.value = otp.code);
Highlightning the current "field"
The single-input OTP is not perfect. When you move from "field" to "field", it would be a better user-experience, if the caret was a block:
.selector {
caret-shape: block;
}
Unfortunately, no browsers support caret-shape
yet.
Another way, would be to add another background-gradient
, but without repeating the pattern:
And position it by multiplying the size-property — --_otp-bgsz
— with the digit-number, --_otp-digit
, as a custom property:
.selector {
background-position:
calc(var(--_otp-digit, 0) * var(--_otp-bgsz)) 0;
}
This is not perfect, because we'd need to put the digit in a CSS Custom Property, and then update that with JavaScript:
input.addEventListener('input', () =>
input.style.setProperty('--_otp-digit',
input.selectionStart)
)
Could this be done in an even simpler way? Other sugestions? Please use the comments!
Here's a Codepen-demo: