ui-032

OTP Input

£10 or the whole Vault, £99

An OTP input for 2FA and verification codes: digits land with a bounce, paste fills every box, and a wrong code shakes and clears.

intermediate
6 more details
micro-interactionform-animationstaggerelasticform-validationkeyboard-navigation

This element reads better at your own screen size than in the frame below:

Updated

About this component

A six-cell verification code field that behaves the way people expect one to. Each digit lands in its cell with an elastic bounce while a ring slides on to the next, Backspace steps the ring back and lifts the character out, and pasting a whole code fills the row one cell after another rather than all at once. Get it wrong and the row shakes and empties itself; get it right and the cells sweep to the accent colour in sequence.

Read the full effect overview

Underneath there is exactly one real input, laid transparently across the row. That single field is the value, the caret, the paste target and the whole keyboard behaviour, so autofill of an SMS code, select-all, undo and form submission all work without a line of code holding them up. A Resend link and a Verify button are optional companions, wired by data attributes. Ships in light and dark, switched with one data-variant attribute.

OTP Input - UI component preview

What's included

9 items
  • Elastic bounce on each cell as its character lands
  • Sliding focus ring measured from the cells, so any gap or size works
  • Backspace steps the ring back and lifts the character out
  • Pasted codes fill every cell with a stagger instead of appearing at once
  • Wrong code shakes the row, colours it and clears itself
  • Right code sweeps the cells to the accent colour one after another
  • One native input holds the value for forms, autofill and screen readers
  • Non-digits are stripped as they arrive, and the length is capped by the cell count
  • Optional Verify and Resend hooks, and light and dark themes on one attribute

Perfect for

4 use cases
  • Two-factor and login verification screens
  • Email or SMS confirmation steps in sign-up flows
  • Payment and transfer confirmation prompts
  • Any short code a person has to copy from somewhere else

How it works

3 sections

One input, six cells

The cells are aria-hidden decoration drawn under a single transparent input that covers the whole row. Every keystroke, paste and autofill goes to that input, and a single input listener diffs its value against what is drawn and animates only the characters that actually changed.

Bounce, slide, stagger

A landing character springs in while its cell bounces. The ring moves by a distance taken from each cell's own rectangle rather than by a CSS offset, so it lands correctly whatever the gap or cell size. A jump of more than one character is treated as a paste and the landings are staggered.

Right and wrong

Reaching the full length compares the value against the code on the wrapper, and the Verify button runs the same check on demand. Wrong shakes the cells with an elastic x and empties the field half a second later; right cross-fades the cells to the success colour with a stagger. Both colours are read from custom properties with getComputedStyle at the moment the tween is built.

Difficulty Intermediate
Includes HTML + JS + CSS source, documentation, AI setup prompt, lifetime updates

Lighthouse, as measured

Google Lighthouse on this UI element's demo, from the latest scan. A measurement of the demo as shipped, not a promise for your page.

Accessibility
100
Best practices
100

No performance score, on purpose. That figure depends on how you deploy: your server's compression and caching, your CDN, the connection and the device doing the test, none of which the code controls. The same page can score very differently on two consecutive runs, so measure it where it will live.

Paid UI element

Purchase to unlock the code.

Buying OTP Input opens the HTML, CSS and JavaScript source, the full documentation, an AI setup prompt for your editor, and every update we ship to it. Standard license: unlimited personal and commercial projects. Every paid product is tested for desktop and mobile layouts, reduced-motion handling, animation cleanup and keyboard controls wherever there is something to operate.

£10 Standard license, unlimited projects

Browse UI elements

Or the whole library: the Vault, £99, one payment.

Documentation

What's Included

  • index.html: the demo page and the markup to copy

  • assets/style.css: component styles, plain CSS custom properties for colours

  • assets/script.js: readable, commented source with an onReady guard

  • Elastic bounce on each cell as its character lands

  • Sliding focus ring measured from the cells, so any gap or size works

  • Backspace steps the ring back and lifts the character out

  • Pasted codes fill every cell with a stagger instead of appearing at once

  • Wrong code shakes the row, colours it and clears itself

  • Right code sweeps the cells to the accent colour one after another

  • One native input holds the value for forms, autofill and screen readers

  • Non-digits are stripped as they arrive, and the length is capped by the cell count

  • Optional Verify and Resend hooks, and light and dark themes on one attribute

Quick Start

1. Add to your HTML <head>:

Code snippet omitted: it ships with the download.

2. Add to your <body>:

Code snippet omitted: it ships with the download.

The number of cells sets the length of the code; the script rewrites maxlength to match, so a four-cell or eight-cell field needs no other change. The status line's initial text is kept as the resting message, so it doubles as the hint under the label.

3. Add before the closing </body> tag:

Code snippet omitted: it ships with the download.

Options

Attribute On Values Default Description
data-otp wrapper present required Marks a code field for the script to wire up
data-code wrapper a code string empty The code to check against. Omit it and every full-length entry succeeds, which is what you want when a server does the checking
data-otp-boxes row present required The measured row; the ring is positioned inside it
data-otp-ring span present optional The sliding focus ring. Delete it and the rest still works
data-otp-status any element present optional Status line; its initial text is reused as the resting message
data-otp-submit button present optional Runs the check on demand. A short code is nudged rather than wiped; a full one is checked exactly as typing it would be. When the wrapper is a <form> the handler is bound to submit, so Enter works too
data-otp-resend button present optional Clears the field, resets the ring and the status line, and returns focus to the input

Checking the code in the browser is only sensible for a demo or a client-rendered flow. In production, leave data-code off, listen for the field reaching full length, and call your server; the shake and the success sweep are both available as the two functions at the bottom of the script.

How It Works

One input, six cells. The cells are aria-hidden decoration drawn beneath a single transparent input that covers the whole row. Every keystroke, paste, autofill and undo goes to that one field, and a single input listener diffs its value against what is currently drawn, animating only the characters that actually changed. There is no per-box input to keep in step, and nothing to intercept before the browser has done its job.

Bounce, slide, stagger. A landing character springs in with back.out(2.6) while its cell bounces with elastic.out(1, 0.5). The ring is moved by x measured from each cell's rectangle rather than by a CSS offset, so it lands correctly whatever the gap or cell size, in either theme. A value that grows by more than one character is treated as a paste and the landings are staggered by PASTE_STEP.

Right and wrong. Reaching the full length compares the value with data-code, and [data-otp-submit] runs the same check on demand. Wrong shakes the row with an elastic x and empties the field half a second later; right cross-fades the boxes to the success colour with a stagger and pops them once. Both colours are read from custom properties with getComputedStyle at the moment the tween is built.

Using It With Your Own Design

What the component actually needs from your markup: a wrapper with data-otp, a [data-otp-boxes] row holding one .otp-box per character (each with an .otp-char inside), and one .otp-field input. The label, the status line, the two action buttons and the fonts are yours to change or delete.

Demo furniture. The 44px toolbar strip at the top of index.html and the .stage / .stage__inner wrapper that centres the component on the grey ground are demo-only: their CSS is marked as such at the bottom of assets/style.css and the toolbar's own styles and script live in index.html. None of it is part of what you bought, and assets/script.js reads no .stage-* class.

The non-obvious CSS the effect depends on:

  • The cells and characters must carry no CSS transform. GSAP owns their x and scale for the bounce, the paste stagger and the refusal shake. A CSS transform underneath is parsed as a pixel offset that never clears.
  • The ring must stay at left: 0. It is moved by x measured from the row's left edge; a CSS left offset would be added on top and the ring would sit one gap too far right.
  • .otp-field is position: absolute; inset: 0; opacity: 0 and sits above the boxes, so the whole row is one click target and the caret is invisible.
  • Each .otp-box is overflow: hidden, which is what lets a character slide out of the top on Backspace instead of drifting over its neighbour.
  • .has-js is added by the script. Without it the cells and the ring are hidden and the input becomes a visible, letter-spaced text field, so the form still works.

Themes

The field ships in one design at two token values, light and dark. Pick one by setting the attribute on <body> in the demo, or on the component's own wrapper in your page:

Code snippet omitted: it ships with the download.

Nothing else changes: same markup, same script, and no theme name is read in JavaScript. The demo's toolbar toggle and the ?variant=dark URL parameter only set that attribute, and dispatch resize so the ring re-measures.

Every colour, radius and size the component and the demo ground use is a custom property defined in both blocks in assets/style.css:

  • Ground and ink: --ground, --ground-2, --raised, --hover, --ink, --ink-2, --ink-3, --line, --line-strong, --shadow
  • Accent: --accent (the ring, the Verify button, ::selection), --accent-ink (text on the accent), --accent-text (the accent as text), --accent-wash
  • Status: --ok, --ok-fg and --bad, the three the script reads with getComputedStyle when it builds a tween
  • The cell row: --box-bg, --box-fg, --box-border, --box-radius, --box-w, --box-h, --box-gap
  • Type and radii: --font, --radius, --radius-sm, --radius-xs

To re-value it for your own brand, change those properties in the two body[data-variant="..."] blocks; nothing else needs touching. The accent here is #00b8a9, and --accent-ink is a dark ink because white does not clear 4.5:1 on that teal, so check the pair if you swap it.

Customisation

  • Colours, radii and sizes live in assets/style.css as custom properties per theme; see the Themes section for the list.
  • --box-w, --box-h and --box-gap reshape the row. The ring sizes itself from the same properties and re-measures on resize.
  • BOUNCE, SLIDE and PASTE_STEP at the top of assets/script.js are the cell bounce duration, the ring travel time and the delay between staggered paste landings.
  • The shake is elastic.out(1, 0.32) from x: -9. Lower the amplitude for a firmer, less cartoonish refusal.
  • The wipe after a wrong code runs on a gsap.delayedCall(0.55, ...). Raise it if your status message is long enough to need reading.

Accessibility

  • The control is one native <input> with inputmode="numeric" and autocomplete="one-time-code", so mobile keyboards show digits and an SMS code can be autofilled straight into it.
  • The cells and the ring are aria-hidden, so a screen reader hears one labelled field rather than six unlabelled ones.
  • The status line is role="status" and referenced with aria-describedby, so acceptance and refusal are announced without moving focus.
  • Keyboard focus is shown by the ring, which appears on focus and parks on the next empty cell, so the caret being invisible costs nothing.
  • Tab order is the input, then Resend, then Verify; Enter in the field submits the form, which runs the same check as the Verify button. Every control has a visible :focus-visible ring in both themes.
  • prefers-reduced-motion: reduce lands, clears and validates instantly, keeping the colours and the messages. Every state is still reachable.

Requirements

  • GSAP 3.12+ (core only, no plugins)
  • No build step, no framework

Worked examples, the events and programmatic API, and the class reference ship with the download, alongside the full source.