05Bật tắt chế độ che số
Chạm icon con mắt là các chữ số hiện rõ thay cho các dấu chấm.
Dùng choPIN tài khoản trong app ngân hàng, PIN thanh toá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 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 · OTP Input UI: 9 tình huống kèm validation
Bài viết chứa demo này
OTP Input UI: 9 tình huống kèm validationOTP input ui là phần form nhận sáu chữ số, mỗi số một ô, muốn mượt thì phải lo cả nhảy ô kế, dán nguyên mã lẫn xử lý lỗi Xem tất cả demo trong bài này
Nếu bạn thích hiệu ứng này - demo tương tự
Giao diện lựa chọn01 Segmented control kiểu pill iOS9 Segmented Control UI — dán là chạy
Giao diện đăng nhập · xác thực01 Thẻ đăng nhập kiểu kính mờ (glassmorphism)9 mẫu form đăng ký & đăng nhập copy-paste được
Giao diện danh sách · bảng01 Vuốt để xóa (reveal)9 mẫu List UI: swipe to delete css và phân trang