05Two-factor — a code entry that sets its confirm button's state from a fill ratio
Counting how many of the six boxes are filled works out a fill ratio, and only once that ratio reaches full does the confirm button's opacity and clickability change together.
Best forOTP code entry, two-factor verification screens
data-indexautocompletesetAttribute

<main class="stage">
<section class="panel">
<p class="title" id="tfaTitle">Enter your 2FA code</p>
<div class="digitGroup" role="group" aria-labelledby="tfaTitle">
<span class="digitBox" data-index="0"></span>
<span class="digitBox" data-index="1"></span>
<span class="digitBox" data-index="2"></span>
<span class="digitBox" data-index="3"></span>
<span class="digitBox" data-index="4"></span>
<span class="digitBox" data-index="5"></span>
</div>
<label class="srOnly" for="otpProxy">6-digit authentication code</label>
<input class="srOnly" type="text" id="otpProxy" inputmode="numeric" autocomplete="one-time-code" tabindex="-1" value="">
<button class="submit" type="button" id="confirmBtn" disabled aria-disabled="true">Confirm</button>
<p class="hint">The confirm button activates once all digits are entered</p>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-blue-deep;
@include stage($stage-ink);
.panel {
width: 100%;
max-width: 340px;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
text-align: center;
}
.title {
margin: 0;
color: $subject-cream;
font-weight: 600;
font-size: 15px;
}
.digitGroup {
display: flex;
gap: $sp-2;
}
.digitBox {
width: 36px;
height: 44px;
display: grid;
place-items: center;
border-radius: $r-sm;
background: rgba($subject-cream, .08);
border: 1px solid rgba($subject-cream, .24);
color: $subject-cream;
font-weight: 700;
font-size: 18px;
transition: border-color $dur-quick $easing, background $dur-quick $easing;
}
.digitBox.filled {
border-color: $color;
background: rgba($color, .22);
}
.srOnly {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.submit {
padding: $sp-2 $sp-8;
border: 0;
border-radius: $r-control;
background: $color;
color: $subject-cream;
font-weight: 600;
font-size: 14px;
cursor: pointer;
}
.submit:disabled {
cursor: not-allowed;
}
.hint {
margin: 0;
color: $subject-cream;
opacity: .72;
font-weight: 400;
font-size: 12px;
}
@media (max-width: 340px) {
.panel { max-width: 280px; gap: $sp-3; }
.digitBox { width: 30px; height: 38px; font-size: 16px; }
.submit { padding: $sp-2 $sp-6; }
}
@media (prefers-reduced-motion: reduce) {
.digitBox { transition: none; }
}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);
}
.panel {
width: 100%;
max-width: 340px;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
text-align: center;
}
.title {
margin: 0;
color: #fff7e6;
font-weight: 600;
font-size: 15px;
}
.digitGroup {
display: flex;
gap: 8px;
}
.digitBox {
width: 36px;
height: 44px;
display: grid;
place-items: center;
border-radius: 8px;
background: rgba(255, 247, 230, 0.08);
border: 1px solid rgba(255, 247, 230, 0.24);
color: #fff7e6;
font-weight: 700;
font-size: 18px;
transition: border-color 220ms ease-out, background 220ms ease-out;
}
.digitBox.filled {
border-color: #0a4fe8;
background: rgba(10, 79, 232, 0.22);
}
.srOnly {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.submit {
padding: 8px 32px;
border: 0;
border-radius: 12px;
background: #0a4fe8;
color: #fff7e6;
font-weight: 600;
font-size: 14px;
cursor: pointer;
}
.submit:disabled {
cursor: not-allowed;
}
.hint {
margin: 0;
color: #fff7e6;
opacity: 0.72;
font-weight: 400;
font-size: 12px;
}
@media (max-width: 340px) {
.panel {
max-width: 280px;
gap: 12px;
}
.digitBox {
width: 30px;
height: 38px;
font-size: 16px;
}
.submit {
padding: 8px 24px;
}
}
@media (prefers-reduced-motion: reduce) {
.digitBox {
transition: none;
}
}// 2단계 Verify(05) — 여섯 자리 칸 중 채워진 자리수를 매 프레임 다시 세어 전체 대비 채움 비율을
// 직접 구하고, 그 비율이 100%에 닿아야 Confirm 버튼의 불투명도와 눌림 가능 여부가 함께 바뀐다.
// 코드 자체는 mulberry32 고정 시드로 만든 데모용 가짜 값이며 실제 Verify 서버와 무관하다.
(function () {
var boxes = document.querySelectorAll('.digitBox');
var proxy = document.getElementById('otpProxy');
var confirmBtn = document.getElementById('confirmBtn');
if (!boxes.length || !proxy || !confirmBtn) return;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var DIGITS = 6;
var DIGIT_MS = 250; // 6 * 250 = 1500, 남는 500ms 는 Done 상태 유지
var CYCLE_MS = 2000;
function mulberry32(seed) {
return function () {
seed |= 0; seed = (seed + 0x6D2B79F5) | 0;
var t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
var rand = mulberry32(42);
var code = [];
for (var i = 0; i < DIGITS; i++) code.push(Math.floor(rand() * 10));
function filled(t) {
return Math.min(Math.max(Math.floor(t / DIGIT_MS), 0), DIGITS);
}
function apply(t) {
var n = filled(t);
var ratio = n / DIGITS;
for (var i = 0; i < DIGITS; i++) {
var box = boxes[i];
if (i < n) {
box.textContent = String(code[i]);
box.classList.add('filled');
} else {
box.textContent = '';
box.classList.remove('filled');
}
}
proxy.value = code.slice(0, n).join('');
var ready = ratio >= 1;
confirmBtn.disabled = !ready;
confirmBtn.setAttribute('aria-disabled', String(!ready));
confirmBtn.style.opacity = ready ? '1' : '.4';
}
var start = null;
function frame(ts) {
if (start === null) start = ts;
var t = (ts - start) % CYCLE_MS;
apply(t);
requestAnimationFrame(frame);
}
if (reduce) {
apply(0);
} else {
requestAnimationFrame(frame);
}
})();import { useEffect, useRef } from "react";
import styles from "./TwoFactor.module.scss";
// 2단계 인증(05) — 여섯 자리 칸 중 채워진 자리수를 매 프레임 다시 세어 전체 대비 채움 비율을
// 직접 구하고, 그 비율이 100%에 닿아야 확인 버튼의 불투명도와 눌림 가능 여부가 함께 바뀐다.
// 코드 자체는 mulberry32 고정 시드로 만든 데모용 가짜 값이며 실제 인증 서버와 무관하다.
const DIGITS = 6;
const DIGIT_MS = 250; // 6 * 250 = 1500, 남는 500ms 는 완료 상태 유지
const CYCLE_MS = 2000;
function mulberry32(seed: number) {
let s = seed;
return function () {
s |= 0; s = (s + 0x6D2B79F5) | 0;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function filled(t: number): number {
return Math.min(Math.max(Math.floor(t / DIGIT_MS), 0), DIGITS);
}
export default function TwoFactor() {
const groupRef = useRef<HTMLDivElement>(null);
const proxyRef = useRef<HTMLInputElement>(null);
const confirmRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const group = groupRef.current;
const proxy = proxyRef.current;
const confirmBtn = confirmRef.current;
if (!group || !proxy || !confirmBtn) return;
const boxes = group.querySelectorAll<HTMLSpanElement>("." + styles.digitBox);
if (boxes.length !== DIGITS) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const rand = mulberry32(42);
const code: number[] = [];
for (let i = 0; i < DIGITS; i++) code.push(Math.floor(rand() * 10));
function apply(t: number) {
const n = filled(t);
const ratio = n / DIGITS;
for (let i = 0; i < DIGITS; i++) {
const box = boxes[i];
if (i < n) {
box.textContent = String(code[i]);
box.classList.add(styles.filled);
} else {
box.textContent = "";
box.classList.remove(styles.filled);
}
}
if (proxy) proxy.value = code.slice(0, n).join("");
const ready = ratio >= 1;
if (confirmBtn) {
confirmBtn.disabled = !ready;
confirmBtn.setAttribute("aria-disabled", String(!ready));
confirmBtn.style.opacity = ready ? "1" : ".4";
}
}
let start: number | null = null;
let raf = 0;
function frame(ts: number) {
if (start === null) start = ts;
const t = (ts - start) % CYCLE_MS;
apply(t);
raf = requestAnimationFrame(frame);
}
if (reduce) {
apply(0);
} else {
raf = requestAnimationFrame(frame);
}
return () => {
if (raf) cancelAnimationFrame(raf);
};
}, []);
return (
<main className={styles.stage}>
<section className={styles.panel}>
<p className={styles.title} id="tfaTitle">
2단계 인증 코드 입력
</p>
<div className={styles.digitGroup} role="group" aria-labelledby="tfaTitle" ref={groupRef}>
<span className={styles.digitBox} />
<span className={styles.digitBox} />
<span className={styles.digitBox} />
<span className={styles.digitBox} />
<span className={styles.digitBox} />
<span className={styles.digitBox} />
</div>
<label className={styles.srOnly} htmlFor="otpProxy">
인증 코드 6자리
</label>
<input
ref={proxyRef}
className={styles.srOnly}
type="text"
id="otpProxy"
inputMode="numeric"
autoComplete="one-time-code"
tabIndex={-1}
defaultValue=""
/>
<button ref={confirmRef} className={styles.submit} type="button" disabled aria-disabled="true">
확인
</button>
<p className={styles.hint}>코드를 모두 입력하면 확인 버튼이 활성화됩니다</p>
</section>
</main>
);
}05 Two-factor — a code entry that sets its confirm button's state from a fill ratio — Measured area 9.225% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · 9 Login UI Design Patterns (Copy-Paste)
The post this demo lives in
9 Login UI Design Patterns (Copy-Paste)Nine login and signup patterns that compute: fill ratio refills a progress ring, remaining time recounts into an arc eve See all demos in this post
If you like this effect - similar demos
Auth UI05 A Check Where the Pointer Trail Drawn Along a Winding Guide Becomes a Score From Its Mean Distance to That GuideCSS Captcha: 9 Warped Text and Rotate Checks
Auth UI02 Three steps to turn on two-factor9 account security settings UI patterns
Auth UI09 Reduced-motion instant update — under a reduced-motion setting, the value jumps straight to its next state with no movementToo Many Failed Login Attempts: 9 Patterns