OTP Input UI: 9 tình huống kèm validation
OTP 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, kèm cả validation.
01 pin-auto-advance
02 pin-backspace-prev
03 pin-paste-full-code
04 pin-mobile-numeric-keypad
05 pin-masked-toggle
06 pin-error-shake-clear
07 pin-resend-timer
08 pin-autofill-sms-otp
09 pin-reduced-motion-static
Tự chạy · bấm ô để tới mục đó · cả 9 trong một zip
- 01 Tự chuyển sang ô kế tiếp
- 02 Backspace về ô trước
- 03 Dán cả mã chia vào từng ô
- 04 Ép bàn phím số trên mobile
- 05 Bật tắt chế độ che số
- 06 Rung và xoá khi sai
- 07 Đếm ngược gửi lại
- 08 Tự điền SMS OTP
- 09 Focus tĩnh khi giảm chuyển động
Chín mục không xếp theo độ nổi tiếng mà theo đúng trình tự một form nhập mã OTP thật sự trải qua. Đầu tiên là dòng chảy bắt đầu nhập (01~04), kế đến xác nhận và bảo mật (05~06), rồi gửi lại và tự điền (07~08), cuối cùng là tiếp cận (09). Mỗi mục giữ đúng một vai trò trên màn hình và chỉ nhặt đúng thuộc tính, sự kiện cần cho khoảnh khắc đó.
01Tự động nhảy ô kế tiếp
Chuyển động thế nào
Gõ xong một số, con trỏ tự nhảy sang ô kế tiếp.

<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>
<span class="pin__caret" aria-hidden="true"></span>
</div>
<span class="pin__signal pin__signal--check" aria-hidden="true"></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
$fg: $subject-cream;
@include stage($stage-ink);
.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__scramble {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: $color;
font-weight: 600; font-size: 20px; line-height: 1;
font-variant-numeric: tabular-nums;
opacity: 0;
pointer-events: none;
}
.pin__box.is-scrambling .pin__input { color: transparent; }
.pin__box.is-scrambling .pin__scramble { opacity: 1; }
.pin__caret {
position: absolute;
left: 0;
top: -9px;
width: 38px;
height: 3px;
border-radius: $r-pill;
background: $color;
opacity: 0;
}
.pin__signal {
position: relative;
width: 22px;
height: 22px;
opacity: 0;
transform: scale(.6);
}
.pin__signal--check::before,
.pin__signal--check::after {
content: "";
position: absolute;
background: $color;
border-radius: 2px;
}
.pin__signal--check::before { left: 6px; top: 12px; width: 6px; height: 3px; transform: rotate(225deg); transform-origin: left center; } // 같은 꼭짓점(6,12)
.pin__signal--check::after { left: 6px; top: 12px; width: 13px; height: 3px; transform: rotate(-45deg); transform-origin: left center; }
.pin.is-demo .pin__ghost { animation: pin-ghost $dur-loop linear infinite; }
@for $i from 1 through 6 {
.pin.is-demo .pin__box:nth-child(#{$i}) .pin__ghost { animation-delay: #{($i - 1) * 90}ms; }
}
@keyframes pin-ghost {
0%, 100% { opacity: 0; }
7% { opacity: 1; }
68% { opacity: 1; }
76% { opacity: 0; }
}
.pin.is-demo .pin__caret {
animation: pin-caret $dur-loop steps(1) infinite;
}
@keyframes pin-caret {
0% { opacity: .9; transform: translateX(0); }
5% { opacity: .9; transform: translateX(46px); }
9% { opacity: .9; transform: translateX(92px); }
14% { opacity: .9; transform: translateX(138px); }
18% { opacity: .9; transform: translateX(184px); }
23% { opacity: .9; transform: translateX(230px); }
76% { opacity: .9; transform: translateX(230px); }
84%, 100% { opacity: 0; transform: translateX(230px); }
}
.pin.is-demo .pin__signal--check { animation: pin-signal $dur-loop linear infinite; }
@keyframes pin-signal {
0%, 78% { opacity: 0; transform: scale(.6); }
86% { opacity: 1; transform: scale(1.12); }
94%, 100% { opacity: 1; transform: scale(1); }
}
@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 {
animation: none !important;
transition: none !important;
}
.pin__scramble { display: none !important; }
.pin__box.is-scrambling .pin__input { color: $fg !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); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
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(255, 247, 230, 0.28);
border-radius: 12px;
background: rgba(255, 247, 230, 0.08);
color: #fff7e6;
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: #fff7e6;
font-weight: 600;
font-size: 20px;
line-height: 1;
opacity: 0;
pointer-events: none;
}
.pin__scramble {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: #2f6df6;
font-weight: 600;
font-size: 20px;
line-height: 1;
font-variant-numeric: tabular-nums;
opacity: 0;
pointer-events: none;
}
.pin__box.is-scrambling .pin__input {
color: transparent;
}
.pin__box.is-scrambling .pin__scramble {
opacity: 1;
}
.pin__caret {
position: absolute;
left: 0;
top: -9px;
width: 38px;
height: 3px;
border-radius: 999px;
background: #2f6df6;
opacity: 0;
}
.pin__signal {
position: relative;
width: 22px;
height: 22px;
opacity: 0;
transform: scale(0.6);
}
.pin__signal--check::before,
.pin__signal--check::after {
content: "";
position: absolute;
background: #2f6df6;
border-radius: 2px;
}
.pin__signal--check::before {
left: 6px;
top: 12px;
width: 6px;
height: 3px;
transform: rotate(225deg);
transform-origin: left center;
}
.pin__signal--check::after {
left: 6px;
top: 12px;
width: 13px;
height: 3px;
transform: rotate(-45deg);
transform-origin: left center;
}
.pin.is-demo .pin__ghost {
animation: pin-ghost 2s linear infinite;
}
.pin.is-demo .pin__box:nth-child(1) .pin__ghost {
animation-delay: 0ms;
}
.pin.is-demo .pin__box:nth-child(2) .pin__ghost {
animation-delay: 90ms;
}
.pin.is-demo .pin__box:nth-child(3) .pin__ghost {
animation-delay: 180ms;
}
.pin.is-demo .pin__box:nth-child(4) .pin__ghost {
animation-delay: 270ms;
}
.pin.is-demo .pin__box:nth-child(5) .pin__ghost {
animation-delay: 360ms;
}
.pin.is-demo .pin__box:nth-child(6) .pin__ghost {
animation-delay: 450ms;
}
@keyframes pin-ghost {
0%, 100% {
opacity: 0;
}
7% {
opacity: 1;
}
68% {
opacity: 1;
}
76% {
opacity: 0;
}
}
.pin.is-demo .pin__caret {
animation: pin-caret 2s steps(1) infinite;
}
@keyframes pin-caret {
0% {
opacity: 0.9;
transform: translateX(0);
}
5% {
opacity: 0.9;
transform: translateX(46px);
}
9% {
opacity: 0.9;
transform: translateX(92px);
}
14% {
opacity: 0.9;
transform: translateX(138px);
}
18% {
opacity: 0.9;
transform: translateX(184px);
}
23% {
opacity: 0.9;
transform: translateX(230px);
}
76% {
opacity: 0.9;
transform: translateX(230px);
}
84%, 100% {
opacity: 0;
transform: translateX(230px);
}
}
.pin.is-demo .pin__signal--check {
animation: pin-signal 2s linear infinite;
}
@keyframes pin-signal {
0%, 78% {
opacity: 0;
transform: scale(0.6);
}
86% {
opacity: 1;
transform: scale(1.12);
}
94%, 100% {
opacity: 1;
transform: scale(1);
}
}
@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 {
animation: none !important;
transition: none !important;
}
.pin__scramble {
display: none !important;
}
.pin__box.is-scrambling .pin__input {
color: #fff7e6 !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);
}
}(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 cancelScramble(box) {
if (box._scrambleTimer) {
clearTimeout(box._scrambleTimer);
box._scrambleTimer = null;
}
}
function scrambleDigit(box, finalDigit) {
var display = box.querySelector('.pin__scramble');
if (!display) return;
cancelScramble(box);
if (reduceMotionQuery.matches) {
box.classList.remove('is-scrambling');
display.textContent = '';
return;
}
box.classList.add('is-scrambling');
var startedAt = Date.now();
var totalMs = 280; // 스크램블 총 지속시간
var delay = 24; // 초기 간격(빠름)
var growth = 1.35; // 감속 배율
var maxDelay = 200; // 최대 간격(느려짐)
function tick() {
var elapsed = Date.now() - startedAt;
if (elapsed >= totalMs) {
display.textContent = finalDigit;
box._scrambleTimer = setTimeout(function () {
box.classList.remove('is-scrambling');
display.textContent = '';
box._scrambleTimer = null;
}, 70);
return;
}
display.textContent = String(Math.floor(Math.random() * 10));
delay = Math.min(delay * growth, maxDelay);
box._scrambleTimer = setTimeout(tick, delay);
}
tick();
}
boxes.forEach(function (b, i) {
var box = b.closest('.pin__box');
b.addEventListener('focus', wake);
b.addEventListener('input', function () {
b.value = b.value.replace(/[^0-9]/g, '').slice(-1);
if (b.value) {
scrambleDigit(box, b.value);
if (boxes[i + 1]) boxes[i + 1].focus({ preventScroll: true });
} else {
cancelScramble(box);
box.classList.remove('is-scrambling');
var display = box.querySelector('.pin__scramble');
if (display) display.textContent = '';
}
});
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;
scrambleDigit(boxes[k].closest('.pin__box'), ch);
}
});
(boxes[Math.min(t.length, 6) - 1] || boxes[0]).focus({ preventScroll: true });
});
});
})();import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./PinAutoAdvance.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 PinAutoAdvanceProps = {
/** 칸 수 (기본 6) */
length?: number;
/** 6칸이 다 채워지면 호출 */
onComplete?: (code: string) => void;
/** 강조 색 */
color?: string;
className?: string;
};
type Vars = CSSProperties & { "--color"?: string };
// useRef 는 T | null 을 돌려준다. 값이 항상 있는 상자는 이렇게 좁혀 쓴다 (077 관례)
type Box<T> = { current: T };
type TimerMap = Record<number, ReturnType<typeof setTimeout> | null>;
export default function PinAutoAdvance({
length = 6,
onComplete,
color = "#2f6df6",
className = "",
}: PinAutoAdvanceProps) {
const [values, setValues] = useState<string[]>(Array(length).fill(""));
const [scrambleText, setScrambleText] = useState<string[]>(Array(length).fill(""));
const [scrambling, setScrambling] = useState<boolean[]>(Array(length).fill(false));
const scrambleTimers = useRef<TimerMap>({}) as Box<TimerMap>;
const vars: Vars = { "--color": color };
// 슬롯머신 스크램블: 최종 숫자를 바로 보여주지 않고 무작위 숫자가 지나가다 감속하며 정착한다
function cancelScramble(i: number) {
const timer = scrambleTimers.current[i];
if (timer) {
clearTimeout(timer);
scrambleTimers.current[i] = null;
}
}
function setScramblingAt(i: number, on: boolean) {
setScrambling((prev) => {
const next = prev.slice();
next[i] = on;
return next;
});
}
function setScrambleTextAt(i: number, text: string) {
setScrambleText((prev) => {
const next = prev.slice();
next[i] = text;
return next;
});
}
function runScramble(i: number, finalDigit: string) {
cancelScramble(i);
const reduceMotion =
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduceMotion) {
setScramblingAt(i, false);
setScrambleTextAt(i, "");
return;
}
setScramblingAt(i, true);
const startedAt = Date.now();
const totalMs = 280; // 스크램블 총 지속시간
const growth = 1.35; // 감속 배율
const maxDelay = 200; // 최대 간격(느려짐)
let delay = 24; // 초기 간격(빠름)
function tick() {
const elapsed = Date.now() - startedAt;
if (elapsed >= totalMs) {
setScrambleTextAt(i, finalDigit);
scrambleTimers.current[i] = setTimeout(() => {
setScramblingAt(i, false);
setScrambleTextAt(i, "");
scrambleTimers.current[i] = null;
}, 70);
return;
}
setScrambleTextAt(i, String(Math.floor(Math.random() * 10)));
delay = Math.min(delay * growth, maxDelay);
scrambleTimers.current[i] = setTimeout(tick, delay);
}
tick();
}
useEffect(() => {
const timers: TimerMap = scrambleTimers.current;
return () => {
Object.keys(timers).forEach((key) => {
const timer = timers[Number(key)];
if (timer) clearTimeout(timer);
});
};
}, []);
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);
const boxes = siblings(el);
if (boxes[i + 1]) boxes[i + 1].focus({ preventScroll: true });
} else {
cancelScramble(i);
setScramblingAt(i, false);
setScrambleTextAt(i, "");
}
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);
text.split("").forEach((ch, k) => runScramble(k, ch));
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={scrambling[i] ? `${styles.boxWrap} ${styles.isScrambling}` : styles.boxWrap}
>
<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">{scrambleText[i]}</span>
</span>
))}
</div>
);
}01 · Tự chuyển sang ô kế tiếp — Diện tích đo được 0.907% · khung hình chuyển động 14/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Khi nào dùng otp input field ui
Cho màn hình nhận sáu chữ số liền mạch như xác thực đăng ký hay đăng nhập hai bước.
boxes.forEach(function (b, i) {
b.addEventListener('input', function () {
b.value = b.value.replace(/[^0-9]/g, '').slice(-1);
if (b.value && boxes[i + 1]) boxes[i + 1].focus({ preventScroll: true });
});
});
02Backspace quay về ô trước
Bạn thấy gì
Nhấn Backspace ở ô trống, con trỏ quay về ô trước và xóa số ở đó.

<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>
<span class="pin__caret" aria-hidden="true"></span>
</div>
<span class="pin__signal pin__signal--chevron" aria-hidden="true"></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-ink;
$fg: $subject-ink;
@include stage($stage-yellow);
.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__scramble {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: $color;
font-weight: 600; font-size: 20px; line-height: 1;
font-variant-numeric: tabular-nums;
opacity: 0;
pointer-events: none;
}
.pin__box.is-scrambling .pin__input { color: transparent; }
.pin__box.is-scrambling .pin__scramble { opacity: 1; }
.pin__caret {
position: absolute;
left: 0;
top: -9px;
width: 38px;
height: 3px;
border-radius: $r-pill;
background: $color;
opacity: 0;
}
.pin__signal {
position: relative;
width: 22px;
height: 22px;
opacity: 0;
transform: scale(.6);
}
.pin__signal--chevron::before,
.pin__signal--chevron::after {
content: "";
position: absolute;
left: 7px;
width: 11px;
height: 3px;
background: $color;
border-radius: 2px;
transform-origin: left center; // 규칙8: 셰브론 막대 왼쪽 꼭짓점 회전
}
.pin__signal--chevron::before { top: 10px; transform: rotate(45deg); }
.pin__signal--chevron::after { top: 12px; transform: rotate(-45deg); }
.pin.is-demo .pin__ghost { animation: pin-ghost $dur-loop linear infinite; }
@for $i from 1 through 6 {
.pin.is-demo .pin__box:nth-child(#{$i}) .pin__ghost { animation-delay: #{($i - 1) * 90}ms; }
}
.pin.is-demo .pin__box:nth-child(n + 5) .pin__ghost { animation-name: pin-ghost-erase; }
@keyframes pin-ghost {
0%, 100% { opacity: 0; }
7% { opacity: 1; }
68% { opacity: 1; }
76% { opacity: 0; }
}
@keyframes pin-ghost-erase {
0%, 100% { opacity: 0; }
7% { opacity: 1; }
40% { opacity: 1; }
47% { opacity: 0; }
}
.pin.is-demo .pin__caret {
animation: pin-caret-erase $dur-loop steps(1) infinite;
}
@keyframes pin-caret-erase {
0% { opacity: .9; transform: translateX(0); }
5% { opacity: .9; transform: translateX(46px); }
9% { opacity: .9; transform: translateX(92px); }
14% { opacity: .9; transform: translateX(138px); }
18% { opacity: .9; transform: translateX(184px); }
23% { opacity: .9; transform: translateX(230px); }
40% { opacity: .9; transform: translateX(230px); }
47% { opacity: .9; transform: translateX(184px); }
54% { opacity: .9; transform: translateX(138px); }
76% { opacity: .9; transform: translateX(138px); }
84%, 100% { opacity: 0; transform: translateX(138px); }
}
.pin.is-demo .pin__signal--chevron { animation: pin-signal $dur-loop linear infinite; }
@keyframes pin-signal {
0%, 78% { opacity: 0; transform: scale(.6); }
86% { opacity: 1; transform: scale(1.12); }
94%, 100% { opacity: 1; transform: scale(1); }
}
@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 {
animation: none !important;
transition: none !important;
}
.pin__scramble { display: none !important; }
.pin__box.is-scrambling .pin__input { color: $fg !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); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
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: #17141a;
}
.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__scramble {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: #17141a;
font-weight: 600;
font-size: 20px;
line-height: 1;
font-variant-numeric: tabular-nums;
opacity: 0;
pointer-events: none;
}
.pin__box.is-scrambling .pin__input {
color: transparent;
}
.pin__box.is-scrambling .pin__scramble {
opacity: 1;
}
.pin__caret {
position: absolute;
left: 0;
top: -9px;
width: 38px;
height: 3px;
border-radius: 999px;
background: #17141a;
opacity: 0;
}
.pin__signal {
position: relative;
width: 22px;
height: 22px;
opacity: 0;
transform: scale(0.6);
}
.pin__signal--chevron::before,
.pin__signal--chevron::after {
content: "";
position: absolute;
left: 7px;
width: 11px;
height: 3px;
background: #17141a;
border-radius: 2px;
transform-origin: left center;
}
.pin__signal--chevron::before {
top: 10px;
transform: rotate(45deg);
}
.pin__signal--chevron::after {
top: 12px;
transform: rotate(-45deg);
}
.pin.is-demo .pin__ghost {
animation: pin-ghost 2s linear infinite;
}
.pin.is-demo .pin__box:nth-child(1) .pin__ghost {
animation-delay: 0ms;
}
.pin.is-demo .pin__box:nth-child(2) .pin__ghost {
animation-delay: 90ms;
}
.pin.is-demo .pin__box:nth-child(3) .pin__ghost {
animation-delay: 180ms;
}
.pin.is-demo .pin__box:nth-child(4) .pin__ghost {
animation-delay: 270ms;
}
.pin.is-demo .pin__box:nth-child(5) .pin__ghost {
animation-delay: 360ms;
}
.pin.is-demo .pin__box:nth-child(6) .pin__ghost {
animation-delay: 450ms;
}
.pin.is-demo .pin__box:nth-child(n+5) .pin__ghost {
animation-name: pin-ghost-erase;
}
@keyframes pin-ghost {
0%, 100% {
opacity: 0;
}
7% {
opacity: 1;
}
68% {
opacity: 1;
}
76% {
opacity: 0;
}
}
@keyframes pin-ghost-erase {
0%, 100% {
opacity: 0;
}
7% {
opacity: 1;
}
40% {
opacity: 1;
}
47% {
opacity: 0;
}
}
.pin.is-demo .pin__caret {
animation: pin-caret-erase 2s steps(1) infinite;
}
@keyframes pin-caret-erase {
0% {
opacity: 0.9;
transform: translateX(0);
}
5% {
opacity: 0.9;
transform: translateX(46px);
}
9% {
opacity: 0.9;
transform: translateX(92px);
}
14% {
opacity: 0.9;
transform: translateX(138px);
}
18% {
opacity: 0.9;
transform: translateX(184px);
}
23% {
opacity: 0.9;
transform: translateX(230px);
}
40% {
opacity: 0.9;
transform: translateX(230px);
}
47% {
opacity: 0.9;
transform: translateX(184px);
}
54% {
opacity: 0.9;
transform: translateX(138px);
}
76% {
opacity: 0.9;
transform: translateX(138px);
}
84%, 100% {
opacity: 0;
transform: translateX(138px);
}
}
.pin.is-demo .pin__signal--chevron {
animation: pin-signal 2s linear infinite;
}
@keyframes pin-signal {
0%, 78% {
opacity: 0;
transform: scale(0.6);
}
86% {
opacity: 1;
transform: scale(1.12);
}
94%, 100% {
opacity: 1;
transform: scale(1);
}
}
@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 {
animation: none !important;
transition: none !important;
}
.pin__scramble {
display: none !important;
}
.pin__box.is-scrambling .pin__input {
color: #17141a !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);
}
}(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 cancelScramble(box) {
if (box._scrambleTimer) {
clearTimeout(box._scrambleTimer);
box._scrambleTimer = null;
}
}
function scrambleDigit(box, finalDigit) {
var display = box.querySelector('.pin__scramble');
if (!display) return;
cancelScramble(box);
if (reduceMotionQuery.matches) {
box.classList.remove('is-scrambling');
display.textContent = '';
return;
}
box.classList.add('is-scrambling');
var startedAt = Date.now();
var totalMs = 280; // 스크램블 총 지속시간
var delay = 24; // 초기 간격(빠름)
var growth = 1.35; // 감속 배율
var maxDelay = 200; // 최대 간격(느려짐)
function tick() {
var elapsed = Date.now() - startedAt;
if (elapsed >= totalMs) {
display.textContent = finalDigit;
box._scrambleTimer = setTimeout(function () {
box.classList.remove('is-scrambling');
display.textContent = '';
box._scrambleTimer = null;
}, 70);
return;
}
display.textContent = String(Math.floor(Math.random() * 10));
delay = Math.min(delay * growth, maxDelay);
box._scrambleTimer = setTimeout(tick, delay);
}
tick();
}
boxes.forEach(function (b, i) {
var box = b.closest('.pin__box');
b.addEventListener('focus', wake);
b.addEventListener('input', function () {
b.value = b.value.replace(/[^0-9]/g, '').slice(-1);
if (b.value) {
scrambleDigit(box, b.value);
if (boxes[i + 1]) boxes[i + 1].focus({ preventScroll: true });
} else {
cancelScramble(box);
box.classList.remove('is-scrambling');
var display = box.querySelector('.pin__scramble');
if (display) display.textContent = '';
}
});
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;
scrambleDigit(boxes[k].closest('.pin__box'), ch);
}
});
(boxes[Math.min(t.length, 6) - 1] || boxes[0]).focus({ preventScroll: true });
});
});
})();import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./PinBackspacePrev.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 PinBackspacePrevProps = {
/** 칸 수 (기본 6) */
length?: number;
/** 6칸이 다 채워지면 호출 */
onComplete?: (code: string) => void;
/** 강조 색 */
color?: string;
className?: string;
};
type Vars = CSSProperties & { "--color"?: string };
// useRef 는 T | null 을 돌려준다. 값이 항상 있는 상자는 이렇게 좁혀 쓴다 (077 관례)
type Box<T> = { current: T };
type TimerMap = Record<number, ReturnType<typeof setTimeout> | null>;
export default function PinBackspacePrev({
length = 6,
onComplete,
color = "#2f6df6",
className = "",
}: PinBackspacePrevProps) {
const [values, setValues] = useState<string[]>(Array(length).fill(""));
const [scrambleText, setScrambleText] = useState<string[]>(Array(length).fill(""));
const [scrambling, setScrambling] = useState<boolean[]>(Array(length).fill(false));
const scrambleTimers = useRef<TimerMap>({}) as Box<TimerMap>;
const vars: Vars = { "--color": color };
// 슬롯머신 스크램블: 최종 숫자를 바로 보여주지 않고 무작위 숫자가 지나가다 감속하며 정착한다
function cancelScramble(i: number) {
const timer = scrambleTimers.current[i];
if (timer) {
clearTimeout(timer);
scrambleTimers.current[i] = null;
}
}
function setScramblingAt(i: number, on: boolean) {
setScrambling((prev) => {
const next = prev.slice();
next[i] = on;
return next;
});
}
function setScrambleTextAt(i: number, text: string) {
setScrambleText((prev) => {
const next = prev.slice();
next[i] = text;
return next;
});
}
function runScramble(i: number, finalDigit: string) {
cancelScramble(i);
const reduceMotion =
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduceMotion) {
setScramblingAt(i, false);
setScrambleTextAt(i, "");
return;
}
setScramblingAt(i, true);
const startedAt = Date.now();
const totalMs = 280; // 스크램블 총 지속시간
const growth = 1.35; // 감속 배율
const maxDelay = 200; // 최대 간격(느려짐)
let delay = 24; // 초기 간격(빠름)
function tick() {
const elapsed = Date.now() - startedAt;
if (elapsed >= totalMs) {
setScrambleTextAt(i, finalDigit);
scrambleTimers.current[i] = setTimeout(() => {
setScramblingAt(i, false);
setScrambleTextAt(i, "");
scrambleTimers.current[i] = null;
}, 70);
return;
}
setScrambleTextAt(i, String(Math.floor(Math.random() * 10)));
delay = Math.min(delay * growth, maxDelay);
scrambleTimers.current[i] = setTimeout(tick, delay);
}
tick();
}
useEffect(() => {
const timers: TimerMap = scrambleTimers.current;
return () => {
Object.keys(timers).forEach((key) => {
const timer = timers[Number(key)];
if (timer) clearTimeout(timer);
});
};
}, []);
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);
const boxes = siblings(el);
if (boxes[i + 1]) boxes[i + 1].focus({ preventScroll: true });
} else {
cancelScramble(i);
setScramblingAt(i, false);
setScrambleTextAt(i, "");
}
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);
text.split("").forEach((ch, k) => runScramble(k, ch));
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={scrambling[i] ? `${styles.boxWrap} ${styles.isScrambling}` : styles.boxWrap}
>
<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">{scrambleText[i]}</span>
</span>
))}
</div>
);
}02 · Backspace về ô trước — Diện tích đo được 0.888% · khung hình chuyển động 18/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Dùng ở đâu
Gõ nhầm giữa chừng thì không phải xóa cả chuỗi từ đầu.
boxes.forEach(function (b, i) {
b.addEventListener('keydown', function (e) {
if (e.key === 'Backspace' && !b.value && boxes[i - 1]) {
boxes[i - 1].focus({ preventScroll: true });
}
});
});
03Dán cả mã, chia vào từng ô
Chuyện gì diễn ra trên màn hình
Dán mã sáu chữ số copy từ app mail hay tin nhắn là nó tự chia vào từng ô ngay một lần.

<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--check" aria-hidden="true"></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-ink;
$fg: $subject-cream;
@include stage($stage-orange);
.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__scramble {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: $color;
font-weight: 600; font-size: 20px; line-height: 1;
font-variant-numeric: tabular-nums;
opacity: 0;
pointer-events: none;
}
.pin__box.is-scrambling .pin__input { color: transparent; }
.pin__box.is-scrambling .pin__scramble { opacity: 1; }
.pin__signal {
position: relative;
width: 22px;
height: 22px;
opacity: 0;
transform: scale(.6);
}
.pin__signal--check::before,
.pin__signal--check::after {
content: "";
position: absolute;
background: $color;
border-radius: 2px;
}
.pin__signal--check::before { left: 6px; top: 12px; width: 6px; height: 3px; transform: rotate(225deg); transform-origin: left center; } // 같은 꼭짓점(6,12)
.pin__signal--check::after { left: 6px; top: 12px; width: 13px; height: 3px; transform: rotate(-45deg); transform-origin: left center; }
.pin.is-demo .pin__ghost { animation: pin-ghost-burst $dur-loop linear infinite; }
@keyframes pin-ghost-burst {
0%, 100% { opacity: 0; }
4% { opacity: 1; }
70% { opacity: 1; }
78% { opacity: 0; }
}
.pin.is-demo .pin__signal--check { animation: pin-signal $dur-loop linear infinite; }
@keyframes pin-signal {
0%, 78% { opacity: 0; transform: scale(.6); }
86% { opacity: 1; transform: scale(1.12); }
94%, 100% { opacity: 1; transform: scale(1); }
}
@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 {
animation: none !important;
transition: none !important;
}
.pin__scramble { display: none !important; }
.pin__box.is-scrambling .pin__input { color: $fg !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); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
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(255, 247, 230, 0.28);
border-radius: 12px;
background: rgba(255, 247, 230, 0.08);
color: #fff7e6;
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: #17141a;
}
.pin__ghost {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: #fff7e6;
font-weight: 600;
font-size: 20px;
line-height: 1;
opacity: 0;
pointer-events: none;
}
.pin__scramble {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: #17141a;
font-weight: 600;
font-size: 20px;
line-height: 1;
font-variant-numeric: tabular-nums;
opacity: 0;
pointer-events: none;
}
.pin__box.is-scrambling .pin__input {
color: transparent;
}
.pin__box.is-scrambling .pin__scramble {
opacity: 1;
}
.pin__signal {
position: relative;
width: 22px;
height: 22px;
opacity: 0;
transform: scale(0.6);
}
.pin__signal--check::before,
.pin__signal--check::after {
content: "";
position: absolute;
background: #17141a;
border-radius: 2px;
}
.pin__signal--check::before {
left: 6px;
top: 12px;
width: 6px;
height: 3px;
transform: rotate(225deg);
transform-origin: left center;
}
.pin__signal--check::after {
left: 6px;
top: 12px;
width: 13px;
height: 3px;
transform: rotate(-45deg);
transform-origin: left center;
}
.pin.is-demo .pin__ghost {
animation: pin-ghost-burst 2s linear infinite;
}
@keyframes pin-ghost-burst {
0%, 100% {
opacity: 0;
}
4% {
opacity: 1;
}
70% {
opacity: 1;
}
78% {
opacity: 0;
}
}
.pin.is-demo .pin__signal--check {
animation: pin-signal 2s linear infinite;
}
@keyframes pin-signal {
0%, 78% {
opacity: 0;
transform: scale(0.6);
}
86% {
opacity: 1;
transform: scale(1.12);
}
94%, 100% {
opacity: 1;
transform: scale(1);
}
}
@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 {
animation: none !important;
transition: none !important;
}
.pin__scramble {
display: none !important;
}
.pin__box.is-scrambling .pin__input {
color: #fff7e6 !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);
}
}(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 cancelScramble(box) {
if (box._scrambleTimer) {
clearTimeout(box._scrambleTimer);
box._scrambleTimer = null;
}
}
function scrambleDigit(box, finalDigit) {
var display = box.querySelector('.pin__scramble');
if (!display) return;
cancelScramble(box);
if (reduceMotionQuery.matches) {
box.classList.remove('is-scrambling');
display.textContent = '';
return;
}
box.classList.add('is-scrambling');
var startedAt = Date.now();
var totalMs = 280; // 스크램블 총 지속시간
var delay = 24; // 초기 간격(빠름)
var growth = 1.35; // 감속 배율
var maxDelay = 200; // 최대 간격(느려짐)
function tick() {
var elapsed = Date.now() - startedAt;
if (elapsed >= totalMs) {
display.textContent = finalDigit;
box._scrambleTimer = setTimeout(function () {
box.classList.remove('is-scrambling');
display.textContent = '';
box._scrambleTimer = null;
}, 70);
return;
}
display.textContent = String(Math.floor(Math.random() * 10));
delay = Math.min(delay * growth, maxDelay);
box._scrambleTimer = setTimeout(tick, delay);
}
tick();
}
boxes.forEach(function (b, i) {
var box = b.closest('.pin__box');
b.addEventListener('focus', wake);
b.addEventListener('input', function () {
b.value = b.value.replace(/[^0-9]/g, '').slice(-1);
if (b.value) {
scrambleDigit(box, b.value);
if (boxes[i + 1]) boxes[i + 1].focus({ preventScroll: true });
} else {
cancelScramble(box);
box.classList.remove('is-scrambling');
var display = box.querySelector('.pin__scramble');
if (display) display.textContent = '';
}
});
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;
scrambleDigit(boxes[k].closest('.pin__box'), ch);
}
});
(boxes[Math.min(t.length, 6) - 1] || boxes[0]).focus({ preventScroll: true });
});
});
})();import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./PinPasteFullCode.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 PinPasteFullCodeProps = {
/** 칸 수 (기본 6) */
length?: number;
/** 6칸이 다 채워지면 호출 */
onComplete?: (code: string) => void;
/** 강조 색 */
color?: string;
className?: string;
};
type Vars = CSSProperties & { "--color"?: string };
// useRef 는 T | null 을 돌려준다. 값이 항상 있는 상자는 이렇게 좁혀 쓴다 (077 관례)
type Box<T> = { current: T };
type TimerMap = Record<number, ReturnType<typeof setTimeout> | null>;
export default function PinPasteFullCode({
length = 6,
onComplete,
color = "#2f6df6",
className = "",
}: PinPasteFullCodeProps) {
const [values, setValues] = useState<string[]>(Array(length).fill(""));
const [scrambleText, setScrambleText] = useState<string[]>(Array(length).fill(""));
const [scrambling, setScrambling] = useState<boolean[]>(Array(length).fill(false));
const scrambleTimers = useRef<TimerMap>({}) as Box<TimerMap>;
const vars: Vars = { "--color": color };
// 슬롯머신 스크램블: 최종 숫자를 바로 보여주지 않고 무작위 숫자가 지나가다 감속하며 정착한다
function cancelScramble(i: number) {
const timer = scrambleTimers.current[i];
if (timer) {
clearTimeout(timer);
scrambleTimers.current[i] = null;
}
}
function setScramblingAt(i: number, on: boolean) {
setScrambling((prev) => {
const next = prev.slice();
next[i] = on;
return next;
});
}
function setScrambleTextAt(i: number, text: string) {
setScrambleText((prev) => {
const next = prev.slice();
next[i] = text;
return next;
});
}
function runScramble(i: number, finalDigit: string) {
cancelScramble(i);
const reduceMotion =
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduceMotion) {
setScramblingAt(i, false);
setScrambleTextAt(i, "");
return;
}
setScramblingAt(i, true);
const startedAt = Date.now();
const totalMs = 280; // 스크램블 총 지속시간
const growth = 1.35; // 감속 배율
const maxDelay = 200; // 최대 간격(느려짐)
let delay = 24; // 초기 간격(빠름)
function tick() {
const elapsed = Date.now() - startedAt;
if (elapsed >= totalMs) {
setScrambleTextAt(i, finalDigit);
scrambleTimers.current[i] = setTimeout(() => {
setScramblingAt(i, false);
setScrambleTextAt(i, "");
scrambleTimers.current[i] = null;
}, 70);
return;
}
setScrambleTextAt(i, String(Math.floor(Math.random() * 10)));
delay = Math.min(delay * growth, maxDelay);
scrambleTimers.current[i] = setTimeout(tick, delay);
}
tick();
}
useEffect(() => {
const timers: TimerMap = scrambleTimers.current;
return () => {
Object.keys(timers).forEach((key) => {
const timer = timers[Number(key)];
if (timer) clearTimeout(timer);
});
};
}, []);
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);
const boxes = siblings(el);
if (boxes[i + 1]) boxes[i + 1].focus({ preventScroll: true });
} else {
cancelScramble(i);
setScramblingAt(i, false);
setScrambleTextAt(i, "");
}
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);
text.split("").forEach((ch, k) => runScramble(k, ch));
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={scrambling[i] ? `${styles.boxWrap} ${styles.isScrambling}` : styles.boxWrap}
>
<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">{scrambleText[i]}</span>
</span>
))}
</div>
);
}03 · Dán cả mã chia vào từng ô — Diện tích đo được 0.432% · khung hình chuyển động 8/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Chỗ dùng
Ký tự không phải số bị lọc bỏ, ô thừa giữ nguyên.
pin.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;
});
});
04Ép bàn phím số trên mobile
Chuyển động thế nào
Trên mobile, bàn phím số bật thẳng thay cho bàn phím chữ.

<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>
<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>
<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>
<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>
<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>
<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>
<span class="pin__caret" aria-hidden="true"></span>
</div>
<span class="pin__signal pin__signal--check" 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__input.is-scrambling { color: rgba($fg, .55); }
.pin__input.is-settle { animation: pin-settle 180ms $easing; }
@keyframes pin-settle {
0% { transform: scale(1.15); }
100% { transform: scale(1); }
}
.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__caret {
position: absolute;
left: 0;
top: -9px;
width: 38px;
height: 3px;
border-radius: $r-pill;
background: $color;
opacity: 0;
}
.pin__signal {
position: relative;
width: 22px;
height: 22px;
opacity: 0;
transform: scale(.6);
}
.pin__signal--check::before,
.pin__signal--check::after {
content: "";
position: absolute;
background: $color;
border-radius: 2px;
}
.pin__signal--check::before { left: 6px; top: 12px; width: 6px; height: 3px; transform: rotate(225deg); transform-origin: left center; } // 같은 꼭짓점(6,12)
.pin__signal--check::after { left: 6px; top: 12px; width: 13px; height: 3px; transform: rotate(-45deg); transform-origin: left center; }
.pin.is-demo .pin__ghost { animation: pin-ghost $dur-loop linear infinite; }
@for $i from 1 through 6 {
.pin.is-demo .pin__box:nth-child(#{$i}) .pin__ghost { animation-delay: #{($i - 1) * 90}ms; }
}
@keyframes pin-ghost {
0%, 100% { opacity: 0; }
7% { opacity: 1; }
68% { opacity: 1; }
76% { opacity: 0; }
}
.pin.is-demo .pin__caret {
animation: pin-caret $dur-loop steps(1) infinite;
}
@keyframes pin-caret {
0% { opacity: .9; transform: translateX(0); }
5% { opacity: .9; transform: translateX(46px); }
9% { opacity: .9; transform: translateX(92px); }
14% { opacity: .9; transform: translateX(138px); }
18% { opacity: .9; transform: translateX(184px); }
23% { opacity: .9; transform: translateX(230px); }
76% { opacity: .9; transform: translateX(230px); }
84%, 100% { opacity: 0; transform: translateX(230px); }
}
.pin.is-demo .pin__signal--check { animation: pin-signal $dur-loop linear infinite; }
@keyframes pin-signal {
0%, 78% { opacity: 0; transform: scale(.6); }
86% { opacity: 1; transform: scale(1.12); }
94%, 100% { opacity: 1; transform: scale(1); }
}
@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 {
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); }
}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__input.is-scrambling {
color: rgba(23, 20, 26, 0.55);
}
.pin__input.is-settle {
animation: pin-settle 180ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
@keyframes pin-settle {
0% {
transform: scale(1.15);
}
100% {
transform: scale(1);
}
}
.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__caret {
position: absolute;
left: 0;
top: -9px;
width: 38px;
height: 3px;
border-radius: 999px;
background: #2f6df6;
opacity: 0;
}
.pin__signal {
position: relative;
width: 22px;
height: 22px;
opacity: 0;
transform: scale(0.6);
}
.pin__signal--check::before,
.pin__signal--check::after {
content: "";
position: absolute;
background: #2f6df6;
border-radius: 2px;
}
.pin__signal--check::before {
left: 6px;
top: 12px;
width: 6px;
height: 3px;
transform: rotate(225deg);
transform-origin: left center;
}
.pin__signal--check::after {
left: 6px;
top: 12px;
width: 13px;
height: 3px;
transform: rotate(-45deg);
transform-origin: left center;
}
.pin.is-demo .pin__ghost {
animation: pin-ghost 2s linear infinite;
}
.pin.is-demo .pin__box:nth-child(1) .pin__ghost {
animation-delay: 0ms;
}
.pin.is-demo .pin__box:nth-child(2) .pin__ghost {
animation-delay: 90ms;
}
.pin.is-demo .pin__box:nth-child(3) .pin__ghost {
animation-delay: 180ms;
}
.pin.is-demo .pin__box:nth-child(4) .pin__ghost {
animation-delay: 270ms;
}
.pin.is-demo .pin__box:nth-child(5) .pin__ghost {
animation-delay: 360ms;
}
.pin.is-demo .pin__box:nth-child(6) .pin__ghost {
animation-delay: 450ms;
}
@keyframes pin-ghost {
0%, 100% {
opacity: 0;
}
7% {
opacity: 1;
}
68% {
opacity: 1;
}
76% {
opacity: 0;
}
}
.pin.is-demo .pin__caret {
animation: pin-caret 2s steps(1) infinite;
}
@keyframes pin-caret {
0% {
opacity: 0.9;
transform: translateX(0);
}
5% {
opacity: 0.9;
transform: translateX(46px);
}
9% {
opacity: 0.9;
transform: translateX(92px);
}
14% {
opacity: 0.9;
transform: translateX(138px);
}
18% {
opacity: 0.9;
transform: translateX(184px);
}
23% {
opacity: 0.9;
transform: translateX(230px);
}
76% {
opacity: 0.9;
transform: translateX(230px);
}
84%, 100% {
opacity: 0;
transform: translateX(230px);
}
}
.pin.is-demo .pin__signal--check {
animation: pin-signal 2s linear infinite;
}
@keyframes pin-signal {
0%, 78% {
opacity: 0;
transform: scale(0.6);
}
86% {
opacity: 1;
transform: scale(1.12);
}
94%, 100% {
opacity: 1;
transform: scale(1);
}
}
@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 {
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);
}
}(function () {
var pin = document.querySelector('.pin');
var boxes = [].slice.call(pin.querySelectorAll('.pin__input'));
var reduceMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function wake() { pin.classList.remove('is-demo'); }
function scrambleToDigit(el, finalDigit, onSettle) {
if (reduceMotion) { el.value = finalDigit; if (onSettle) onSettle(); return; }
el.classList.add('is-scrambling');
var step = 0;
var steps = 7;
var delay = 22;
(function tick() {
if (step >= steps) {
el.value = finalDigit;
el.classList.remove('is-scrambling');
el.classList.add('is-settle');
setTimeout(function () { el.classList.remove('is-settle'); }, 200);
if (onSettle) onSettle();
return;
}
el.value = String(Math.floor(Math.random() * 10));
step++;
delay = Math.round(delay * 1.4);
setTimeout(tick, delay);
})();
}
boxes.forEach(function (b, i) {
b.addEventListener('focus', wake);
b.addEventListener('input', function () {
var digit = b.value.replace(/[^0-9]/g, '').slice(-1);
if (!digit) { b.value = ''; return; }
scrambleToDigit(b, digit, function () {
if (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; });
(boxes[Math.min(t.length, 6) - 1] || boxes[0]).focus({ preventScroll: true });
});
});
})();import { useState, useRef, useEffect } from "react";
import type { CSSProperties } from "react";
import styles from "./PinMobileNumericKeypad.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 PinMobileNumericKeypadProps = {
/** 칸 수 (기본 6) */
length?: number;
/** 6칸이 다 채워지면 호출 */
onComplete?: (code: string) => void;
/** 강조 색 */
color?: string;
className?: string;
};
type Vars = CSSProperties & { "--color"?: string };
// useRef 는 T | null 을 돌려준다. 값이 항상 있는 상자는 이렇게 좁혀 쓴다 (077 관례)
type Box<T> = { current: T };
type TimerList = Array<ReturnType<typeof setTimeout> | undefined>;
// useState setter — 스텁 환경에 Dispatch/SetStateAction 이 없어 로컬로 같은 모양을 쓴다
type ListSetter<T> = (v: T[] | ((prev: T[]) => T[])) => void;
export default function PinMobileNumericKeypad({
length = 6,
onComplete,
color = "#2f6df6",
className = "",
}: PinMobileNumericKeypadProps) {
const [values, setValues] = useState<string[]>(Array(length).fill(""));
const [displayValues, setDisplayValues] = useState<string[]>(Array(length).fill(""));
const [animState, setAnimState] = useState<Array<"idle" | "scrambling" | "settle">>(Array(length).fill("idle"));
const vars: Vars = { "--color": color };
const reduceMotionRef = useRef(false) as Box<boolean>;
const timersRef = useRef<TimerList>([]) as Box<TimerList>;
useEffect(() => {
reduceMotionRef.current = typeof window !== "undefined" && !!window.matchMedia
? window.matchMedia("(prefers-reduced-motion: reduce)").matches
: false;
return () => { timersRef.current.forEach((t) => t && clearTimeout(t)); };
}, []);
function updateAt<T>(setter: ListSetter<T>, index: number, val: T) {
setter((prev) => {
const next = prev.slice();
next[index] = val;
return next;
});
}
function scrambleToDigit(index: number, finalDigit: string, onSettle: () => void) {
if (reduceMotionRef.current) {
updateAt(setDisplayValues, index, finalDigit);
onSettle();
return;
}
updateAt(setAnimState, index, "scrambling");
let step = 0;
const steps = 7;
let delay = 22;
const tick = () => {
if (step >= steps) {
updateAt(setDisplayValues, index, finalDigit);
updateAt(setAnimState, index, "settle");
timersRef.current[index] = setTimeout(() => {
updateAt(setAnimState, index, "idle");
}, 200);
onSettle();
return;
}
updateAt(setDisplayValues, index, String(Math.floor(Math.random() * 10)));
step++;
delay = Math.round(delay * 1.4);
timersRef.current[index] = setTimeout(tick, delay);
};
tick();
}
function handleChange(e: any, i: number) {
const el = e.currentTarget;
const v = String(el.value).replace(/[^0-9]/g, "").slice(-1);
if (!v) {
const next = values.slice();
next[i] = "";
setValues(next);
updateAt(setDisplayValues, i, "");
return;
}
const committed = values.slice();
committed[i] = v;
setValues(committed);
scrambleToDigit(i, v, () => {
const boxes = siblings(el);
if (boxes[i + 1]) boxes[i + 1].focus({ preventScroll: true });
if (committed.every(Boolean)) onComplete?.(committed.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);
setDisplayValues(next.slice());
if (text.length === length) onComplete?.(text);
}
return (
<div className={`${styles.pin} ${className}`} style={vars} data-pin-row>
{displayValues.map((v, i) => (
<input
key={i}
className={[
styles.box,
animState[i] === "scrambling" ? styles.scrambling : "",
animState[i] === "settle" ? styles.settle : "",
].join(" ").trim()}
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)}
/>
))}
</div>
);
}04 · Ép bàn phím số trên mobile — Diện tích đo được 0.8% · khung hình chuyển động 14/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Dùng khi nào
Chỉ hai thuộc tính inputmode và pattern là đủ, otp input html css thuần không cần JS riêng.
<input
type="text"
inputmode="numeric"
pattern="[0-9]*"
maxlength="1"
autocomplete="one-time-code"
aria-label="Ô mã OTP thứ 1"
>
05Bật tắt chế độ che số
Bạn thấy gì
Bấm icon con mắt, các số đã nhập hiện rõ thay vì các dấu chấm.

<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 pin__ghost--dot" aria-hidden="true">•</span><span class="pin__ghost pin__ghost--digit" aria-hidden="true">1</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 pin__ghost--dot" aria-hidden="true">•</span><span class="pin__ghost pin__ghost--digit" aria-hidden="true">2</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 pin__ghost--dot" aria-hidden="true">•</span><span class="pin__ghost pin__ghost--digit" aria-hidden="true">3</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 pin__ghost--dot" aria-hidden="true">•</span><span class="pin__ghost pin__ghost--digit" aria-hidden="true">4</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 pin__ghost--dot" aria-hidden="true">•</span><span class="pin__ghost pin__ghost--digit" aria-hidden="true">5</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 pin__ghost--dot" aria-hidden="true">•</span><span class="pin__ghost pin__ghost--digit" aria-hidden="true">6</span>
</span>
</div>
<button type="button" class="pin__mask-toggle" aria-pressed="false">Hiện chữ số</button>
<span class="pin__signal pin__signal--eye" aria-hidden="true"></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
$fg: $subject-cream;
@include stage($stage-ink);
.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__input.is-scrambling { color: rgba($fg, .55); }
.pin__input.is-settle { animation: pin-settle 180ms $easing; }
@keyframes pin-settle {
0% { transform: scale(1.15); }
100% { transform: scale(1); }
}
.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--eye {
border: 2px solid $color;
border-radius: 50% / 60% 60% 40% 40%;
}
.pin__signal--eye::before {
content: "";
position: absolute;
left: 50%;
top: 50%;
width: 6px;
height: 6px;
margin: -3px 0 0 -3px;
background: $color;
border-radius: 50%;
}
.pin.is-demo .pin__ghost--dot { animation: pin-ghost-dot $dur-loop linear infinite; }
.pin.is-demo .pin__ghost--digit { animation: pin-ghost-digit $dur-loop linear infinite; }
@for $i from 1 through 6 {
.pin.is-demo .pin__box:nth-child(#{$i}) .pin__ghost--dot,
.pin.is-demo .pin__box:nth-child(#{$i}) .pin__ghost--digit { animation-delay: #{($i - 1) * 40}ms; }
}
@keyframes pin-ghost-dot {
0%, 100% { opacity: 0; }
7% { opacity: 1; }
44% { opacity: 1; }
50% { opacity: 0; }
78% { opacity: 0; }
84% { opacity: 1; }
96% { opacity: 1; }
}
@keyframes pin-ghost-digit {
0%, 50%, 100% { opacity: 0; }
56% { opacity: 1; }
74% { opacity: 1; }
80% { opacity: 0; }
}
.pin.is-demo .pin__signal--eye { animation: pin-eye $dur-loop linear infinite; }
@keyframes pin-eye {
0%, 50%, 100% { opacity: .55; transform: scaleY(1); }
53% { transform: scaleY(.15); }
56%, 78% { opacity: 1; transform: scaleY(1); }
81% { transform: scaleY(.15); }
84% { transform: scaleY(1); }
}
.pin__mask-toggle {
border: 0;
border-radius: $r-pill;
padding: 8px 16px;
font-weight: 600; font-size: 12px; line-height: 1;
background: rgba($fg, .14);
color: $fg;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
@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 {
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); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
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(255, 247, 230, 0.28);
border-radius: 12px;
background: rgba(255, 247, 230, 0.08);
color: #fff7e6;
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__input.is-scrambling {
color: rgba(255, 247, 230, 0.55);
}
.pin__input.is-settle {
animation: pin-settle 180ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
@keyframes pin-settle {
0% {
transform: scale(1.15);
}
100% {
transform: scale(1);
}
}
.pin__ghost {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: #fff7e6;
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--eye {
border: 2px solid #2f6df6;
border-radius: 50%/60% 60% 40% 40%;
}
.pin__signal--eye::before {
content: "";
position: absolute;
left: 50%;
top: 50%;
width: 6px;
height: 6px;
margin: -3px 0 0 -3px;
background: #2f6df6;
border-radius: 50%;
}
.pin.is-demo .pin__ghost--dot {
animation: pin-ghost-dot 2s linear infinite;
}
.pin.is-demo .pin__ghost--digit {
animation: pin-ghost-digit 2s linear infinite;
}
.pin.is-demo .pin__box:nth-child(1) .pin__ghost--dot,
.pin.is-demo .pin__box:nth-child(1) .pin__ghost--digit {
animation-delay: 0ms;
}
.pin.is-demo .pin__box:nth-child(2) .pin__ghost--dot,
.pin.is-demo .pin__box:nth-child(2) .pin__ghost--digit {
animation-delay: 40ms;
}
.pin.is-demo .pin__box:nth-child(3) .pin__ghost--dot,
.pin.is-demo .pin__box:nth-child(3) .pin__ghost--digit {
animation-delay: 80ms;
}
.pin.is-demo .pin__box:nth-child(4) .pin__ghost--dot,
.pin.is-demo .pin__box:nth-child(4) .pin__ghost--digit {
animation-delay: 120ms;
}
.pin.is-demo .pin__box:nth-child(5) .pin__ghost--dot,
.pin.is-demo .pin__box:nth-child(5) .pin__ghost--digit {
animation-delay: 160ms;
}
.pin.is-demo .pin__box:nth-child(6) .pin__ghost--dot,
.pin.is-demo .pin__box:nth-child(6) .pin__ghost--digit {
animation-delay: 200ms;
}
@keyframes pin-ghost-dot {
0%, 100% {
opacity: 0;
}
7% {
opacity: 1;
}
44% {
opacity: 1;
}
50% {
opacity: 0;
}
78% {
opacity: 0;
}
84% {
opacity: 1;
}
96% {
opacity: 1;
}
}
@keyframes pin-ghost-digit {
0%, 50%, 100% {
opacity: 0;
}
56% {
opacity: 1;
}
74% {
opacity: 1;
}
80% {
opacity: 0;
}
}
.pin.is-demo .pin__signal--eye {
animation: pin-eye 2s linear infinite;
}
@keyframes pin-eye {
0%, 50%, 100% {
opacity: 0.55;
transform: scaleY(1);
}
53% {
transform: scaleY(0.15);
}
56%, 78% {
opacity: 1;
transform: scaleY(1);
}
81% {
transform: scaleY(0.15);
}
84% {
transform: scaleY(1);
}
}
.pin__mask-toggle {
border: 0;
border-radius: 999px;
padding: 8px 16px;
font-weight: 600;
font-size: 12px;
line-height: 1;
background: rgba(255, 247, 230, 0.14);
color: #fff7e6;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
@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 {
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);
}
}(function () {
var pin = document.querySelector('.pin');
var boxes = [].slice.call(pin.querySelectorAll('.pin__input'));
var reduceMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function wake() { pin.classList.remove('is-demo'); }
function scrambleToDigit(el, finalDigit, onSettle) {
if (reduceMotion) { el.value = finalDigit; if (onSettle) onSettle(); return; }
el.classList.add('is-scrambling');
var step = 0;
var steps = 7;
var delay = 22;
(function tick() {
if (step >= steps) {
el.value = finalDigit;
el.classList.remove('is-scrambling');
el.classList.add('is-settle');
setTimeout(function () { el.classList.remove('is-settle'); }, 200);
if (onSettle) onSettle();
return;
}
el.value = String(Math.floor(Math.random() * 10));
step++;
delay = Math.round(delay * 1.4);
setTimeout(tick, delay);
})();
}
boxes.forEach(function (b, i) {
b.addEventListener('focus', wake);
b.addEventListener('input', function () {
var digit = b.value.replace(/[^0-9]/g, '').slice(-1);
if (!digit) { b.value = ''; return; }
scrambleToDigit(b, digit, function () {
if (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; });
(boxes[Math.min(t.length, 6) - 1] || boxes[0]).focus({ preventScroll: true });
});
});
var toggle = pin.querySelector('.pin__mask-toggle');
var masked = true;
toggle.addEventListener('click', function () {
masked = !masked;
boxes.forEach(function (b) { b.type = masked ? 'password' : 'text'; });
toggle.setAttribute('aria-pressed', String(!masked));
toggle.textContent = masked ? 'Hiện chữ số' : 'Ẩn lại';
});
})();import { useState, useRef, useEffect } from "react";
import type { CSSProperties } from "react";
import styles from "./PinMaskedToggle.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 PinMaskedToggleProps = {
/** 칸 수 (기본 6) */
length?: number;
/** 6칸이 다 채워지면 호출 */
onComplete?: (code: string) => void;
/** 강조 색 */
color?: string;
className?: string;
/** 초기 마스킹 여부 (기본 true) */
masked?: boolean;
};
type Vars = CSSProperties & { "--color"?: string };
// useRef 는 T | null 을 돌려준다. 값이 항상 있는 상자는 이렇게 좁혀 쓴다 (077 관례)
type Box<T> = { current: T };
type TimerList = Array<ReturnType<typeof setTimeout> | undefined>;
// useState setter — 스텁 환경에 Dispatch/SetStateAction 이 없어 로컬로 같은 모양을 쓴다
type ListSetter<T> = (v: T[] | ((prev: T[]) => T[])) => void;
export default function PinMaskedToggle({
length = 6,
onComplete,
color = "#2f6df6",
className = "",
masked: maskedProp = true,
}: PinMaskedToggleProps) {
const [values, setValues] = useState<string[]>(Array(length).fill(""));
const [displayValues, setDisplayValues] = useState<string[]>(Array(length).fill(""));
const [animState, setAnimState] = useState<Array<"idle" | "scrambling" | "settle">>(Array(length).fill("idle"));
const vars: Vars = { "--color": color };
const [masked, setMasked] = useState(maskedProp);
const reduceMotionRef = useRef(false) as Box<boolean>;
const timersRef = useRef<TimerList>([]) as Box<TimerList>;
useEffect(() => {
reduceMotionRef.current = typeof window !== "undefined" && !!window.matchMedia
? window.matchMedia("(prefers-reduced-motion: reduce)").matches
: false;
return () => { timersRef.current.forEach((t) => t && clearTimeout(t)); };
}, []);
function updateAt<T>(setter: ListSetter<T>, index: number, val: T) {
setter((prev) => {
const next = prev.slice();
next[index] = val;
return next;
});
}
// 마스킹(●) 상태에선 실제 문자가 가려지므로 스크램블은 마스킹 해제 시에만 눈에 보인다
function scrambleToDigit(index: number, finalDigit: string, onSettle: () => void) {
if (reduceMotionRef.current) {
updateAt(setDisplayValues, index, finalDigit);
onSettle();
return;
}
updateAt(setAnimState, index, "scrambling");
let step = 0;
const steps = 7;
let delay = 22;
const tick = () => {
if (step >= steps) {
updateAt(setDisplayValues, index, finalDigit);
updateAt(setAnimState, index, "settle");
timersRef.current[index] = setTimeout(() => {
updateAt(setAnimState, index, "idle");
}, 200);
onSettle();
return;
}
updateAt(setDisplayValues, index, String(Math.floor(Math.random() * 10)));
step++;
delay = Math.round(delay * 1.4);
timersRef.current[index] = setTimeout(tick, delay);
};
tick();
}
function handleChange(e: any, i: number) {
const el = e.currentTarget;
const v = String(el.value).replace(/[^0-9]/g, "").slice(-1);
if (!v) {
const next = values.slice();
next[i] = "";
setValues(next);
updateAt(setDisplayValues, i, "");
return;
}
const committed = values.slice();
committed[i] = v;
setValues(committed);
scrambleToDigit(i, v, () => {
const boxes = siblings(el);
if (boxes[i + 1]) boxes[i + 1].focus({ preventScroll: true });
if (committed.every(Boolean)) onComplete?.(committed.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);
setDisplayValues(next.slice());
if (text.length === length) onComplete?.(text);
}
return (
<div className={`${styles.pin} ${className}`} style={vars} data-pin-row>
{displayValues.map((v, i) => (
<input
key={i}
className={[
styles.box,
animState[i] === "scrambling" ? styles.scrambling : "",
animState[i] === "settle" ? styles.settle : "",
].join(" ").trim()}
type={masked ? "password" : "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)}
/>
))}
<button
type="button"
className={styles.toggle}
aria-pressed={!masked}
onClick={() => setMasked((m) => !m)}
>
{masked ? "가려진 숫자 보기" : "다시 가리기"}
</button>
</div>
);
}05 · Bật tắt chế độ che số — Diện tích đo được 0.702% · khung hình chuyển động 16/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Dùng ở đâu
Hợp lúc bạn cần tự kiểm tra giá trị, như mật khẩu tài khoản trong app ngân hàng hay mã PIN thanh toán.
var masked = true;
toggle.addEventListener('click', function () {
masked = !masked;
boxes.forEach(function (b) {
b.type = masked ? 'password' : 'text';
});
toggle.textContent = masked ? 'Xem số đang ẩn' : 'Ẩn lại';
});
06Lỗi thì rung và dọn sạch
Chuyện gì diễn ra trên màn hình
Nhập mã sai, cả hàng ô rung lên rồi tự động sạch trơn.

<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>
<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>
<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>
<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>
<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>
<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>
</div>
<button type="button" class="pin__confirm">Xác nhận</button>
<p class="pin__status" role="status" hidden>Mã không khớp. Vui lòng nhập lại.</p>
<span class="pin__signal pin__signal--error" aria-hidden="true"></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $error;
$fg: $subject-ink;
@include stage($stage-yellow);
.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__input.is-scrambling { color: rgba($fg, .55); }
.pin__input.is-settle { animation: pin-settle 180ms $easing; }
@keyframes pin-settle {
0% { transform: scale(1.15); }
100% { transform: scale(1); }
}
.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--error {
border: 3px solid $color;
border-radius: 50%;
}
.pin__signal--error::before,
.pin__signal--error::after {
content: "";
position: absolute;
left: 50%;
top: 50%;
width: 2px;
height: 10px;
margin: -5px 0 0 -1px;
background: $color;
}
.pin__signal--error::before { transform: rotate(45deg); }
.pin__signal--error::after { transform: rotate(-45deg); }
.pin.is-demo .pin__ghost { animation: pin-ghost-fail $dur-loop linear infinite; }
@for $i from 1 through 6 {
.pin.is-demo .pin__box:nth-child(#{$i}) .pin__ghost { animation-delay: #{($i - 1) * 70}ms; }
}
@keyframes pin-ghost-fail {
0%, 100% { opacity: 0; }
7% { opacity: 1; }
62% { opacity: 1; }
68% { opacity: 0; }
}
.pin.is-demo .pin__row { animation: pin-shake $dur-loop $easing infinite; }
@keyframes pin-shake {
0%, 58%, 100% { transform: translateX(0); }
61% { transform: translateX(-6px); }
64% { transform: translateX(6px); }
67% { transform: translateX(-4px); }
70% { transform: translateX(4px); }
73%, 100% { transform: translateX(0); }
}
.pin__confirm {
border: 0;
border-radius: $r-pill;
padding: 8px 18px;
font-weight: 600; font-size: 13px; line-height: 1;
background: rgba($fg, .14);
color: $fg;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.pin__status {
margin: 0;
color: $color;
font-weight: 600; font-size: 12px; line-height: 1.4;
max-width: 220px;
text-align: center;
}
.pin.is-demo .pin__signal--error { animation: pin-signal-err $dur-loop linear infinite; }
@keyframes pin-signal-err {
0%, 60% { opacity: 0; transform: scale(.6); }
66% { opacity: 1; transform: scale(1.1); }
73%, 84% { opacity: 1; transform: scale(1); }
90%, 100% { opacity: 0; transform: scale(.7); }
}
@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 {
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); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
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: #c2361a;
}
.pin__input.is-scrambling {
color: rgba(23, 20, 26, 0.55);
}
.pin__input.is-settle {
animation: pin-settle 180ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
@keyframes pin-settle {
0% {
transform: scale(1.15);
}
100% {
transform: scale(1);
}
}
.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--error {
border: 3px solid #c2361a;
border-radius: 50%;
}
.pin__signal--error::before,
.pin__signal--error::after {
content: "";
position: absolute;
left: 50%;
top: 50%;
width: 2px;
height: 10px;
margin: -5px 0 0 -1px;
background: #c2361a;
}
.pin__signal--error::before {
transform: rotate(45deg);
}
.pin__signal--error::after {
transform: rotate(-45deg);
}
.pin.is-demo .pin__ghost {
animation: pin-ghost-fail 2s linear infinite;
}
.pin.is-demo .pin__box:nth-child(1) .pin__ghost {
animation-delay: 0ms;
}
.pin.is-demo .pin__box:nth-child(2) .pin__ghost {
animation-delay: 70ms;
}
.pin.is-demo .pin__box:nth-child(3) .pin__ghost {
animation-delay: 140ms;
}
.pin.is-demo .pin__box:nth-child(4) .pin__ghost {
animation-delay: 210ms;
}
.pin.is-demo .pin__box:nth-child(5) .pin__ghost {
animation-delay: 280ms;
}
.pin.is-demo .pin__box:nth-child(6) .pin__ghost {
animation-delay: 350ms;
}
@keyframes pin-ghost-fail {
0%, 100% {
opacity: 0;
}
7% {
opacity: 1;
}
62% {
opacity: 1;
}
68% {
opacity: 0;
}
}
.pin.is-demo .pin__row {
animation: pin-shake 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes pin-shake {
0%, 58%, 100% {
transform: translateX(0);
}
61% {
transform: translateX(-6px);
}
64% {
transform: translateX(6px);
}
67% {
transform: translateX(-4px);
}
70% {
transform: translateX(4px);
}
73%, 100% {
transform: translateX(0);
}
}
.pin__confirm {
border: 0;
border-radius: 999px;
padding: 8px 18px;
font-weight: 600;
font-size: 13px;
line-height: 1;
background: rgba(23, 20, 26, 0.14);
color: #17141a;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.pin__status {
margin: 0;
color: #c2361a;
font-weight: 600;
font-size: 12px;
line-height: 1.4;
max-width: 220px;
text-align: center;
}
.pin.is-demo .pin__signal--error {
animation: pin-signal-err 2s linear infinite;
}
@keyframes pin-signal-err {
0%, 60% {
opacity: 0;
transform: scale(0.6);
}
66% {
opacity: 1;
transform: scale(1.1);
}
73%, 84% {
opacity: 1;
transform: scale(1);
}
90%, 100% {
opacity: 0;
transform: scale(0.7);
}
}
@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 {
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);
}
}(function () {
var pin = document.querySelector('.pin');
var boxes = [].slice.call(pin.querySelectorAll('.pin__input'));
var reduceMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function wake() { pin.classList.remove('is-demo'); }
function scrambleToDigit(el, finalDigit, onSettle) {
if (reduceMotion) { el.value = finalDigit; if (onSettle) onSettle(); return; }
el.classList.add('is-scrambling');
var step = 0;
var steps = 7;
var delay = 22;
(function tick() {
if (step >= steps) {
el.value = finalDigit;
el.classList.remove('is-scrambling');
el.classList.add('is-settle');
setTimeout(function () { el.classList.remove('is-settle'); }, 200);
if (onSettle) onSettle();
return;
}
el.value = String(Math.floor(Math.random() * 10));
step++;
delay = Math.round(delay * 1.4);
setTimeout(tick, delay);
})();
}
boxes.forEach(function (b, i) {
b.addEventListener('focus', wake);
b.addEventListener('input', function () {
var digit = b.value.replace(/[^0-9]/g, '').slice(-1);
if (!digit) { b.value = ''; return; }
scrambleToDigit(b, digit, function () {
if (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; });
(boxes[Math.min(t.length, 6) - 1] || boxes[0]).focus({ preventScroll: true });
});
});
var confirmBtn = pin.querySelector('.pin__confirm');
var status = pin.querySelector('.pin__status');
boxes.forEach(function (b) {
b.addEventListener('input', function () { status.hidden = true; });
});
confirmBtn.addEventListener('click', function () {
var code = boxes.map(function (b) { return b.value; }).join('');
if (code.length < 6 || code !== '123456') {
pin.classList.add('is-error');
status.hidden = false;
pin.addEventListener('animationend', function clear() {
pin.classList.remove('is-error');
boxes.forEach(function (b) { b.value = ''; });
boxes[0].focus({ preventScroll: true });
pin.removeEventListener('animationend', clear);
});
}
});
})();import { useState, useRef, useEffect } from "react";
import type { CSSProperties } from "react";
import styles from "./PinErrorShakeClear.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 PinErrorShakeClearProps = {
/** 칸 수 (기본 6) */
length?: number;
/** 6칸이 다 채워지면 호출 */
onComplete?: (code: string) => void;
/** 강조 색 */
color?: string;
className?: string;
/** 맞춰야 하는 코드 (데모용, 기본 123456) */
expected?: string;
onError?: () => void;
};
type Vars = CSSProperties & { "--color"?: string };
// useRef 는 T | null 을 돌려준다. 값이 항상 있는 상자는 이렇게 좁혀 쓴다 (077 관례)
type Box<T> = { current: T };
type TimerList = Array<ReturnType<typeof setTimeout> | undefined>;
// useState setter — 스텁 환경에 Dispatch/SetStateAction 이 없어 로컬로 같은 모양을 쓴다
type ListSetter<T> = (v: T[] | ((prev: T[]) => T[])) => void;
export default function PinErrorShakeClear({
length = 6,
onComplete,
color = "#2f6df6",
className = "",
expected = "123456",
onError,
}: PinErrorShakeClearProps) {
const [values, setValues] = useState<string[]>(Array(length).fill(""));
const [displayValues, setDisplayValues] = useState<string[]>(Array(length).fill(""));
const [animState, setAnimState] = useState<Array<"idle" | "scrambling" | "settle">>(Array(length).fill("idle"));
const vars: Vars = { "--color": color };
const [shaking, setShaking] = useState(false);
const reduceMotionRef = useRef(false) as Box<boolean>;
const timersRef = useRef<TimerList>([]) as Box<TimerList>;
useEffect(() => {
reduceMotionRef.current = typeof window !== "undefined" && !!window.matchMedia
? window.matchMedia("(prefers-reduced-motion: reduce)").matches
: false;
return () => { timersRef.current.forEach((t) => t && clearTimeout(t)); };
}, []);
function updateAt<T>(setter: ListSetter<T>, index: number, val: T) {
setter((prev) => {
const next = prev.slice();
next[index] = val;
return next;
});
}
function confirm() {
const code = values.join("");
if (code.length < length || code !== expected) {
setShaking(true);
onError?.();
setTimeout(() => {
setShaking(false);
setValues(Array(length).fill(""));
setDisplayValues(Array(length).fill(""));
}, 420);
}
}
// 스크램블은 정상 입력 시 숫자 표시 레이어에만 얹고, 에러·흔들림·클리어 로직은 그대로 둔다
function scrambleToDigit(index: number, finalDigit: string, onSettle: () => void) {
if (reduceMotionRef.current) {
updateAt(setDisplayValues, index, finalDigit);
onSettle();
return;
}
updateAt(setAnimState, index, "scrambling");
let step = 0;
const steps = 7;
let delay = 22;
const tick = () => {
if (step >= steps) {
updateAt(setDisplayValues, index, finalDigit);
updateAt(setAnimState, index, "settle");
timersRef.current[index] = setTimeout(() => {
updateAt(setAnimState, index, "idle");
}, 200);
onSettle();
return;
}
updateAt(setDisplayValues, index, String(Math.floor(Math.random() * 10)));
step++;
delay = Math.round(delay * 1.4);
timersRef.current[index] = setTimeout(tick, delay);
};
tick();
}
function handleChange(e: any, i: number) {
const el = e.currentTarget;
const v = String(el.value).replace(/[^0-9]/g, "").slice(-1);
if (!v) {
const next = values.slice();
next[i] = "";
setValues(next);
updateAt(setDisplayValues, i, "");
return;
}
const committed = values.slice();
committed[i] = v;
setValues(committed);
scrambleToDigit(i, v, () => {
const boxes = siblings(el);
if (boxes[i + 1]) boxes[i + 1].focus({ preventScroll: true });
if (committed.every(Boolean)) onComplete?.(committed.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);
setDisplayValues(next.slice());
if (text.length === length) onComplete?.(text);
}
return (
<div className={`${styles.pin} ${shaking ? styles.shake : ""} ${className}`} style={vars} data-pin-row>
{displayValues.map((v, i) => (
<input
key={i}
className={[
styles.box,
animState[i] === "scrambling" ? styles.scrambling : "",
animState[i] === "settle" ? styles.settle : "",
].join(" ").trim()}
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)}
/>
))}
<button type="button" className={styles.confirm} onClick={confirm}>
확인
</button>
</div>
);
}06 · Rung và xoá khi sai — Diện tích đo được 5.167% · khung hình chuyển động 15/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Chỗ dùng
Trong lúc đó màn hình giữ một câu trạng thái để trình đọc màn hình cũng biết việc xác nhận đã thất bại.
confirmBtn.addEventListener('click', function () {
var code = boxes.map(function (b) { return b.value; }).join('');
if (code.length < 6 || code !== realCode) {
pin.classList.add('is-error');
boxes.forEach(function (b) { b.value = ''; });
}
});
07Timer gửi lại
Chuyển động thế nào
Nút gửi lại chỉ bấm được sau khi đếm ngược hết 60 giây.

<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>
<div class="pin__timer" aria-hidden="true">
<span class="pin__timer-n">3</span><span class="pin__timer-n">2</span><span class="pin__timer-n">1</span>
</div>
<button type="button" class="pin__resend" disabled>Gửi lại sau 60 giây</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-ink;
$fg: $subject-cream;
@include stage($stage-orange);
.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__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-hold $dur-loop linear infinite; }
@for $i from 1 through 6 {
.pin.is-demo .pin__box:nth-child(#{$i}) .pin__ghost { animation-delay: #{($i - 1) * 60}ms; }
}
@keyframes pin-ghost-hold {
0%, 100% { opacity: 0; }
7% { opacity: 1; }
88% { opacity: 1; }
94% { opacity: 0; }
}
.pin__timer { position: relative; width: 26px; height: 26px; }
.pin__timer-n {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: $fg;
font-weight: 700; font-size: 16px; line-height: 1;
opacity: 0;
}
.pin.is-demo .pin__timer-n { animation: pin-timer $dur-loop steps(1) infinite; }
.pin.is-demo .pin__timer-n:nth-child(1) { animation-delay: 0ms; }
.pin.is-demo .pin__timer-n:nth-child(2) { animation-delay: 260ms; }
.pin.is-demo .pin__timer-n:nth-child(3) { animation-delay: 520ms; }
@keyframes pin-timer {
0%, 13% { opacity: 1; }
13.1%, 100% { opacity: 0; }
}
.pin__resend {
border: 0;
border-radius: $r-pill;
padding: 8px 18px;
font-weight: 600; font-size: 13px; line-height: 1;
background: rgba($fg, .14);
color: $color;
opacity: .82;
transform: scale(.96);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.pin.is-demo .pin__resend { animation: pin-resend $dur-loop $easing infinite; }
@keyframes pin-resend {
0%, 86% { opacity: .82; transform: scale(.96); }
94%, 100% { opacity: 1; transform: scale(1); }
}
@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: #ff4d1f;
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(255, 247, 230, 0.28);
border-radius: 12px;
background: rgba(255, 247, 230, 0.08);
color: #fff7e6;
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: #17141a;
}
.pin__ghost {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: #fff7e6;
font-weight: 600;
font-size: 20px;
line-height: 1;
opacity: 0;
pointer-events: none;
}
.pin__scramble {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: #17141a;
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-hold 2s linear infinite;
}
.pin.is-demo .pin__box:nth-child(1) .pin__ghost {
animation-delay: 0ms;
}
.pin.is-demo .pin__box:nth-child(2) .pin__ghost {
animation-delay: 60ms;
}
.pin.is-demo .pin__box:nth-child(3) .pin__ghost {
animation-delay: 120ms;
}
.pin.is-demo .pin__box:nth-child(4) .pin__ghost {
animation-delay: 180ms;
}
.pin.is-demo .pin__box:nth-child(5) .pin__ghost {
animation-delay: 240ms;
}
.pin.is-demo .pin__box:nth-child(6) .pin__ghost {
animation-delay: 300ms;
}
@keyframes pin-ghost-hold {
0%, 100% {
opacity: 0;
}
7% {
opacity: 1;
}
88% {
opacity: 1;
}
94% {
opacity: 0;
}
}
.pin__timer {
position: relative;
width: 26px;
height: 26px;
}
.pin__timer-n {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: #fff7e6;
font-weight: 700;
font-size: 16px;
line-height: 1;
opacity: 0;
}
.pin.is-demo .pin__timer-n {
animation: pin-timer 2s steps(1) infinite;
}
.pin.is-demo .pin__timer-n:nth-child(1) {
animation-delay: 0ms;
}
.pin.is-demo .pin__timer-n:nth-child(2) {
animation-delay: 260ms;
}
.pin.is-demo .pin__timer-n:nth-child(3) {
animation-delay: 520ms;
}
@keyframes pin-timer {
0%, 13% {
opacity: 1;
}
13.1%, 100% {
opacity: 0;
}
}
.pin__resend {
border: 0;
border-radius: 999px;
padding: 8px 18px;
font-weight: 600;
font-size: 13px;
line-height: 1;
background: rgba(255, 247, 230, 0.14);
color: #17141a;
opacity: 0.82;
transform: scale(0.96);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.pin.is-demo .pin__resend {
animation: pin-resend 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes pin-resend {
0%, 86% {
opacity: 0.82;
transform: scale(0.96);
}
94%, 100% {
opacity: 1;
transform: scale(1);
}
}
@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: #fff7e6;
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 * 70); }
});
(boxes[Math.min(t.length, 6) - 1] || boxes[0]).focus({ preventScroll: true });
});
});
var resend = pin.querySelector('.pin__resend');
var left = 60;
function tick() {
resend.textContent = left > 0 ? left + ' giây nữa gửi lại được' : 'Gửi lại';
resend.disabled = left > 0;
if (left > 0) { left--; setTimeout(tick, 1000); }
}
resend.addEventListener('click', function () { if (!resend.disabled) { left = 60; tick(); } });
})();import { useState, useEffect } from "react";
import type { CSSProperties } from "react";
import styles from "./PinResendTimer.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 PinResendTimerProps = {
/** 칸 수 (기본 6) */
length?: number;
/** 6칸이 다 채워지면 호출 */
onComplete?: (code: string) => void;
/** 강조 색 */
color?: string;
className?: string;
/** 재전송까지 대기 시간(초). 기본 60 */
resendSeconds?: number;
onResend?: () => void;
};
type Vars = CSSProperties & { "--color"?: string };
export default function PinResendTimer({
length = 6,
onComplete,
color = "#2f6df6",
className = "",
resendSeconds = 60,
onResend,
}: PinResendTimerProps) {
const [values, setValues] = useState<string[]>(Array(length).fill(""));
const vars: Vars = { "--color": color };
const [left, setLeft] = useState(resendSeconds);
const [scrambling, setScrambling] = useState<boolean[]>(Array(length).fill(false));
const [scrambleDisplay, setScrambleDisplay] = useState<string[]>(Array(length).fill(""));
useEffect(() => {
if (left <= 0) return;
const t = setTimeout(() => setLeft((s) => s - 1), 1000);
return () => clearTimeout(t);
}, [left]);
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 * 70);
});
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>
))}
<button
type="button"
className={styles.resend}
disabled={left > 0}
onClick={() => {
setLeft(resendSeconds);
onResend?.();
}}
>
{left > 0 ? `${left}초 후 재전송` : "재전송"}
</button>
</div>
);
}07 · Đếm ngược gửi lại — Diện tích đo được 1.097% · khung hình chuyển động 11/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Dùng khi nào
Cách này chặn việc yêu cầu mã SMS nhiều lần trong một khoảng thời gian ngắn.
var left = 60;
function tick() {
resend.textContent = left > 0 ? left + ' giây nữa gửi lại' : 'Gửi lại';
resend.disabled = left > 0;
if (left > 0) { left--; setTimeout(tick, 1000); }
}
tick();
08Tự điền SMS OTP
Bạn thấy gì
Hệ điều hành nhận ra mã đến trong tin nhắn và tự điền vào các ô.

<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 · Xem mật khẩu
Dùng ở đâu
Chỉ một thuộc tính autocomplete="one-time-code" là chạy được cả trên iOS lẫn Android.
<input
type="text"
inputmode="numeric"
autocomplete="one-time-code"
maxlength="1"
>
09Focus tĩnh khi giảm chuyển động
Chuyện gì diễn ra trên màn hình
Với cài đặt giảm chuyển động, focus nhảy thẳng sang ô kế mà không có hiệu ứng.

<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>
<span class="pin__caret" aria-hidden="true"></span>
</div>
<span class="pin__signal pin__signal--toggle" aria-hidden="true"></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
$fg: $subject-cream;
@include stage($stage-ink);
.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__caret {
position: absolute;
left: 0;
top: -9px;
width: 38px;
height: 3px;
border-radius: $r-pill;
background: $color;
opacity: 0;
}
.pin__signal {
position: relative;
width: 22px;
height: 22px;
opacity: 0;
transform: scale(.6);
}
.pin__signal--toggle {
width: 36px;
height: 20px;
border-radius: $r-pill;
background: rgba($fg, .18);
opacity: 0;
}
.pin__signal--toggle::before {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
border-radius: 50%;
background: $color;
transform: translateX(0);
}
.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 $dur-loop linear infinite; }
@for $i from 1 through 6 {
.pin.is-demo .pin__box:nth-child(#{$i}) .pin__ghost { animation-delay: #{($i - 1) * 90}ms; }
}
@keyframes pin-ghost {
0%, 100% { opacity: 0; }
7% { opacity: 1; }
68% { opacity: 1; }
76% { opacity: 0; }
}
.pin.is-demo .pin__caret {
animation: pin-caret $dur-loop steps(1) infinite;
}
@keyframes pin-caret {
0% { opacity: .9; transform: translateX(0); }
5% { opacity: .9; transform: translateX(46px); }
9% { opacity: .9; transform: translateX(92px); }
14% { opacity: .9; transform: translateX(138px); }
18% { opacity: .9; transform: translateX(184px); }
23% { opacity: .9; transform: translateX(230px); }
76% { opacity: .9; transform: translateX(230px); }
84%, 100% { opacity: 0; transform: translateX(230px); }
}
.pin.is-demo .pin__signal--toggle {
opacity: 1;
animation: pin-toggle-bg $dur-loop $easing infinite;
}
.pin.is-demo .pin__signal--toggle::before { animation: pin-toggle-knob $dur-loop $easing infinite; }
@keyframes pin-toggle-knob {
0%, 55% { transform: translateX(0); }
75%, 100% { transform: translateX(16px); }
}
@keyframes pin-toggle-bg {
0%, 55% { background: rgba($fg, .18); }
75%, 100% { background: rgba($color, .35); }
}
@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: #17141a;
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(255, 247, 230, 0.28);
border-radius: 12px;
background: rgba(255, 247, 230, 0.08);
color: #fff7e6;
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: #fff7e6;
font-weight: 600;
font-size: 20px;
line-height: 1;
opacity: 0;
pointer-events: none;
}
.pin__caret {
position: absolute;
left: 0;
top: -9px;
width: 38px;
height: 3px;
border-radius: 999px;
background: #2f6df6;
opacity: 0;
}
.pin__signal {
position: relative;
width: 22px;
height: 22px;
opacity: 0;
transform: scale(0.6);
}
.pin__signal--toggle {
width: 36px;
height: 20px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.18);
opacity: 0;
}
.pin__signal--toggle::before {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #2f6df6;
transform: translateX(0);
}
.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 2s linear infinite;
}
.pin.is-demo .pin__box:nth-child(1) .pin__ghost {
animation-delay: 0ms;
}
.pin.is-demo .pin__box:nth-child(2) .pin__ghost {
animation-delay: 90ms;
}
.pin.is-demo .pin__box:nth-child(3) .pin__ghost {
animation-delay: 180ms;
}
.pin.is-demo .pin__box:nth-child(4) .pin__ghost {
animation-delay: 270ms;
}
.pin.is-demo .pin__box:nth-child(5) .pin__ghost {
animation-delay: 360ms;
}
.pin.is-demo .pin__box:nth-child(6) .pin__ghost {
animation-delay: 450ms;
}
@keyframes pin-ghost {
0%, 100% {
opacity: 0;
}
7% {
opacity: 1;
}
68% {
opacity: 1;
}
76% {
opacity: 0;
}
}
.pin.is-demo .pin__caret {
animation: pin-caret 2s steps(1) infinite;
}
@keyframes pin-caret {
0% {
opacity: 0.9;
transform: translateX(0);
}
5% {
opacity: 0.9;
transform: translateX(46px);
}
9% {
opacity: 0.9;
transform: translateX(92px);
}
14% {
opacity: 0.9;
transform: translateX(138px);
}
18% {
opacity: 0.9;
transform: translateX(184px);
}
23% {
opacity: 0.9;
transform: translateX(230px);
}
76% {
opacity: 0.9;
transform: translateX(230px);
}
84%, 100% {
opacity: 0;
transform: translateX(230px);
}
}
.pin.is-demo .pin__signal--toggle {
opacity: 1;
animation: pin-toggle-bg 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.pin.is-demo .pin__signal--toggle::before {
animation: pin-toggle-knob 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes pin-toggle-knob {
0%, 55% {
transform: translateX(0);
}
75%, 100% {
transform: translateX(16px);
}
}
@keyframes pin-toggle-bg {
0%, 55% {
background: rgba(255, 247, 230, 0.18);
}
75%, 100% {
background: rgba(47, 109, 246, 0.35);
}
}
@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: #fff7e6;
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 * 70); }
});
(boxes[Math.min(t.length, 6) - 1] || boxes[0]).focus({ preventScroll: true });
});
});
})();import { useState, useEffect } from "react";
import type { CSSProperties } from "react";
import styles from "./PinReducedMotionStatic.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 PinReducedMotionStaticProps = {
/** 칸 수 (기본 6) */
length?: number;
/** 6칸이 다 채워지면 호출 */
onComplete?: (code: string) => void;
/** 강조 색 */
color?: string;
className?: string;
/** 시스템 reduced-motion 을 존중할지 (기본 true) */
respectReducedMotion?: boolean;
};
type Vars = CSSProperties & { "--color"?: string };
export default function PinReducedMotionStatic({
length = 6,
onComplete,
color = "#2f6df6",
className = "",
respectReducedMotion = true,
}: PinReducedMotionStaticProps) {
const [values, setValues] = useState<string[]>(Array(length).fill(""));
const vars: Vars = { "--color": color };
const [reduced, setReduced] = useState(false);
const [scrambling, setScrambling] = useState<boolean[]>(Array(length).fill(false));
const [scrambleDisplay, setScrambleDisplay] = useState<string[]>(Array(length).fill(""));
useEffect(() => {
if (!respectReducedMotion) return;
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
setReduced(mq.matches);
const onChange = () => setReduced(mq.matches);
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, [respectReducedMotion]);
function runScramble(index: number, finalDigit: string, delayStart = 0) {
// 09 핵심: prefers-reduced-motion(또는 respectReducedMotion 무시) 이면 스크램블 없이 즉시 정착
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 * 70);
});
if (text.length === length) onComplete?.(text);
}
return (
<div className={`${styles.pin} ${reduced ? styles.static : ""} ${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>
);
}09 · Focus tĩnh khi giảm chuyển động — Diện tích đo được 1.001% · khung hình chuyển động 17/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Chỗ dùng
Vừa giảm gánh nặng cho người nhạy tiền đình, vừa giữ nguyên tính năng tự nhảy ô.
@media (prefers-reduced-motion: reduce) {
.pin__ghost, .pin__caret, .pin__signal {
animation: none !important;
transition: none !important;
}
.pin.is-demo .pin__ghost { opacity: 1; }
}
OTP input dễ hỏng ở đâu — cái bẫy
Lúc tự điền bỏ sót cả sáu ô
Gắn autocomplete="one-time-code" cho từng ô thì phần lớn trình duyệt mới tự điền mã SMS vào được, nhưng một vài Safari và trình duyệt Android cũ vẫn hay bỏ sót kiểu tự điền chia sáu ô. Mẫu form OTP của web.dev đề xuất phương án giữ sáu ô hiển thị nguyên vẹn, còn việc tự điền nhận qua một input ẩn ngoài màn hình rồi JS chia vào sáu ô khi giá trị tới. Mục 08 trong zip này chưa dùng phương án thỏa hiệp ấy, chỉ gắn thuộc tính từng ô, nên trước khi gắn vào dịch vụ thật bạn nên tự bấm thử trên vài máy để xác nhận tự điền thật sự chạy đúng. File nén mở bằng mật khẩu 39kzjrs9, chứa trọn vẹn cả chín mục đến tận 09. Sự kiện paste cũng có đúng cái bẫy này: gắn listener cho từng ô thì một lần dán có thể chạy chồng nhiều lần, phải gắn duy nhất vào container bọc ngoài như mục 03 thì mã mới chia đúng một lần.
Khả năng tiếp cận
Giảm chuyển động và trình đọc màn hình
Cả chín mục đều nghe theo tùy chọn giảm chuyển động của hệ điều hành. Ở 01·04·09, con trỏ và tín hiệu biến mất, các ô chỉ còn trạng thái đã điền; 05 đổi trạng thái che mà không chớp icon; 06 bỏ rung, chỉ giữ câu báo lỗi trên màn hình. Mỗi ô có aria-label đọc ra vị trí thứ mấy, còn câu lỗi của 06 dùng role="status" để trình đọc màn hình nhận thông báo riêng. Muốn nhấn mạnh xác thực bằng hiệu ứng thì chín hiệu ứng modal animation có sẵn phần rung lỗi và fade để tham khảo, còn hướng vận hành của trang nằm ở trang giới thiệu.
FAQ
Nhập mã PIN có bắt buộc chia thành sáu ô không?
Không. Một <input maxlength="6"> duy nhất cũng nhận được, nhưng chia ô giúp bạn thấy ngay còn thiếu mấy số và dễ đối xử khác nhau cho từng vị trí khi làm tự nhảy ô hay Backspace.
autocomplete="one-time-code" chạy trên trình duyệt nào?
Safari, Chrome, Edge bản mới đều nhận diện autocomplete="one-time-code" và tự điền mã đến qua SMS. Tổ hợp trình duyệt với hệ điều hành chính xác thì tra thêm trên caniuse.com, và nên thử kèm cách input ẩn đã nêu ở phần bẫy, nhất là khi bạn tự dựng otp input ui design cho dịch vụ thật.
Mã bốn chữ số thay vì sáu cũng dùng được chứ?
Được. React component trong zip chỉ cần đổi giá trị length là số ô chạy theo, nên mã PIN ngân hàng bốn số hay mã dự phòng tám số đều tái dùng đúng một cấu trúc.









