08Tự điền SMS OTP
Hệ điều hành nhận ra mã trong tin nhắn đến và tự điền sẵn vào các ô.
Dùng choTự động điền mã SMS trên iOS và Android
autocompleteone-time-codeOS 감지

<main class="stage">
<div class="pin is-demo" role="group" aria-label="Mã xác minh">
<div class="pin__row">
<span class="pin__box">
<input class="pin__input" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="1" autocomplete="one-time-code" aria-label="Mã xác minh, chữ số 1">
<span class="pin__ghost" aria-hidden="true">1</span>
<span class="pin__scramble" aria-hidden="true"></span>
</span>
<span class="pin__box">
<input class="pin__input" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="1" autocomplete="one-time-code" aria-label="Mã xác minh, chữ số 2">
<span class="pin__ghost" aria-hidden="true">2</span>
<span class="pin__scramble" aria-hidden="true"></span>
</span>
<span class="pin__box">
<input class="pin__input" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="1" autocomplete="one-time-code" aria-label="Mã xác minh, chữ số 3">
<span class="pin__ghost" aria-hidden="true">3</span>
<span class="pin__scramble" aria-hidden="true"></span>
</span>
<span class="pin__box">
<input class="pin__input" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="1" autocomplete="one-time-code" aria-label="Mã xác minh, chữ số 4">
<span class="pin__ghost" aria-hidden="true">4</span>
<span class="pin__scramble" aria-hidden="true"></span>
</span>
<span class="pin__box">
<input class="pin__input" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="1" autocomplete="one-time-code" aria-label="Mã xác minh, chữ số 5">
<span class="pin__ghost" aria-hidden="true">5</span>
<span class="pin__scramble" aria-hidden="true"></span>
</span>
<span class="pin__box">
<input class="pin__input" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="1" autocomplete="one-time-code" aria-label="Mã xác minh, chữ số 6">
<span class="pin__ghost" aria-hidden="true">6</span>
<span class="pin__scramble" aria-hidden="true"></span>
</span>
</div>
<span class="pin__signal pin__signal--chip" aria-hidden="true"></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
$fg: $subject-ink;
@include stage($stage-paper);
.pin {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.pin__row {
position: relative;
display: flex;
gap: 8px;
}
.pin__box {
position: relative;
width: 38px;
height: 46px;
}
.pin__input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: 2px solid rgba($fg, .28);
border-radius: $r-control;
background: rgba($fg, .08);
color: $fg;
text-align: center;
font-weight: 600; font-size: 20px; line-height: 1;
outline: none;
transition: border-color $duration $easing;
-webkit-tap-highlight-color: transparent;
}
.pin__input:focus { border-color: $color; }
.pin__ghost {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: $fg;
font-weight: 600; font-size: 20px; line-height: 1;
opacity: 0;
pointer-events: none;
}
.pin__signal {
position: relative;
width: 22px;
height: 22px;
opacity: 0;
transform: scale(.6);
}
.pin__signal--chip {
background: $color;
border-radius: 6px 6px 6px 2px;
}
.pin__scramble {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: $color;
font-weight: 700; font-size: 20px; line-height: 1;
opacity: 0;
pointer-events: none;
}
.pin__box.is-scrambling .pin__input { color: transparent; caret-color: transparent; }
.pin__box.is-scrambling .pin__scramble { opacity: 1; }
.pin.is-demo .pin__ghost { animation: pin-ghost-burstfast $dur-loop linear infinite; }
@keyframes pin-ghost-burstfast {
0%, 100% { opacity: 0; }
10% { opacity: 1; }
48% { opacity: 1; }
62% { opacity: 0; }
}
.pin.is-demo .pin__signal--chip {
animation: pin-chip $dur-loop $easing infinite;
}
@keyframes pin-chip {
0% { opacity: 0; transform: translateY(-10px) scale(.8); }
10% { opacity: 1; transform: translateY(0) scale(1); }
40% { opacity: 1; transform: translateY(0) scale(1); }
58%, 100% { opacity: 0; transform: translateY(-10px) scale(.8); }
}
@media (prefers-reduced-motion: reduce) {
.pin__ghost, .pin__ghost--dot, .pin__ghost--digit, .pin__caret, .pin__signal,
.pin__row, .pin__timer-n, .pin__resend, .pin__input, .pin__scramble {
animation: none !important;
transition: none !important;
}
.pin.is-demo .pin__ghost,
.pin.is-demo .pin__ghost--digit { opacity: 1; }
.pin.is-demo .pin__ghost--dot { opacity: 0; }
.pin.is-demo .pin__caret { opacity: .9; transform: translateX(230px); }
.pin.is-demo .pin__signal { opacity: 1; transform: scale(1); }
.pin.is-demo .pin__signal--toggle::before { transform: translateX(16px); }
.pin.is-demo .pin__resend { opacity: 1; transform: scale(1); }
.pin__box.is-scrambling .pin__input { color: $fg; caret-color: auto; }
.pin__box.is-scrambling .pin__scramble { opacity: 0; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pin {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.pin__row {
position: relative;
display: flex;
gap: 8px;
}
.pin__box {
position: relative;
width: 38px;
height: 46px;
}
.pin__input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: 2px solid rgba(23, 20, 26, 0.28);
border-radius: 12px;
background: rgba(23, 20, 26, 0.08);
color: #17141a;
text-align: center;
font-weight: 600;
font-size: 20px;
line-height: 1;
outline: none;
transition: border-color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
-webkit-tap-highlight-color: transparent;
}
.pin__input:focus {
border-color: #2f6df6;
}
.pin__ghost {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: #17141a;
font-weight: 600;
font-size: 20px;
line-height: 1;
opacity: 0;
pointer-events: none;
}
.pin__signal {
position: relative;
width: 22px;
height: 22px;
opacity: 0;
transform: scale(0.6);
}
.pin__signal--chip {
background: #2f6df6;
border-radius: 6px 6px 6px 2px;
}
.pin__scramble {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: #2f6df6;
font-weight: 700;
font-size: 20px;
line-height: 1;
opacity: 0;
pointer-events: none;
}
.pin__box.is-scrambling .pin__input {
color: transparent;
caret-color: transparent;
}
.pin__box.is-scrambling .pin__scramble {
opacity: 1;
}
.pin.is-demo .pin__ghost {
animation: pin-ghost-burstfast 2s linear infinite;
}
@keyframes pin-ghost-burstfast {
0%, 100% {
opacity: 0;
}
10% {
opacity: 1;
}
48% {
opacity: 1;
}
62% {
opacity: 0;
}
}
.pin.is-demo .pin__signal--chip {
animation: pin-chip 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes pin-chip {
0% {
opacity: 0;
transform: translateY(-10px) scale(0.8);
}
10% {
opacity: 1;
transform: translateY(0) scale(1);
}
40% {
opacity: 1;
transform: translateY(0) scale(1);
}
58%, 100% {
opacity: 0;
transform: translateY(-10px) scale(0.8);
}
}
@media (prefers-reduced-motion: reduce) {
.pin__ghost, .pin__ghost--dot, .pin__ghost--digit, .pin__caret, .pin__signal,
.pin__row, .pin__timer-n, .pin__resend, .pin__input, .pin__scramble {
animation: none !important;
transition: none !important;
}
.pin.is-demo .pin__ghost,
.pin.is-demo .pin__ghost--digit {
opacity: 1;
}
.pin.is-demo .pin__ghost--dot {
opacity: 0;
}
.pin.is-demo .pin__caret {
opacity: 0.9;
transform: translateX(230px);
}
.pin.is-demo .pin__signal {
opacity: 1;
transform: scale(1);
}
.pin.is-demo .pin__signal--toggle::before {
transform: translateX(16px);
}
.pin.is-demo .pin__resend {
opacity: 1;
transform: scale(1);
}
.pin__box.is-scrambling .pin__input {
color: #17141a;
caret-color: auto;
}
.pin__box.is-scrambling .pin__scramble {
opacity: 0;
}
}(function () {
var pin = document.querySelector('.pin');
var boxes = [].slice.call(pin.querySelectorAll('.pin__input'));
var reduceMotionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
function wake() { pin.classList.remove('is-demo'); }
function scrambleSettle(input, finalDigit, delayStart) {
var boxEl = input.closest('.pin__box');
var scrambleEl = boxEl.querySelector('.pin__scramble');
if (!finalDigit || reduceMotionQuery.matches) {
scrambleEl.textContent = '';
boxEl.classList.remove('is-scrambling');
return;
}
setTimeout(function start() {
boxEl.classList.add('is-scrambling');
var stepsLeft = 9;
var delay = 24;
(function spin() {
if (stepsLeft > 0) {
scrambleEl.textContent = String(Math.floor(Math.random() * 10));
stepsLeft--;
delay = Math.round(delay * 1.34) + 2;
setTimeout(spin, delay);
} else {
scrambleEl.textContent = finalDigit;
setTimeout(function () { boxEl.classList.remove('is-scrambling'); }, 70);
}
})();
}, delayStart || 0);
}
boxes.forEach(function (b, i) {
b.addEventListener('focus', wake);
b.addEventListener('input', function () {
b.value = b.value.replace(/[^0-9]/g, '').slice(-1);
if (b.value) scrambleSettle(b, b.value, 0);
if (b.value && boxes[i + 1]) boxes[i + 1].focus({ preventScroll: true });
});
b.addEventListener('keydown', function (e) {
if (e.key === 'Backspace' && !b.value && boxes[i - 1]) boxes[i - 1].focus({ preventScroll: true });
});
b.addEventListener('paste', function (e) {
var t = (e.clipboardData || window.clipboardData).getData('text').replace(/[^0-9]/g, '');
if (!t) return;
e.preventDefault();
t.slice(0, 6).split('').forEach(function (ch, k) {
if (boxes[k]) { boxes[k].value = ch; scrambleSettle(boxes[k], ch, k * 90); }
});
(boxes[Math.min(t.length, 6) - 1] || boxes[0]).focus({ preventScroll: true });
});
});
})();import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./PinAutofillSmsOtp.module.scss";
function siblings(el: any): HTMLInputElement[] {
var row = el.closest("[data-pin-row]");
return row ? Array.prototype.slice.call(row.querySelectorAll("input")) : [];
}
export type PinAutofillSmsOtpProps = {
/** 칸 수 (기본 6) */
length?: number;
/** 6칸이 다 채워지면 호출 */
onComplete?: (code: string) => void;
/** 강조 색 */
color?: string;
className?: string;
};
type Vars = CSSProperties & { "--color"?: string };
export default function PinAutofillSmsOtp({
length = 6,
onComplete,
color = "#2f6df6",
className = "",
}: PinAutofillSmsOtpProps) {
const [values, setValues] = useState<string[]>(Array(length).fill(""));
const vars: Vars = { "--color": color };
const [scrambling, setScrambling] = useState<boolean[]>(Array(length).fill(false));
const [scrambleDisplay, setScrambleDisplay] = useState<string[]>(Array(length).fill(""));
function runScramble(index: number, finalDigit: string, delayStart = 0) {
const reduced =
typeof window !== "undefined" &&
window.matchMedia &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!finalDigit || reduced) return;
window.setTimeout(function start() {
setScrambling((prev) => {
const next = prev.slice();
next[index] = true;
return next;
});
let stepsLeft = 9;
let delay = 24;
(function spin() {
if (stepsLeft > 0) {
const randomDigit = String(Math.floor(Math.random() * 10));
setScrambleDisplay((prev) => {
const next = prev.slice();
next[index] = randomDigit;
return next;
});
stepsLeft--;
delay = Math.round(delay * 1.34) + 2;
window.setTimeout(spin, delay);
} else {
setScrambleDisplay((prev) => {
const next = prev.slice();
next[index] = finalDigit;
return next;
});
window.setTimeout(() => {
setScrambling((prev) => {
const next = prev.slice();
next[index] = false;
return next;
});
}, 70);
}
})();
}, delayStart);
}
function handleChange(e: any, i: number) {
const el = e.currentTarget;
const v = String(el.value).replace(/[^0-9]/g, "").slice(-1);
const next = values.slice();
next[i] = v;
setValues(next);
if (v) {
runScramble(i, v, 0);
const boxes = siblings(el);
if (boxes[i + 1]) boxes[i + 1].focus({ preventScroll: true });
}
if (next.every(Boolean)) onComplete?.(next.join(""));
}
function handleKeyDown(e: any, i: number) {
if (e.key === "Backspace" && !values[i]) {
const boxes = siblings(e.currentTarget);
if (boxes[i - 1]) boxes[i - 1].focus({ preventScroll: true });
}
}
function handlePaste(e: any) {
const text = String(e.clipboardData.getData("text")).replace(/[^0-9]/g, "").slice(0, length);
if (!text) return;
e.preventDefault();
const next = text.split("");
while (next.length < length) next.push("");
setValues(next);
next.forEach((ch, k) => {
if (ch) runScramble(k, ch, k * 90);
});
if (text.length === length) onComplete?.(text);
}
return (
<div className={`${styles.pin} ${className}`} style={vars} data-pin-row>
{values.map((v, i) => (
<span key={i} className={`${styles.boxWrap} ${scrambling[i] ? styles.scrambling : ""}`}>
<input
className={styles.box}
type="text"
inputMode="numeric"
pattern="[0-9]*"
maxLength={1}
autoComplete="one-time-code"
aria-label={`인증번호 ${i + 1}번째 자리`}
value={v}
onChange={(e: any) => handleChange(e, i)}
onKeyDown={(e: any) => handleKeyDown(e, i)}
onPaste={(e: any) => handlePaste(e)}
/>
<span className={styles.scramble} aria-hidden="true">
{scrambling[i] ? scrambleDisplay[i] : ""}
</span>
</span>
))}
</div>
);
}08 Tự điền SMS OTP — Diện tích đo được 0.749% · khung hình chuyển động 9/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · OTP Input UI: 9 tình huống kèm validation
Bài viết chứa demo này
OTP Input UI: 9 tình huống kèm validationOTP input ui là phần form nhận sáu chữ số, mỗi số một ô, muốn mượt thì phải lo cả nhảy ô kế, dán nguyên mã lẫn xử lý lỗi Xem tất cả demo trong bài này
Nếu bạn thích hiệu ứng này - demo tương tự
Giao diện đăng nhập · xác thực01 Thẻ đăng nhập kiểu kính mờ (glassmorphism)9 mẫu form đăng ký & đăng nhập copy-paste được
Giao diện danh sách · bảng01 Vuốt để xóa (reveal)9 mẫu List UI: swipe to delete css và phân trang
Giao diện lựa chọn01 Segmented control kiểu pill iOS9 Segmented Control UI — dán là chạy