Using a Single Input for one-time-code

Mads Stoumann - Jun 12 '23 - - Dev Community

The other night I had a bit of fun trying to create a single-input one-time-code:

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.

From Wikipedia:

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}"
>
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Unfortunately, no browsers support caret-shape yet.


Another way, would be to add another background-gradient, but without repeating the pattern:

Image description

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;
}
Enter fullscreen mode Exit fullscreen mode

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)
)
Enter fullscreen mode Exit fullscreen mode

Could this be done in an even simpler way? Other sugestions? Please use the comments!


Here's a Codepen-demo:

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .