UI Components Medium
OTP / PIN Input
One-time password input with individual digit boxes, auto-advance on type, paste support, backspace to go back, and length variants (4 and 6 digits).
Open in Lab
MCP
css vanilla-js
Targets: JS HTML React Native
Expo Snack
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg: #050910;
--card: #0d1117;
--border: rgba(255, 255, 255, 0.08);
--border-focus: rgba(255, 255, 255, 0.25);
--text: #f2f6ff;
--muted: #475569;
--accent: #38bdf8;
--success: #4ade80;
--error: #f87171;
--radius: 12px;
}
body {
background: var(--bg);
color: var(--text);
font-family: system-ui, -apple-system, sans-serif;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.page {
display: flex;
flex-direction: column;
gap: 2.5rem;
width: 100%;
max-width: 480px;
}
/* โโ Demo block โโ */
.demo-block {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.875rem;
}
.demo-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.09em;
color: var(--muted);
align-self: flex-start;
}
.demo-hint {
font-size: 0.82rem;
color: var(--muted);
}
/* โโ OTP group โโ */
.otp-group {
display: flex;
gap: 0.625rem;
align-items: center;
}
/* โโ Individual box โโ */
.otp-box {
width: 52px;
height: 58px;
background: var(--card);
border: 1.5px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-size: 1.4rem;
font-weight: 700;
text-align: center;
caret-color: var(--accent);
outline: none;
transition: border-color 0.2s, box-shadow 0.2s, transform 0.15s;
-webkit-appearance: none;
appearance: none;
/* Hide number spinners */
-moz-appearance: textfield;
}
.otp-box::-webkit-inner-spin-button,
.otp-box::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
.otp-box:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.18);
transform: scale(1.04);
}
.otp-box:not(:placeholder-shown) {
border-color: var(--border-focus);
}
/* Separator dash for 6-digit (between index 2 and 3) */
.otp-sep {
width: 12px;
height: 2px;
background: var(--border);
border-radius: 1px;
flex-shrink: 0;
}
/* โโ Error state โโ */
.otp-group--error .otp-box {
border-color: rgba(248, 113, 113, 0.5);
animation: shake 0.38s ease;
}
.otp-group--error .otp-box:focus {
border-color: var(--error);
box-shadow: 0 0 0 3px rgba(248, 113, 113, 0.15);
}
.otp-error-msg {
color: var(--error) !important;
}
@keyframes shake {
0%,
100% {
transform: translateX(0);
}
20% {
transform: translateX(-6px);
}
40% {
transform: translateX(6px);
}
60% {
transform: translateX(-4px);
}
80% {
transform: translateX(4px);
}
}
/* โโ Success state โโ */
.otp-group--success .otp-box {
border-color: rgba(74, 222, 128, 0.4);
}
.otp-group--success .otp-box:focus {
border-color: var(--success);
box-shadow: 0 0 0 3px rgba(74, 222, 128, 0.15);
}
.otp-success-msg {
color: var(--success) !important;
}
/* โโ Completed flash โโ */
.otp-group--filled .otp-box {
border-color: var(--accent);
background: rgba(56, 189, 248, 0.06);
}
@media (max-width: 400px) {
.otp-box {
width: 42px;
height: 48px;
font-size: 1.15rem;
}
}(function () {
"use strict";
// Init all otp-group elements on the page
document.querySelectorAll(".otp-group").forEach(function (group) {
initOTP(group);
});
function initOTP(group) {
const len = parseInt(group.dataset.length, 10) || 6;
const prefill = group.dataset.prefill || "";
// Build boxes (with optional separator for 6-digit at position 3)
for (let i = 0; i < len; i++) {
if (len === 6 && i === 3) {
const sep = document.createElement("span");
sep.className = "otp-sep";
sep.setAttribute("aria-hidden", "true");
group.appendChild(sep);
}
const input = document.createElement("input");
input.type = "text";
input.inputMode = "numeric";
input.maxLength = 1;
input.pattern = "[0-9]";
input.className = "otp-box";
input.setAttribute("aria-label", "Digit " + (i + 1));
input.setAttribute("autocomplete", i === 0 ? "one-time-code" : "off");
input.placeholder = "ยท";
group.appendChild(input);
}
const boxes = Array.from(group.querySelectorAll(".otp-box"));
// Pre-fill
if (prefill) {
boxes.forEach(function (box, i) {
box.value = prefill[i] || "";
});
}
// โโ Keydown โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
boxes.forEach(function (box, i) {
box.addEventListener("keydown", function (e) {
if (e.key === "Backspace") {
if (box.value) {
box.value = "";
} else if (i > 0) {
boxes[i - 1].focus();
boxes[i - 1].value = "";
}
e.preventDefault();
return;
}
if (e.key === "ArrowLeft" && i > 0) {
boxes[i - 1].focus();
e.preventDefault();
return;
}
if (e.key === "ArrowRight" && i < boxes.length - 1) {
boxes[i + 1].focus();
e.preventDefault();
return;
}
// Block non-digit printable characters before input fires
if (e.key.length === 1 && !/[0-9]/.test(e.key) && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
}
});
// โโ Input โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
box.addEventListener("input", function () {
const val = box.value.replace(/\D/g, "").slice(0, 1);
box.value = val;
if (val && i < boxes.length - 1) boxes[i + 1].focus();
checkComplete(group, boxes);
});
// โโ Focus: select existing value โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
box.addEventListener("focus", function () {
box.select();
});
// โโ Paste โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
box.addEventListener("paste", function (e) {
e.preventDefault();
const text = (e.clipboardData || window.clipboardData).getData("text").replace(/\D/g, "");
const chars = text.slice(0, boxes.length - i).split("");
chars.forEach(function (ch, j) {
if (boxes[i + j]) boxes[i + j].value = ch;
});
const next = Math.min(i + chars.length, boxes.length - 1);
boxes[next].focus();
checkComplete(group, boxes);
});
});
}
// โโ Completion handler โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function checkComplete(group, boxes) {
const value = boxes
.map(function (b) {
return b.value;
})
.join("");
if (value.length === boxes.length && !/[^0-9]/.test(value)) {
group.classList.add("otp-group--filled");
// Dispatch custom event
group.dispatchEvent(
new CustomEvent("otp:complete", { detail: { value: value }, bubbles: true })
);
console.log("[OTP complete]", value);
} else {
group.classList.remove("otp-group--filled");
}
}
// Listen globally for demonstration
document.addEventListener("otp:complete", function (e) {
console.log("OTP/PIN entered:", e.detail.value);
});
})();<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OTP / PIN Input</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page">
<!-- Demo 1: 6-digit OTP -->
<div class="demo-block">
<p class="demo-label">6-digit OTP โ default</p>
<div class="otp-group" data-length="6" id="otp-default" aria-label="One-time password">
<!-- boxes injected by JS -->
</div>
<p class="demo-hint">Enter your 6-digit verification code</p>
</div>
<!-- Demo 2: 4-digit PIN -->
<div class="demo-block">
<p class="demo-label">4-digit PIN</p>
<div class="otp-group" data-length="4" id="otp-pin" aria-label="PIN code">
</div>
<p class="demo-hint">Enter your 4-digit PIN</p>
</div>
<!-- Demo 3: Error state -->
<div class="demo-block">
<p class="demo-label">6-digit โ error state</p>
<div class="otp-group otp-group--error" data-length="6" id="otp-error" aria-label="Invalid code" data-prefill="123456">
</div>
<p class="demo-hint otp-error-msg" aria-live="polite">Invalid code. Please try again.</p>
</div>
<!-- Demo 4: Success state -->
<div class="demo-block">
<p class="demo-label">6-digit โ success state</p>
<div class="otp-group otp-group--success" data-length="6" id="otp-success" aria-label="Verified code" data-prefill="987654">
</div>
<p class="demo-hint otp-success-msg" aria-live="polite">โ Code verified successfully</p>
</div>
</div>
<script src="script.js"></script>
</body>
</html>import React, { useState, useRef } from "react";
import { View, Text, TextInput, TouchableOpacity, StyleSheet } from "react-native";
interface OtpInputProps {
length?: number;
onComplete?: (code: string) => void;
}
function OtpInput({ length = 6, onComplete }: OtpInputProps) {
const [code, setCode] = useState("");
const [focused, setFocused] = useState(false);
const inputRef = useRef<TextInput>(null);
const handleChange = (text: string) => {
const digits = text.replace(/[^0-9]/g, "").slice(0, length);
setCode(digits);
if (digits.length === length) {
onComplete?.(digits);
}
};
const handlePress = () => {
inputRef.current?.focus();
};
const boxes = Array.from({ length }, (_, i) => {
const digit = code[i] || "";
const isActive = focused && i === Math.min(code.length, length - 1);
return (
<TouchableOpacity
key={i}
style={[styles.box, isActive && styles.boxActive]}
onPress={handlePress}
activeOpacity={1}
>
<Text style={styles.digit}>{digit}</Text>
</TouchableOpacity>
);
});
return (
<View>
<View style={styles.boxRow}>{boxes}</View>
<TextInput
ref={inputRef}
value={code}
onChangeText={handleChange}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
keyboardType="number-pad"
maxLength={length}
style={styles.hiddenInput}
autoFocus
/>
</View>
);
}
// --- Demo ---
export default function App() {
const [otpCode, setOtpCode] = useState("");
const [verified, setVerified] = useState(false);
const handleComplete = (code: string) => {
setOtpCode(code);
};
const handleVerify = () => {
if (otpCode.length === 6) {
setVerified(true);
}
};
return (
<View style={styles.container}>
<Text style={styles.title}>Enter Verification Code</Text>
<Text style={styles.subtitle}>We sent a 6-digit code to your device</Text>
<View style={{ marginTop: 32 }}>
<OtpInput length={6} onComplete={handleComplete} />
</View>
<TouchableOpacity
style={[styles.verifyButton, otpCode.length < 6 && styles.verifyButtonDisabled]}
onPress={handleVerify}
disabled={otpCode.length < 6}
activeOpacity={0.7}
>
<Text style={styles.verifyText}>{verified ? "โ Verified" : "Verify"}</Text>
</TouchableOpacity>
{verified && <Text style={styles.successText}>Code verified successfully!</Text>}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#0f172a",
alignItems: "center",
justifyContent: "center",
padding: 24,
},
title: {
color: "#f8fafc",
fontSize: 22,
fontWeight: "700",
marginBottom: 8,
},
subtitle: {
color: "#94a3b8",
fontSize: 14,
},
boxRow: {
flexDirection: "row",
gap: 10,
},
box: {
width: 48,
height: 56,
borderRadius: 10,
borderWidth: 2,
borderColor: "#334155",
backgroundColor: "#1e293b",
alignItems: "center",
justifyContent: "center",
},
boxActive: {
borderColor: "#6366f1",
shadowColor: "#6366f1",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.3,
shadowRadius: 8,
},
digit: {
color: "#f8fafc",
fontSize: 24,
fontWeight: "700",
},
hiddenInput: {
position: "absolute",
width: 1,
height: 1,
opacity: 0,
},
verifyButton: {
marginTop: 32,
backgroundColor: "#6366f1",
paddingHorizontal: 48,
paddingVertical: 14,
borderRadius: 10,
},
verifyButtonDisabled: {
opacity: 0.4,
},
verifyText: {
color: "#fff",
fontSize: 16,
fontWeight: "700",
},
successText: {
color: "#22c55e",
fontSize: 14,
marginTop: 16,
fontWeight: "600",
},
});OTP / PIN Input
Individual digit input boxes with auto-advance, backspace navigation, and paste support. Four variants cover the most common verification UX patterns.
Variants
- 6-digit OTP โ default verification code input
- 4-digit PIN โ shorter PIN entry
- 6-digit error โ red-bordered invalid state with error message
- 6-digit success โ green-bordered confirmed state
How it works
- Each box is an
<input maxlength="1" inputmode="numeric">in a flex row - On a valid digit keypress, the value is set and focus moves to the next box
- On
Backspace, if the current box is empty, focus moves to the previous box - On
paste, the pasted string is split across boxes left-to-right; excess characters are ignored - When the last digit is filled, the group dispatches a
completeevent with the full code value
States
- Default โ neutral border, accent focus ring
- Error โ red border on all boxes, shake animation, error label below
- Success โ green border on all boxes, checkmark label below