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 say | Technique | What happens |
|---|---|---|
| keep that input as the only control | native first | The 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 technology | accessibility | The boxes are aria-hidden. A screen reader meets one labelled textbox, and nothing else. |
| typing over a filled box replaces it | state | beforeinput 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 along | state | remove(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 script | text | digitsOf 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 digit | pointer | On 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 digit | accessibility | Submitting 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 filter | texture | One <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 tilt | seed | boxPose(seed, i) gives up to 3.5 degrees and a pixel's nudge, fixed per box. |
| pressing past flat and settling while its ink spreads | easing | Web 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-invalidandaria-describedbyon 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
| Attribute | Values | Default |
|---|---|---|
length | 3 to 12 | the input's maxlength, else the count in its pattern, else 6 |
seed | any string: fixes the playful stamps' tilt | the input's name |
webotp | boolean: on Android Chrome, also ask for the code from the SMS with the WebOTP API | absent |
register | quiet, warm, playful | inherited |
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-otpevent, bubbling,detail: { value, complete }, on every change. The input's owninputevent 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
completeif 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
| Look | Motion | |
|---|---|---|
| quiet | plain boxes with a hairline, digits in the body face | digits appear; the caret blinks |
| warm | stamp boxes: a carved double frame that inks when its digit arrives, digits in the display face with a little ink starvation | each digit inks in over 240 ms |
| playful | a stamp per digit, each box at its own small tilt | each 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-raisedat 1.75rem, which counts as large text. - The input stays at 16px or more, so iOS does not zoom on focus.
Budget
| File | Bytes |
|---|---|
otp.js + otp.core.js (behaviour) | 13.0 KB of 14 KB declared |
skins/boxes.js (shared) | 1.6 KB |
skins/quiet.js | 0.2 KB |
skins/warm.js | 2.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.