OTP / PIN Code Input
Six segmented fields with auto-advance, smart paste, backspace navigation, and a satisfying error shake. Copy the prompt, paste it, ship it.
Live demo
Type a digit to advance, paste a 6-digit code to fill all cells, backspace to step back. Wrong codes shake; the right one auto-submits.
The Prompt
Build a one-time-code (OTP / PIN) input using vanilla HTML, CSS, and JavaScript. No libraries, no frameworks — zero dependencies.
VISUAL DESIGN
- Six segmented input cells in a horizontal row with 10px gap between them
- Each cell: 48px wide, 56px tall, border-radius 10px, 1.5px border at rgba(255,255,255,0.1), background rgba(255,255,255,0.04)
- Centered 22px monospace-feel digit (use font-variant-numeric: tabular-nums for stable widths)
- Cyan caret color (#00c4ff)
- Hover state: brighter border and background
- Focus state: cyan border (#00c4ff), pale cyan background tint, 4px cyan glow ring
- Filled state: subtle cyan background tint and cyan-tinted border (so users see at a glance which cells are populated)
- Hide native number-input spinners (-webkit-appearance: none on inner/outer spin buttons; -moz-appearance: textfield)
- Status line below the row, min-height 18px so layout doesn't jump
VALIDATION STATES
- Success: all cells turn green (#00ff88) with green border and green tinted background, status text "Verified" in green
- Error: all cells turn red (#ff6b6b) with red border and red tinted background, status text "Invalid code, try again", and the row plays a 500ms shake keyframe (translate -2/4/-7/7/-7/4/-2)
- Cells lock to readOnly during success state to prevent further editing
INPUT BEHAVIOR
- type="text" with inputmode="numeric" so mobile keyboards show numpads (do not use type="number" — it allows scientific notation, "e", and signs)
- maxlength="1" per cell as a safety net, but ALSO sanitize the input event:
- On input: take only the first numeric character of the new value, replace cell value with it
- If the user typed a digit (cell was empty before, is now filled): focus the next empty cell
- Non-numeric characters are silently rejected
- Auto-advance only on a real new character — pressing the same digit twice should still move forward
- Always select() on focus so the cell's existing value is overwritten by the next keystroke
KEYBOARD NAVIGATION
- ArrowLeft: move focus to previous cell (no wrap)
- ArrowRight: move focus to next cell (no wrap)
- Backspace on an empty cell: clear and focus the previous cell (the most expected behavior — never lose a keystroke)
- Backspace on a filled cell: clear it and stay (the next backspace then steps back per rule above)
- Delete: clear current cell, stay
- Home: focus first cell
- End: focus last filled cell (or last cell if all filled)
- Tab / Shift+Tab: leave the group entirely (do not trap focus inside)
PASTE HANDLING
- Listen for the paste event on the container (or each cell)
- Strip non-numeric characters from clipboard data, then take the first 6 digits
- Distribute one digit per cell starting from the focused cell (or from cell 0 if pasted into the container)
- After paste, focus the cell after the last filled cell (or stay on the last cell if the row is full)
- If paste fills all cells, fire the onComplete callback automatically
API
- Initialize: createOtpInput(container, { length: 6, onChange: (code) => ..., onComplete: (code) => ..., validate: (code) => boolean | Promise<boolean> })
- Public methods on the returned controller: getValue(), setValue(string), clear(), focus(), setError(message), setSuccess(message), reset()
- onComplete fires when all cells are filled; if validate is provided, it runs validate(code) and applies setSuccess or setError automatically
ACCESSIBILITY
- Wrap the row in role="group" with an aria-label like "One-time verification code"
- Each input has an aria-label "Digit 1" through "Digit N" so screen readers can identify the position
- Status message in role="status", aria-live="polite" — announced when validation result changes
- autocomplete="one-time-code" on each input so iOS / browsers can auto-fill from SMS
- prefers-reduced-motion: skip the shake animation; show error state instantly
OUTPUT
- Single HTML file with embedded <style> and <script>
- Demo with a "validate against hardcoded 123456" example
- Buttons to manually trigger valid / invalid / clear states for testing
- Polished, professional aesthetic — should feel like the verification screens on a premium modern sign-in flow
- CopyThe full prompt, one click.
- PasteInto Cursor, Claude Code, or any agent.
- CompareAgainst the live demo above.
What’s Inside
Segmented Cells
Six independent inputs in a row, each holding exactly one numeric character with stable tabular widths.
Auto-Advance
Typing a digit instantly forwards focus to the next empty cell — no extra clicks, no Tab presses.
Smart Paste
Pasting a 6-digit code anywhere in the row strips non-digits and distributes one character per cell.
Backspace Step-Back
Backspace on an empty cell jumps focus to the previous one and clears it — the gesture every user expects.
Filled vs. Empty
Filled cells get a subtle cyan tint so partial progress is obvious at a glance.
Error Shake
A 500ms horizontal shake plus red recolor signals invalid codes without disrupting the page layout.
SMS Auto-Fill
autocomplete="one-time-code" lets iOS, Safari, and Chrome offer SMS codes inline above the keyboard.
Live Status Region
An aria-live polite status line announces success or error to screen readers without stealing focus.
Usage Guidelines
Use This When
- Verifying a one-time code from email or SMS for sign-in, password reset, or two-factor authentication
- Confirming a payment, transaction, or destructive action with a short numeric PIN
- Onboarding flows that need to confirm phone or email ownership before account creation
Not Ideal When
- The code contains letters or symbols — use a single text input with monospace font instead
- The code is longer than 8 characters — segmented cells become cramped and easy to misread
- The user needs to memorize and type the same code repeatedly — a normal password field with a reveal toggle is friendlier