Susegad UI
Register
Theme
Palette

Components

One-time code

A one-time code entry. The native path is one input with autocomplete="one-time-code" and the number pad, which submits, takes SMS autofill and works without JavaScript.

quiet warm playful

Open the live demo npx susegad add otp

Stands on: Core, Core: components, Engine, Tokens

The prompt

the prompt

The six digits from a text message that prove it is you.

A row of boxes for a one-time code, stamped digit by digit, that still behaves exactly like one plain input: paste, autofill and screen readers all just work.

<link rel="stylesheet" href="susegad/components/otp/otp.css">
<script type="module" src="susegad/components/otp/otp.js"></script>

<sg-otp>
  <label for="code">Enter the 6-digit code we sent to 98220 12345</label>
  <input id="code" name="code" autocomplete="one-time-code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" required>
</sg-otp>

The prompt

Make a web component for entering a one-time code. Start from one input with autocomplete="one-time-code", inputmode="numeric", a pattern and a maxlength, so it submits, takes SMS autofill and validates without JavaScript. With JavaScript, keep that input as the only control: lay it transparently over a row of boxes that are hidden from assistive technology, one per digit, grouped in threes or fours for reading. Show each digit in its box, the caret and focus ring in the current box, and every selected box when there is a selection. Handle beforeinput so the row behaves like boxes: typing over a filled box replaces it, backspace empties the box before the caret and pulls the rest along, and a paste or autofill keeps just the digits from whatever text arrives and fills every box. Read digits typed in any Indian script, or full width, as ASCII. A tap on a filled box selects its digit. Never submit on the last digit; fire an event instead. Give it three registers. Quiet: plain hairline boxes, digits in the body face. Warm: stamp boxes, a carved double frame that inks when its digit arrives, the digit in the display face with a little ink starvation from an SVG turbulence filter, and each digit inking in as it lands. Playful: a stamp per digit, each box at its own seeded tilt, each digit coming down, pressing past flat and settling while its ink spreads, and a pasted code landing left to right. With reduced motion, show the digits in place.

Words to code

When you sayTechniqueWhat happens
keep that input as the only controlnative firstThe input sits in the same grid cell as the boxes, above them, with transparent text, caret and selection. Every tap, keystroke, paste and autofill goes to it; the boxes only draw its value. Until a skin loads, the input is shown as it is.
hidden from assistive technologyaccessibilityThe boxes are aria-hidden. A screen reader meets one labelled textbox, and nothing else.
typing over a filled box replaces itstatebeforeinput is cancelled and insert(value, start, end, text, length) decides: the new digits overwrite from the selection start, and the caret lands after them. The element then sets the value and fires the usual input event itself.
backspace … pulls the rest alongstateremove(value, start, end) takes out the digit before the caret (or the selection) and closes the gap, so the code never has holes.
keeps just the digits … any Indian scripttextdigitsOf walks the text and maps each decimal digit, from ASCII, Devanagari, Bengali, Gurmukhi, Gujarati, Odia, Tamil, Telugu, Kannada, Malayalam, Arabic-Indic or full width, to its ASCII value. A code of the full length pasted anywhere replaces the whole code.
A tap on a filled box selects its digitpointerOn pointerup the element finds the box under the finger and sets the selection to that one digit, so the next digit typed replaces it.
Never submit on the last digitaccessibilitySubmitting on input would change the page under someone mid-typing (WCAG 3.2.2). sg-otp reports { value, complete } and the builder decides.
a little ink starvation from an SVG turbulence filtertextureOne <filter> per element: fractal noise, turned into an alpha mask by a colour matrix and cut out of the digit with feComposite in. It sits on the digit only, so the focus ring stays whole.
each box at its own seeded tiltseedboxPose(seed, i) gives up to 3.5 degrees and a pixel's nudge, fixed per box.
pressing past flat and settling while its ink spreadseasingWeb Animations keyframes from landing(motion): scale 1.4, 0.92, 1.03, 1 over 320 ms, and a halo that scales out and fades. A paste staggers the boxes 55 ms apart. Every animation is cancelled when it finishes.

Accessibility

  • One labelled input, with its native autocomplete, keyboard and validation. The label says how many digits and where the code went.
  • The focus ring is on the current box. Errors come from aria-invalid and aria-describedby on the input, and turn the boxes to the danger colour.
  • No automatic submit. No time limit of its own.
  • Reduced motion shows the digits in place and a steady caret.

Credit

The stamp boxes come from the Wave 1 Stamp, itself harvested from the "held" stamp on the Casa Exemplo booking card and the rubber stamps of Indian offices. Tier: pan-Indian.

<sg-otp> is the box you type a one-time code into. It enhances one <input>, which stays the only control: it submits with the form, takes the phone's code-from-messages suggestion and password managers, and works without JavaScript.

Use

<link rel="stylesheet" href="susegad/components/otp/otp.css">
<script type="module" src="susegad/components/otp/otp.js"></script>

<form method="post" action="/verify">
  <sg-otp>
    <label for="code">Enter the 6-digit code we sent to 98220 12345</label>
    <input id="code" name="code" autocomplete="one-time-code" inputmode="numeric"
           pattern="[0-9]{6}" maxlength="6" required>
  </sg-otp>
  <button>Confirm</button>
</form>

Say in the label how many digits and where the code went. Keep autocomplete="one-time-code", inputmode="numeric" and the pattern: they are what makes autofill, the number pad and native validation work, with or without JavaScript. (The element adds the first two if they are missing, but only once JavaScript runs.)

What people see and do

  • Without JavaScript, or until a skin loads: one input with the digits spaced out.
  • With a skin: a row of boxes, one per digit, grouped for reading (6 as 3 + 3, 8 as 4 + 4). The input lies over them, transparent, so every tap and keystroke still goes to the real input.
  • Typing fills the next box. Typing over a filled box replaces it. Tapping a filled box selects its digit.
  • Backspace empties the box before the caret and the rest move up. Arrow keys, Home and End move between boxes. Select all and delete clears them.
  • Pasting a whole message ("Your code is 482 913") keeps just the digits and fills every box. So does an SMS suggestion or autofill.
  • Digits typed in Devanagari, Kannada, Tamil, Bengali or any other Indian script, or full width, are read as ASCII digits.
  • Letters and symbols go nowhere.

Attributes

AttributeValuesDefault
length3 to 12the input's maxlength, else the count in its pattern, else 6
seedany string: fixes the playful stamps' tiltthe input's name
webotpboolean: on Android Chrome, also ask for the code from the SMS with the WebOTP APIabsent
registerquiet, warm, playfulinherited

webotp needs your SMS to end with a line like @your.domain #482913. It is cancelled when the form submits or the element goes.

Properties and events

  • value: the code (get and set; setting keeps digits only).
  • complete (read only): all boxes filled.
  • sg-otp event, bubbling, detail: { value, complete }, on every change. The input's own input event fires as usual, including for the edits the element makes.
  • Nothing submits by itself. Submitting when the last digit arrives would change the page under someone mid-typing (WCAG 3.2.2). Listen for complete if your flow really needs it, and say so in the label.

Errors

Mark a code the server rejected with aria-invalid="true" on the input and describe it with aria-describedby, or use <sg-field-note>. The boxes and digits turn to the danger colour in every register.

Registers

LookMotion
quietplain boxes with a hairline, digits in the body facedigits appear; the caret blinks
warmstamp boxes: a carved double frame that inks when its digit arrives, digits in the display face with a little ink starvationeach digit inks in over 240 ms
playfula stamp per digit, each box at its own small tilteach digit comes down, presses past flat and settles while its ink spreads; a paste lands left to right

Reduced motion shows every digit in place, with a steady caret.

Accessibility

  • One input, with its label. The boxes are aria-hidden, so a screen reader hears exactly what it would hear for a plain input: the label, the digits typed, and any error.
  • The current box carries the focus ring (the input's own ring would surround the whole row). With a selection, every selected box shows it.
  • The input's text is transparent, so axe asks for colour contrast to be checked by hand. The digits people see are token text colours on --sg-surface-raised at 1.75rem, which counts as large text.
  • The input stays at 16px or more, so iOS does not zoom on focus.

Budget

FileBytes
otp.js + otp.core.js (behaviour)13.0 KB of 14 KB declared
skins/boxes.js (shared)1.6 KB
skins/quiet.js0.2 KB
skins/warm.js2.4 KB
skins/playful.js (loads warm.js)0.4 KB + 2.4 KB

Nothing runs per frame. A digit's landing is one or two Web Animations, cancelled when they finish.