パスワード 目のアイコン 9パターン — 文字とドットの入れ替え計算
「パスワード 目のアイコン」の作り方は9通りある。目のアイコンの下には九つに枝分かれする路地がある。01番の路地は文字と点が入れ替わる交差点で、05番の路地は光が壁を伝って広がる通路で、06番の路地は文字が一歩ずつ歩いて入ってくる階段だ。
02 password-reveal-capslock-warning-spring
03 password-reveal-multi-field-sync-toggle
04 password-reveal-paste-detect-banner
05 password-reveal-autofill-highlight-glow
06 password-reveal-generate-copy-cascade
07 password-reveal-hold-to-show-progress
08 password-reveal-keyboard-shortcut-focus-ring
09 password-reveal-hero-signup-form-scene
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 文字と点が入れ替わる目のアイコンのモーフ
- 02 Caps Lockのオンをスプリングで知らせる警告バッジ
- 03 複数の入力欄が一斉に開く同期トグル
- 04 貼り付けを検知してせり出す案内バナー
- 05 ブラウザの自動入力を検知して広がるハイライトグロー
- 06 生成ボタンで一文字ずつ現れるカスケード
- 07 押している間だけ進行リングで開くホールド方式
- 08 ショートカットで切り替えるとフォーカスリングが明るくなるキーボード経路
- 09 複数の方式を一つのフォームにまとめた会員登録ヒーロー場面
パスワード 目のアイコン 9パターン — input type の値ひとつが九通りに変わる瞬間
以下の9パターンはすべて同じ問いに対してそれぞれ別々の答えを出しています — パスワード欄のマスク状態が切り替わる瞬間、何が起きるべきか、という問いです。01番は一つのフィールド自身の反応にとどまりますが、03番では同じ変化が二つのフィールドで共有され、05番と06番はマスク解除以外の目的でフィールドを使い、09番はここまでの仕組みの一部を一つの会員登録フォームにまとめています。全体としては、一つのフィールドが自分自身に反応する形から、複数のフィールドと機能がひとつの共有された値に反応する形へと移っていきます。
01文字と点が入れ替わる目のアイコンのモーフ
buildOverlay() は文字数ぶんの pem__ch ラッパーを一つずつ組み立て、マスク用の pem__mask の点と実際の文字を表す pem__char を同じグリッド位置に重ねています。別々の要素を出し入れしているわけではありません。animateEyeStep() は単一の eyeProgress 値を EYE_MORPH_MS かけて0か1へイージングし、paintEye() がその一つの値を読んでまぶたのパスの曲がりと瞳の不透明度を同時に変えています。
<main class="stage">
<div class="pem is-demo" id="pemRoot">
<form class="pem__form" id="pemForm" novalidate>
<label class="pem__field">
<span class="pem__labelText">パスワード</span>
<span class="pem__inputWrap">
<input class="pem__input" id="pemInput" type="password" autocomplete="new-password" spellcheck="false" value="">
<span class="pem__overlay" id="pemOverlay" aria-hidden="true"></span>
<button class="pem__toggle" id="pemToggle" type="button" aria-pressed="false" aria-label="パスワードを表示">
<svg class="pem__eye" viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
<path class="pem__lidTop" id="pemLidTop" d="M2,12 Q12,4 22,12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path class="pem__lidBottom" id="pemLidBottom" d="M2,12 Q12,20 22,12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<circle class="pem__pupil" id="pemPupil" cx="12" cy="12" r="3.2" fill="currentColor"/>
</svg>
</button>
</span>
</label>
</form>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
// 계산 겹: 문자 오버레이는 마스크 점 레이어 위에 실제 글자 레이어를 겹쳐 opacity 크로스페이드로
// 바꾸고, 각 span 의 transition-delay 는 JS 가 인덱스*CHAR_STAGGER_MS 로 순차 계산해 넣는다.
// 눈 아이콘은 JS 가 매 프레임 path d 를 다시 그려 눈꺼풀이 감기고 뜨이는 곡선을 만든다.
.pem {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
}
.pem__form {
width: 100%;
max-width: 320px;
box-sizing: border-box;
}
.pem__field {
display: grid;
gap: $sp-2;
}
.pem__labelText {
color: rgba($subject-cream, .82);
font-weight: 600;
font-size: 13px;
line-height: 1.3;
}
.pem__inputWrap {
position: relative;
display: flex;
align-items: center;
}
.pem__input {
width: 100%;
box-sizing: border-box;
padding: $sp-3 $sp-10 $sp-3 $sp-4;
border-radius: $r-control;
border: 0;
background: rgba($stage-paper, .1);
color: transparent;
caret-color: $subject-cream;
font-weight: 500;
font-size: 17px;
line-height: 1.3;
letter-spacing: .06em;
&:focus { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
}
.pem__overlay {
position: absolute;
left: $sp-4;
right: $sp-10;
top: 50%;
transform: translateY(-50%);
display: flex;
pointer-events: none;
font-weight: 500;
font-size: 17px;
line-height: 1.3;
letter-spacing: .06em;
color: $subject-cream;
white-space: nowrap;
overflow: hidden;
}
.pem__ch {
position: relative;
display: inline-grid;
place-items: center;
min-width: .62em;
}
.pem__mask,
.pem__char {
grid-area: 1 / 1;
transition: opacity $dur-quick $ease-out;
}
.pem__mask { opacity: 1; font-size: 20px; line-height: 1; }
.pem__char { opacity: 0; }
.pem--revealed .pem__mask { opacity: 0; }
.pem--revealed .pem__char { opacity: 1; }
.pem__toggle {
position: absolute;
right: $sp-2;
top: 50%;
transform: translateY(-50%);
width: 36px;
height: 36px;
display: grid;
place-items: center;
border: 0;
border-radius: $r-pill;
background: transparent;
color: rgba($subject-cream, .82);
cursor: pointer;
&:hover { background: rgba($stage-paper, .1); }
&:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
}
.pem__eye { display: block; }
// 배경 톤: 새 계산을 얹지 않고 이미 있는 revealed 신호를 카드 전체 톤으로 넓힌다(077 기준 — 면적은 값의 전파 범위로).
.pem::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: rgba(91, 140, 248, 0);
transition: background-color .28s ease;
}
.pem--revealed::before {
background: rgba(91, 140, 248, .16);
}
@media (max-width: 340px) {
.pem__form { max-width: 260px; }
.pem__input { font-size: 15px; padding-right: $sp-8; }
.pem__overlay { font-size: 15px; right: $sp-8; }
}
@media (prefers-reduced-motion: reduce) {
.pem__mask,
.pem__char {
transition: none;
transition-delay: 0s;
}
.pem::before { 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);
}
.pem {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
}
.pem__form {
width: 100%;
max-width: 320px;
box-sizing: border-box;
}
.pem__field {
display: grid;
gap: 8px;
}
.pem__labelText {
color: rgba(255, 247, 230, 0.82);
font-weight: 600;
font-size: 13px;
line-height: 1.3;
}
.pem__inputWrap {
position: relative;
display: flex;
align-items: center;
}
.pem__input {
width: 100%;
box-sizing: border-box;
padding: 12px 40px 12px 16px;
border-radius: 12px;
border: 0;
background: rgba(255, 255, 255, 0.1);
color: transparent;
caret-color: #fff7e6;
font-weight: 500;
font-size: 17px;
line-height: 1.3;
letter-spacing: 0.06em;
}
.pem__input:focus {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.pem__overlay {
position: absolute;
left: 16px;
right: 40px;
top: 50%;
transform: translateY(-50%);
display: flex;
pointer-events: none;
font-weight: 500;
font-size: 17px;
line-height: 1.3;
letter-spacing: 0.06em;
color: #fff7e6;
white-space: nowrap;
overflow: hidden;
}
.pem__ch {
position: relative;
display: inline-grid;
place-items: center;
min-width: 0.62em;
}
.pem__mask,
.pem__char {
grid-area: 1/1;
transition: opacity 220ms ease-out;
}
.pem__mask {
opacity: 1;
font-size: 20px;
line-height: 1;
}
.pem__char {
opacity: 0;
}
.pem--revealed .pem__mask {
opacity: 0;
}
.pem--revealed .pem__char {
opacity: 1;
}
.pem__toggle {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
width: 36px;
height: 36px;
display: grid;
place-items: center;
border: 0;
border-radius: 999px;
background: transparent;
color: rgba(255, 247, 230, 0.82);
cursor: pointer;
}
.pem__toggle:hover {
background: rgba(255, 255, 255, 0.1);
}
.pem__toggle:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.pem__eye {
display: block;
}
.pem::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: rgba(91, 140, 248, 0);
transition: background-color 0.28s ease;
}
.pem--revealed::before {
background: rgba(91, 140, 248, 0.16);
}
@media (max-width: 340px) {
.pem__form {
max-width: 260px;
}
.pem__input {
font-size: 15px;
padding-right: 32px;
}
.pem__overlay {
font-size: 15px;
right: 32px;
}
}
@media (prefers-reduced-motion: reduce) {
.pem__mask,
.pem__char {
transition: none;
transition-delay: 0s;
}
.pem::before {
transition: none;
}
}(function () {
// 기법: 실제 input 의 type 을 password/text 로 진짜 토글하면서, 그 위에 문자별 span 오버레이
// (점 마스크 레이어 + 실제 글자 레이어)를 얹어 opacity 크로스페이드로 보여준다. input 자체 글자는
// color:transparent 로 숨기고 caret-color 만 남긴다. 눈 아이콘은 상태 스위치가 아니라 매 프레임
// 베지어 제어점(ctrlY)을 다시 계산해 눈꺼풀이 감기고 뜨이는 것처럼 그린다.
var root = document.getElementById('pemRoot');
var form = document.getElementById('pemForm');
var input = document.getElementById('pemInput');
var overlay = document.getElementById('pemOverlay');
var toggle = document.getElementById('pemToggle');
var lidTop = document.getElementById('pemLidTop');
var lidBottom = document.getElementById('pemLidBottom');
var pupil = document.getElementById('pemPupil');
var EYE_MORPH_MS = 260;
var CHAR_STAGGER_MS = 28;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var pwRand = seedRand(426011);
var POOL_UPPER = 'ABCDEFGHJKLMNPQRSTUVWXYZ';
var POOL_LOWER = 'abcdefghijkmnpqrstuvwxyz';
var POOL_DIGIT = '23456789';
var POOL_SYMBOL = '!@#$%*?-';
var POOL_ALL = POOL_UPPER + POOL_LOWER + POOL_DIGIT + POOL_SYMBOL;
function makeFakePassword() {
var len = 8 + Math.floor(pwRand() * 5);
var out = '';
for (var i = 0; i < len; i += 1) out += POOL_ALL.charAt(Math.floor(pwRand() * POOL_ALL.length));
return out;
}
var FAKE_PW = makeFakePassword();
input.value = FAKE_PW;
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
var LABEL_SHOW = 'パスワードを表示';
var LABEL_HIDE = 'パスワードを隠す';
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function buildOverlay(value) {
overlay.textContent = '';
for (var i = 0; i < value.length; i += 1) {
var ch = document.createElement('span');
ch.className = 'pem__ch';
var delay = (i * CHAR_STAGGER_MS) + 'ms';
var mask = document.createElement('span');
mask.className = 'pem__mask';
mask.style.transitionDelay = delay;
mask.textContent = String.fromCharCode(8226);
var real = document.createElement('span');
real.className = 'pem__char';
real.style.transitionDelay = delay;
real.textContent = value.charAt(i);
ch.appendChild(mask);
ch.appendChild(real);
overlay.appendChild(ch);
}
}
buildOverlay(FAKE_PW);
input.addEventListener('input', function () {
buildOverlay(input.value);
});
var eyeProgress = 0;
var eyeRaf = null;
function paintEye(progress) {
var eased = easeOutCubic(progress);
var topY = 12 - 8 * eased;
var botY = 12 + 8 * eased;
lidTop.setAttribute('d', 'M2,12 Q12,' + topY + ' 22,12');
lidBottom.setAttribute('d', 'M2,12 Q12,' + botY + ' 22,12');
pupil.style.opacity = String(eased);
}
paintEye(eyeProgress);
function animateEyeStep(ts) {
if (animT0 === null) animT0 = ts;
var elapsed = ts - animT0;
var p = Math.min(1, elapsed / EYE_MORPH_MS);
eyeProgress = animStart + (animTarget - animStart) * p;
paintEye(eyeProgress);
if (p < 1) { eyeRaf = requestAnimationFrame(animateEyeStep); }
else { eyeRaf = null; eyeProgress = animTarget; paintEye(eyeProgress); }
}
var animT0 = null, animStart = 0, animTarget = 0;
function animateEyeTo(target) {
if (eyeRaf) { cancelAnimationFrame(eyeRaf); eyeRaf = null; }
if (reduce) { eyeProgress = target; paintEye(eyeProgress); return; }
animStart = eyeProgress;
animTarget = target;
animT0 = null;
eyeRaf = requestAnimationFrame(animateEyeStep);
}
var revealed = false;
function setRevealed(next) {
revealed = next;
input.type = revealed ? 'text' : 'password';
root.classList.toggle('pem--revealed', revealed);
toggle.setAttribute('aria-pressed', String(revealed));
toggle.setAttribute('aria-label', revealed ? LABEL_HIDE : LABEL_SHOW);
animateEyeTo(revealed ? 1 : 0);
}
toggle.addEventListener('click', function () {
root.classList.remove('is-demo');
setRevealed(!revealed);
});
form.addEventListener('submit', function (e) { e.preventDefault(); });
if (reduce) { root.classList.remove('is-demo'); }
function demoLoop() {
if (reduce) return;
setRevealed(true);
setTimeout(function () { setRevealed(false); }, 900);
setTimeout(demoLoop, 2000);
}
if (!reduce) { setTimeout(demoLoop, 200); }
})();import { useEffect, useRef } from "react";
import styles from "./PasswordRevealEyeIconMorph.module.scss";
type Box<T> = { current: T };
const EYE_MORPH_MS = 260;
const CHAR_STAGGER_MS = 28;
const LABEL_SHOW = "비밀번호 표시";
const LABEL_HIDE = "비밀번호 숨기기";
export default function PasswordRevealEyeIconMorph() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement | null>;
const formRef = useRef(null) as unknown as Box<HTMLFormElement | null>;
const inputRef = useRef(null) as unknown as Box<HTMLInputElement | null>;
const overlayRef = useRef(null) as unknown as Box<HTMLSpanElement | null>;
const toggleRef = useRef(null) as unknown as Box<HTMLButtonElement | null>;
const lidTopRef = useRef(null) as unknown as Box<SVGPathElement | null>;
const lidBottomRef = useRef(null) as unknown as Box<SVGPathElement | null>;
const pupilRef = useRef(null) as unknown as Box<SVGCircleElement | null>;
useEffect(() => {
const root = rootRef.current;
const form = formRef.current;
const input = inputRef.current;
const overlay = overlayRef.current;
const toggle = toggleRef.current;
const lidTop = lidTopRef.current;
const lidBottom = lidBottomRef.current;
const pupil = pupilRef.current;
if (!root || !form || !input || !overlay || !toggle || !lidTop || !lidBottom || !pupil) return;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const pwRand = seedRand(426012);
const POOL_UPPER = "ABCDEFGHJKLMNPQRSTUVWXYZ";
const POOL_LOWER = "abcdefghijkmnpqrstuvwxyz";
const POOL_DIGIT = "23456789";
const POOL_SYMBOL = "!@#$%*?-";
const POOL_ALL = POOL_UPPER + POOL_LOWER + POOL_DIGIT + POOL_SYMBOL;
function makeFakePassword() {
const len = 8 + Math.floor(pwRand() * 5);
let out = "";
for (let i = 0; i < len; i += 1) out += POOL_ALL.charAt(Math.floor(pwRand() * POOL_ALL.length));
return out;
}
const FAKE_PW = makeFakePassword();
input.value = FAKE_PW;
function easeOutCubic(t: number) { return 1 - Math.pow(1 - t, 3); }
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function buildOverlay(value: string) {
overlay!.textContent = "";
for (let i = 0; i < value.length; i += 1) {
const ch = document.createElement("span");
ch.className = styles.ch;
const delay = (i * CHAR_STAGGER_MS) + "ms";
const mask = document.createElement("span");
mask.className = styles.mask;
mask.style.transitionDelay = delay;
mask.textContent = String.fromCharCode(8226);
const real = document.createElement("span");
real.className = styles.char;
real.style.transitionDelay = delay;
real.textContent = value.charAt(i);
ch.appendChild(mask);
ch.appendChild(real);
overlay!.appendChild(ch);
}
}
buildOverlay(FAKE_PW);
function onInput() { buildOverlay(input!.value); }
input.addEventListener("input", onInput);
let eyeProgress = 0;
let eyeRaf = 0;
function paintEye(progress: number) {
const eased = easeOutCubic(progress);
const topY = 12 - 8 * eased;
const botY = 12 + 8 * eased;
lidTop!.setAttribute("d", "M2,12 Q12," + topY + " 22,12");
lidBottom!.setAttribute("d", "M2,12 Q12," + botY + " 22,12");
pupil!.style.opacity = String(eased);
}
paintEye(eyeProgress);
let animT0: number | null = null;
let animStart = 0;
let animTarget = 0;
function animateEyeStep(ts: number) {
if (animT0 === null) animT0 = ts;
const elapsed = ts - animT0;
const p = Math.min(1, elapsed / EYE_MORPH_MS);
eyeProgress = animStart + (animTarget - animStart) * p;
paintEye(eyeProgress);
if (p < 1) { eyeRaf = requestAnimationFrame(animateEyeStep); }
else { eyeRaf = 0; eyeProgress = animTarget; paintEye(eyeProgress); }
}
function animateEyeTo(target: number) {
if (eyeRaf) { cancelAnimationFrame(eyeRaf); eyeRaf = 0; }
if (reduce) { eyeProgress = target; paintEye(eyeProgress); return; }
animStart = eyeProgress;
animTarget = target;
animT0 = null;
eyeRaf = requestAnimationFrame(animateEyeStep);
}
let revealed = false;
function setRevealed(next: boolean) {
revealed = next;
input!.type = revealed ? "text" : "password";
root!.classList.toggle(styles["is-revealed"], revealed);
toggle!.setAttribute("aria-pressed", String(revealed));
toggle!.setAttribute("aria-label", revealed ? LABEL_HIDE : LABEL_SHOW);
animateEyeTo(revealed ? 1 : 0);
}
function onToggleClick() {
root!.classList.remove(styles["is-demo"]);
setRevealed(!revealed);
}
toggle.addEventListener("click", onToggleClick);
function onSubmit(e: Event) { e.preventDefault(); }
form.addEventListener("submit", onSubmit);
if (reduce) { root.classList.remove(styles["is-demo"]); }
let loopTimer = 0;
let closeTimer = 0;
function demoLoop() {
if (reduce) return;
setRevealed(true);
closeTimer = window.setTimeout(function () { setRevealed(false); }, 900);
loopTimer = window.setTimeout(demoLoop, 2000);
}
let startTimer = 0;
if (!reduce) { startTimer = window.setTimeout(demoLoop, 200); }
return () => {
input.removeEventListener("input", onInput);
toggle.removeEventListener("click", onToggleClick);
form.removeEventListener("submit", onSubmit);
if (eyeRaf) cancelAnimationFrame(eyeRaf);
window.clearTimeout(startTimer);
window.clearTimeout(loopTimer);
window.clearTimeout(closeTimer);
};
}, []);
return (
<main className={styles.stage}>
<div ref={rootRef} className={`${styles.pem} ${styles["is-demo"]}`}>
<form ref={formRef} className={styles.form} noValidate>
<label className={styles.field}>
<span className={styles.labelText}>비밀번호</span>
<span className={styles.inputWrap}>
<input
ref={inputRef}
className={styles.input}
type="password"
autoComplete="new-password"
spellCheck={false}
defaultValue=""
/>
<span ref={overlayRef} className={styles.overlay} aria-hidden="true" />
<button ref={toggleRef} className={styles.toggle} type="button" aria-pressed="false" aria-label="비밀번호 표시">
<svg className={styles.eye} viewBox="0 0 24 24" width={22} height={22} aria-hidden="true">
<path ref={lidTopRef} d="M2,12 Q12,4 22,12" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" />
<path ref={lidBottomRef} d="M2,12 Q12,20 22,12" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" />
<circle ref={pupilRef} cx={12} cy={12} r={3.2} fill="currentColor" />
</svg>
</button>
</span>
</label>
</form>
</div>
</main>
);
}01 · 文字と点が入れ替わる目のアイコンのモーフ — 計測 累積面積 76.015% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function buildOverlay(value) {
overlay.textContent = '';
for (var i = 0; i < value.length; i += 1) {
var ch = document.createElement('span');
ch.className = 'pem__ch';
var delay = (i * CHAR_STAGGER_MS) + 'ms';
var mask = document.createElement('span');
mask.className = 'pem__mask';
mask.style.transitionDelay = delay;
mask.textContent = String.fromCharCode(8226);
var real = document.createElement('span');
real.className = 'pem__char';
real.style.transitionDelay = delay;
real.textContent = value.charAt(i);
ch.appendChild(mask);
ch.appendChild(real);
overlay.appendChild(ch);
}
}
buildOverlay(FAKE_PW);
01番は23フレーム中14フレームで動き、累積面積76.0%・平均強度44.6を記録しました。
02Caps Lockのオンをスプリングで知らせる警告バッジ
onKey() は keydown と keyup のたびに本物の KeyboardEvent.getModifierState('CapsLock') を読み取り、その真偽値をそのまま setCapsLock() に渡しています。この関数が変えるのは target という数値ひとつだけです。stepSpring() は毎フレームその target を pos と vel に対して積分するので、CAPSLOCK_BADGE_OVERSHOOT_PX の値がバッジが静止位置をどれだけ超えて跳ねるかを決め、最後に paintBadge() が元の位置へ収めます。

<main class="stage">
<div class="pcs is-demo" id="pcsRoot">
<form class="pcs__form" id="pcsForm" novalidate>
<label class="pcs__field">
<span class="pcs__labelRow">
<span class="pcs__labelText">パスワード</span>
<span class="pcs__badge" id="pcsBadge" role="status">
<svg class="pcs__badgeIcon" width="1em" height="1em" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24m0 192a88 88 0 1 1 88-88a88.1 88.1 0 0 1-88 88m-8-80V80a8 8 0 0 1 16 0v56a8 8 0 0 1-16 0m20 36a12 12 0 1 1-12-12a12 12 0 0 1 12 12"/></g></svg>
<span class="pcs__badgeText">Caps Lock</span>
</span>
<span class="pcs__srAnnounce" id="pcsAnnounce" aria-live="polite"></span>
</span>
<span class="pcs__inputWrap">
<input class="pcs__input" id="pcsInput" type="password" autocomplete="new-password" spellcheck="false" value="">
<button class="pcs__toggle" id="pcsToggle" type="button" aria-pressed="false" aria-label="パスワードを表示">
<svg class="pcs__eyeShow" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<path d="M2,12 C5,6 9,4 12,4 C15,4 19,6 22,12 C19,18 15,20 12,20 C9,20 5,18 2,12 Z" fill="none" stroke="currentColor" stroke-width="2"/>
<circle cx="12" cy="12" r="3.2" fill="currentColor"/>
</svg>
<svg class="pcs__eyeHide" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<path d="M2,12 Q12,4 22,12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M2,12 Q12,20 22,12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
</span>
</label>
</form>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-yellow);
// 계산 겹: 배지 pos 는 언더댐프 스프링 적분(가속도=강성*오차-감쇠*속도)으로 매 프레임 갱신되고,
// pos 가 1 을 넘는 구간(overshoot)만큼 CAPSLOCK_BADGE_OVERSHOOT_PX 를 곱해 튀어 오르는 높이를 만든다.
// 실제 CapsLock 상태는 input 의 keydown/keyup 이벤트에서 getModifierState 로 읽는다.
.pcs {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
}
.pcs__form {
width: 100%;
max-width: 320px;
box-sizing: border-box;
}
.pcs__field {
display: grid;
gap: $sp-2;
}
.pcs__labelRow {
display: flex;
align-items: center;
gap: $sp-2;
min-height: 26px;
}
.pcs__labelText {
color: rgba($subject-ink, .82);
font-weight: 600;
font-size: 13px;
line-height: 1.3;
}
.pcs__badge {
display: inline-flex;
align-items: center;
gap: $sp-1;
padding: $sp-1 $sp-2;
border-radius: $r-xs;
background: $stage-paper;
color: $subject-ink;
font-weight: 700;
font-size: 11px;
line-height: 1.4;
opacity: 0;
transform: translateY(14px) scale(.86);
transform-origin: left center;
will-change: transform, opacity;
}
.pcs__badge--active {
box-shadow: $shadow-press;
}
.pcs__badgeIcon {
flex: 0 0 auto;
color: $subject-orange-deep;
}
.pcs__srAnnounce {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.pcs__inputWrap {
position: relative;
display: flex;
align-items: center;
}
.pcs__input {
width: 100%;
box-sizing: border-box;
padding: $sp-3 $sp-10 $sp-3 $sp-4;
border-radius: $r-control;
border: 0;
background: rgba($subject-ink, .06);
color: $subject-ink;
caret-color: $subject-ink;
font-weight: 500;
font-size: 17px;
line-height: 1.3;
letter-spacing: .06em;
&:focus { outline: 2px solid $subject-blue; outline-offset: 2px; }
}
.pcs__toggle {
position: absolute;
right: $sp-2;
top: 50%;
transform: translateY(-50%);
width: 36px;
height: 36px;
display: grid;
place-items: center;
border: 0;
border-radius: $r-pill;
background: transparent;
color: rgba($subject-ink, .78);
cursor: pointer;
&:hover { background: rgba($subject-ink, .08); }
&:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
}
.pcs__eyeHide { display: none; }
.pcs--revealed .pcs__eyeShow { display: none; }
.pcs--revealed .pcs__eyeHide { display: block; }
// 배경 톤: Caps Lock 경고를 배지 하나에 가두지 않고 카드 톤 전체로 넓힌다(계산 추가 없이 기존 신호 재사용).
.pcs::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: rgba(201, 48, 0, 0);
transition: background-color .22s ease;
}
.pcs--warning::before {
background: rgba(201, 48, 0, .12);
}
@media (max-width: 340px) {
.pcs__form { max-width: 260px; }
.pcs__input { font-size: 15px; padding-right: $sp-8; }
.pcs__badgeText { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.pcs__badge {
transition: none;
}
.pcs::before { transition: none; }
}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);
}
.pcs {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
}
.pcs__form {
width: 100%;
max-width: 320px;
box-sizing: border-box;
}
.pcs__field {
display: grid;
gap: 8px;
}
.pcs__labelRow {
display: flex;
align-items: center;
gap: 8px;
min-height: 26px;
}
.pcs__labelText {
color: rgba(23, 20, 26, 0.82);
font-weight: 600;
font-size: 13px;
line-height: 1.3;
}
.pcs__badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 4px;
background: #ffffff;
color: #17141a;
font-weight: 700;
font-size: 11px;
line-height: 1.4;
opacity: 0;
transform: translateY(14px) scale(0.86);
transform-origin: left center;
will-change: transform, opacity;
}
.pcs__badge--active {
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.pcs__badgeIcon {
flex: 0 0 auto;
color: #c93000;
}
.pcs__srAnnounce {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.pcs__inputWrap {
position: relative;
display: flex;
align-items: center;
}
.pcs__input {
width: 100%;
box-sizing: border-box;
padding: 12px 40px 12px 16px;
border-radius: 12px;
border: 0;
background: rgba(23, 20, 26, 0.06);
color: #17141a;
caret-color: #17141a;
font-weight: 500;
font-size: 17px;
line-height: 1.3;
letter-spacing: 0.06em;
}
.pcs__input:focus {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.pcs__toggle {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
width: 36px;
height: 36px;
display: grid;
place-items: center;
border: 0;
border-radius: 999px;
background: transparent;
color: rgba(23, 20, 26, 0.78);
cursor: pointer;
}
.pcs__toggle:hover {
background: rgba(23, 20, 26, 0.08);
}
.pcs__toggle:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.pcs__eyeHide {
display: none;
}
.pcs--revealed .pcs__eyeShow {
display: none;
}
.pcs--revealed .pcs__eyeHide {
display: block;
}
.pcs::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: rgba(201, 48, 0, 0);
transition: background-color 0.22s ease;
}
.pcs--warning::before {
background: rgba(201, 48, 0, 0.12);
}
@media (max-width: 340px) {
.pcs__form {
max-width: 260px;
}
.pcs__input {
font-size: 15px;
padding-right: 32px;
}
.pcs__badgeText {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.pcs__badge {
transition: none;
}
.pcs::before {
transition: none;
}
}(function () {
// 기법: keydown/keyup 에서 실제 KeyboardEvent.getModifierState('CapsLock') 을 읽어 setCapsLock() 하나로
// 몰아 보낸다. 배지는 항상 DOM 에 있고 pos 스프링 값(0~1, 언더댐프라 1을 넘겨 튄다)으로 매 프레임
// translateY·scale·opacity 를 다시 그린다. CAPSLOCK_BADGE_OVERSHOOT_PX 는 그 튐의 최대 폭(px).
var root = document.getElementById('pcsRoot');
var form = document.getElementById('pcsForm');
var input = document.getElementById('pcsInput');
var toggle = document.getElementById('pcsToggle');
var badge = document.getElementById('pcsBadge');
var announce = document.getElementById('pcsAnnounce');
var CAPSLOCK_BADGE_OVERSHOOT_PX = 6;
var SPRING_STIFFNESS = 210;
var SPRING_DAMPING = 14;
var BADGE_RISE_PX = 14;
var LABEL_SHOW = 'パスワードを表示';
var LABEL_HIDE = 'パスワードを隠す';
var ANNOUNCE_ON = 'Caps Lockオン';
var ANNOUNCE_OFF = 'Caps Lockオフ';
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var pwRand = seedRand(426021);
var POOL_ALL = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%*?-';
function makeFakePassword() {
var len = 8 + Math.floor(pwRand() * 5);
var out = '';
for (var i = 0; i < len; i += 1) out += POOL_ALL.charAt(Math.floor(pwRand() * POOL_ALL.length));
return out;
}
input.value = makeFakePassword();
var revealed = false;
function setRevealed(next) {
revealed = next;
input.type = revealed ? 'text' : 'password';
toggle.setAttribute('aria-pressed', String(revealed));
toggle.setAttribute('aria-label', revealed ? LABEL_HIDE : LABEL_SHOW);
root.classList.toggle('pcs--revealed', revealed);
}
toggle.addEventListener('click', function () {
root.classList.remove('is-demo');
setRevealed(!revealed);
});
var pos = 0;
var vel = 0;
var target = 0;
var springRaf = null;
var lastT = null;
function paintBadge(p) {
var clamped = Math.min(1, Math.max(0, p));
var overshoot = Math.max(0, p - 1);
var y = BADGE_RISE_PX * (1 - clamped) - CAPSLOCK_BADGE_OVERSHOOT_PX * overshoot;
var scale = 0.86 + 0.14 * clamped + 0.05 * overshoot;
var opacity = Math.min(1, clamped * 3);
badge.style.transform = 'translateY(' + y.toFixed(2) + 'px) scale(' + scale.toFixed(3) + ')';
badge.style.opacity = String(opacity);
}
paintBadge(pos);
function stepSpring(ts) {
if (lastT === null) lastT = ts;
var dt = Math.min(0.032, (ts - lastT) / 1000);
lastT = ts;
var acc = (target - pos) * SPRING_STIFFNESS - vel * SPRING_DAMPING;
vel += acc * dt;
pos += vel * dt;
paintBadge(pos);
var settled = Math.abs(target - pos) < 0.002 && Math.abs(vel) < 0.01;
if (!settled) {
springRaf = requestAnimationFrame(stepSpring);
} else {
pos = target;
paintBadge(pos);
springRaf = null;
lastT = null;
}
}
function setCapsLock(on) {
var next = on ? 1 : 0;
if (target === next) return;
target = next;
badge.classList.toggle('pcs__badge--active', on);
root.classList.toggle('pcs--warning', on);
announce.textContent = on ? ANNOUNCE_ON : ANNOUNCE_OFF;
if (reduce) {
if (springRaf) { cancelAnimationFrame(springRaf); springRaf = null; }
pos = target;
vel = 0;
lastT = null;
paintBadge(pos);
return;
}
if (!springRaf) { lastT = null; springRaf = requestAnimationFrame(stepSpring); }
}
function onKey(e) {
if (!e.getModifierState) return;
setCapsLock(e.getModifierState('CapsLock'));
}
input.addEventListener('keydown', onKey);
input.addEventListener('keyup', onKey);
form.addEventListener('submit', function (e) { e.preventDefault(); });
if (reduce) { root.classList.remove('is-demo'); }
function demoLoop() {
if (reduce) return;
setCapsLock(true);
setTimeout(function () { setCapsLock(false); }, 1100);
setTimeout(demoLoop, 2200);
}
if (!reduce) { setTimeout(demoLoop, 240); }
})();import { useEffect, useRef } from "react";
import styles from "./PasswordRevealCapslockWarningSpring.module.scss";
type Box<T> = { current: T };
const CAPSLOCK_BADGE_OVERSHOOT_PX = 6;
const SPRING_STIFFNESS = 210;
const SPRING_DAMPING = 14;
const BADGE_RISE_PX = 14;
const LABEL_SHOW = "비밀번호 표시";
const LABEL_HIDE = "비밀번호 숨기기";
const ANNOUNCE_ON = "Caps Lock 켜짐";
const ANNOUNCE_OFF = "Caps Lock 꺼짐";
export default function PasswordRevealCapslockWarningSpring() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement | null>;
const formRef = useRef(null) as unknown as Box<HTMLFormElement | null>;
const inputRef = useRef(null) as unknown as Box<HTMLInputElement | null>;
const toggleRef = useRef(null) as unknown as Box<HTMLButtonElement | null>;
const badgeRef = useRef(null) as unknown as Box<HTMLSpanElement | null>;
const announceRef = useRef(null) as unknown as Box<HTMLSpanElement | null>;
useEffect(() => {
const root = rootRef.current;
const form = formRef.current;
const input = inputRef.current;
const toggle = toggleRef.current;
const badge = badgeRef.current;
const announce = announceRef.current;
if (!root || !form || !input || !toggle || !badge || !announce) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const pwRand = seedRand(426022);
const POOL_ALL = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%*?-";
function makeFakePassword() {
const len = 8 + Math.floor(pwRand() * 5);
let out = "";
for (let i = 0; i < len; i += 1) out += POOL_ALL.charAt(Math.floor(pwRand() * POOL_ALL.length));
return out;
}
input.value = makeFakePassword();
let revealed = false;
function setRevealed(next: boolean) {
revealed = next;
input!.type = revealed ? "text" : "password";
toggle!.setAttribute("aria-pressed", String(revealed));
toggle!.setAttribute("aria-label", revealed ? LABEL_HIDE : LABEL_SHOW);
root!.classList.toggle(styles["is-revealed"], revealed);
}
function onToggleClick() {
root!.classList.remove(styles["is-demo"]);
setRevealed(!revealed);
}
toggle.addEventListener("click", onToggleClick);
let pos = 0;
let vel = 0;
let target = 0;
let springRaf = 0;
let lastT: number | null = null;
function paintBadge(p: number) {
const clamped = Math.min(1, Math.max(0, p));
const overshoot = Math.max(0, p - 1);
const y = BADGE_RISE_PX * (1 - clamped) - CAPSLOCK_BADGE_OVERSHOOT_PX * overshoot;
const scale = 0.86 + 0.14 * clamped + 0.05 * overshoot;
const opacity = Math.min(1, clamped * 3);
badge!.style.transform = "translateY(" + y.toFixed(2) + "px) scale(" + scale.toFixed(3) + ")";
badge!.style.opacity = String(opacity);
}
paintBadge(pos);
function stepSpring(ts: number) {
if (lastT === null) lastT = ts;
const dt = Math.min(0.032, (ts - lastT) / 1000);
lastT = ts;
const acc = (target - pos) * SPRING_STIFFNESS - vel * SPRING_DAMPING;
vel += acc * dt;
pos += vel * dt;
paintBadge(pos);
const settled = Math.abs(target - pos) < 0.002 && Math.abs(vel) < 0.01;
if (!settled) {
springRaf = requestAnimationFrame(stepSpring);
} else {
pos = target;
paintBadge(pos);
springRaf = 0;
lastT = null;
}
}
function setCapsLock(on: boolean) {
const next = on ? 1 : 0;
if (target === next) return;
target = next;
badge!.classList.toggle(styles["badge--active"], on);
root!.classList.toggle(styles.warning, on);
announce!.textContent = on ? ANNOUNCE_ON : ANNOUNCE_OFF;
if (reduce) {
if (springRaf) { cancelAnimationFrame(springRaf); springRaf = 0; }
pos = target;
vel = 0;
lastT = null;
paintBadge(pos);
return;
}
if (!springRaf) { lastT = null; springRaf = requestAnimationFrame(stepSpring); }
}
function onKey(e: KeyboardEvent) {
if (!e.getModifierState) return;
setCapsLock(e.getModifierState("CapsLock"));
}
input.addEventListener("keydown", onKey);
input.addEventListener("keyup", onKey);
function onSubmit(e: Event) { e.preventDefault(); }
form.addEventListener("submit", onSubmit);
if (reduce) { root.classList.remove(styles["is-demo"]); }
let loopTimer = 0;
let offTimer = 0;
function demoLoop() {
if (reduce) return;
setCapsLock(true);
offTimer = window.setTimeout(function () { setCapsLock(false); }, 1100);
loopTimer = window.setTimeout(demoLoop, 2200);
}
let startTimer = 0;
if (!reduce) { startTimer = window.setTimeout(demoLoop, 240); }
return () => {
toggle.removeEventListener("click", onToggleClick);
input.removeEventListener("keydown", onKey);
input.removeEventListener("keyup", onKey);
form.removeEventListener("submit", onSubmit);
if (springRaf) cancelAnimationFrame(springRaf);
window.clearTimeout(startTimer);
window.clearTimeout(loopTimer);
window.clearTimeout(offTimer);
};
}, []);
return (
<main className={styles.stage}>
<div ref={rootRef} className={`${styles.pcs} ${styles["is-demo"]}`}>
<form ref={formRef} className={styles.form} noValidate>
<label className={styles.field}>
<span className={styles.labelRow}>
<span className={styles.labelText}>비밀번호</span>
<span ref={badgeRef} className={styles.badge} role="status">
<svg className={styles.badgeIcon} width="1em" height="1em" viewBox="0 0 256 256" aria-hidden="true">
<g fill="currentColor">
<path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2" />
<path d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24m0 192a88 88 0 1 1 88-88a88.1 88.1 0 0 1-88 88m-8-80V80a8 8 0 0 1 16 0v56a8 8 0 0 1-16 0m20 36a12 12 0 1 1-12-12a12 12 0 0 1 12 12" />
</g>
</svg>
<span className={styles.badgeText}>Caps Lock</span>
</span>
<span ref={announceRef} className={styles.srAnnounce} aria-live="polite" />
</span>
<span className={styles.inputWrap}>
<input
ref={inputRef}
className={styles.input}
type="password"
autoComplete="new-password"
spellCheck={false}
defaultValue=""
/>
<button ref={toggleRef} className={styles.toggle} type="button" aria-pressed="false" aria-label="비밀번호 표시">
<svg className={styles.eyeShow} viewBox="0 0 24 24" width={20} height={20} aria-hidden="true">
<path d="M2,12 C5,6 9,4 12,4 C15,4 19,6 22,12 C19,18 15,20 12,20 C9,20 5,18 2,12 Z" fill="none" stroke="currentColor" strokeWidth={2} />
<circle cx={12} cy={12} r={3.2} fill="currentColor" />
</svg>
<svg className={styles.eyeHide} viewBox="0 0 24 24" width={20} height={20} aria-hidden="true">
<path d="M2,12 Q12,4 22,12" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" />
<path d="M2,12 Q12,20 22,12" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" />
</svg>
</button>
</span>
</label>
</form>
</div>
</main>
);
}02 · Caps Lockのオンをスプリングで知らせる警告バッジ — 計測 累積面積 75.869% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function stepSpring(ts) {
if (lastT === null) lastT = ts;
var dt = Math.min(0.032, (ts - lastT) / 1000);
lastT = ts;
var acc = (target - pos) * SPRING_STIFFNESS - vel * SPRING_DAMPING;
vel += acc * dt;
pos += vel * dt;
paintBadge(pos);
var settled = Math.abs(target - pos) < 0.002 && Math.abs(vel) < 0.01;
if (!settled) {
springRaf = requestAnimationFrame(stepSpring);
} else {
pos = target;
paintBadge(pos);
springRaf = null;
lastT = null;
}
}
function setCapsLock(on) {
var next = on ? 1 : 0;
if (target === next) return;
target = next;
badge.classList.toggle('pcs__badge--active', on);
root.classList.toggle('pcs--warning', on);
02番は23フレーム中15フレームで動き、累積面積75.9%・平均強度49.3を記録しました。
03複数の入力欄が一斉に開く同期トグル
一つの onToggle() ハンドラがパスワード欄と確認用パスワード欄で共有された revealed 値を切り替え、paintEyes() はその同じイージング済みの進行値を一回の関数呼び出しの中で両方の目のアイコンのまぶたパスと瞳へ同時に書き込みます。SYNC_EYE_MORPH_MS はどちらの欄も参照する唯一のタイミング値で、それぞれの欄が別々の進行値を持つことはありません。

<main class="stage">
<div class="pfs is-demo" id="pfsRoot">
<form class="pfs__form" id="pfsForm" novalidate>
<label class="pfs__field">
<span class="pfs__labelText">パスワード</span>
<span class="pfs__inputWrap">
<input class="pfs__input" id="pfsInputA" type="password" autocomplete="new-password" spellcheck="false" value="">
<button class="pfs__toggle" id="pfsToggleA" type="button" aria-pressed="false" aria-label="パスワードを表示">
<svg class="pfs__eye" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<path class="pfs__lidTop" id="pfsLidTopA" d="M2,12 Q12,4 22,12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path class="pfs__lidBottom" id="pfsLidBottomA" d="M2,12 Q12,20 22,12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<circle class="pfs__pupil" id="pfsPupilA" cx="12" cy="12" r="3.2" fill="currentColor"/>
</svg>
</button>
</span>
</label>
<label class="pfs__field">
<span class="pfs__labelText">パスワード確認</span>
<span class="pfs__inputWrap">
<input class="pfs__input" id="pfsInputB" type="password" autocomplete="new-password" spellcheck="false" value="">
<button class="pfs__toggle" id="pfsToggleB" type="button" aria-pressed="false" aria-label="パスワードを表示">
<svg class="pfs__eye" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<path class="pfs__lidTop" id="pfsLidTopB" d="M2,12 Q12,4 22,12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path class="pfs__lidBottom" id="pfsLidBottomB" d="M2,12 Q12,20 22,12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<circle class="pfs__pupil" id="pfsPupilB" cx="12" cy="12" r="3.2" fill="currentColor"/>
</svg>
</button>
</span>
</label>
<p class="pfs__hint" id="pfsHint">両方の欄は一つの状態を共有します</p>
</form>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-orange);
// 계산 겹: 두 필드가 각각 눈 아이콘을 갖지만 실제로 갱신하는 값은 eyeProgress 하나뿐 —
// 어느 버튼을 눌러도 setRevealed() 가 paintEyes() 를 한 번 불러 두 SVG path d 를 같은 프레임에 다시 그린다.
.pfs {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
}
.pfs__form {
width: 100%;
max-width: 320px;
box-sizing: border-box;
display: grid;
gap: $sp-3;
}
.pfs__field {
display: grid;
gap: $sp-2;
}
.pfs__labelText {
color: rgba($subject-ink, .82);
font-weight: 600;
font-size: 13px;
line-height: 1.3;
}
.pfs__inputWrap {
position: relative;
display: flex;
align-items: center;
}
.pfs__input {
width: 100%;
box-sizing: border-box;
padding: $sp-3 $sp-10 $sp-3 $sp-4;
border-radius: $r-control;
border: 0;
background: rgba($subject-ink, .08);
color: $subject-ink;
caret-color: $subject-ink;
font-weight: 500;
font-size: 16px;
line-height: 1.3;
letter-spacing: .06em;
&:focus { outline: 2px solid $subject-blue; outline-offset: 2px; }
}
.pfs__toggle {
position: absolute;
right: $sp-2;
top: 50%;
transform: translateY(-50%);
width: 34px;
height: 34px;
display: grid;
place-items: center;
border: 0;
border-radius: $r-pill;
background: transparent;
color: rgba($subject-ink, .78);
cursor: pointer;
&:hover { background: rgba($subject-ink, .1); }
&:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
}
.pfs__eye { display: block; }
.pfs__hint {
margin: 0;
color: rgba($subject-ink, .62);
font-weight: 500;
font-size: 11px;
line-height: 1.4;
text-align: center;
}
// 배경 톤: 두 필드가 공유하는 revealed 신호 하나를 카드 톤으로 넓힌다(기법 그대로, 계산 겹 추가 없음).
.pfs::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: rgba(91, 140, 248, 0);
transition: background-color .24s ease;
}
.pfs--revealed::before {
background: rgba(91, 140, 248, .14);
}
@media (max-width: 340px) {
.pfs__form { max-width: 260px; }
.pfs__input { font-size: 14px; padding-right: $sp-8; }
}
@media (prefers-reduced-motion: reduce) {
.pfs__lidTop,
.pfs__lidBottom,
.pfs__pupil {
transition: none;
}
.pfs::before { transition: none; }
}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);
}
.pfs {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
}
.pfs__form {
width: 100%;
max-width: 320px;
box-sizing: border-box;
display: grid;
gap: 12px;
}
.pfs__field {
display: grid;
gap: 8px;
}
.pfs__labelText {
color: rgba(23, 20, 26, 0.82);
font-weight: 600;
font-size: 13px;
line-height: 1.3;
}
.pfs__inputWrap {
position: relative;
display: flex;
align-items: center;
}
.pfs__input {
width: 100%;
box-sizing: border-box;
padding: 12px 40px 12px 16px;
border-radius: 12px;
border: 0;
background: rgba(23, 20, 26, 0.08);
color: #17141a;
caret-color: #17141a;
font-weight: 500;
font-size: 16px;
line-height: 1.3;
letter-spacing: 0.06em;
}
.pfs__input:focus {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.pfs__toggle {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
width: 34px;
height: 34px;
display: grid;
place-items: center;
border: 0;
border-radius: 999px;
background: transparent;
color: rgba(23, 20, 26, 0.78);
cursor: pointer;
}
.pfs__toggle:hover {
background: rgba(23, 20, 26, 0.1);
}
.pfs__toggle:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.pfs__eye {
display: block;
}
.pfs__hint {
margin: 0;
color: rgba(23, 20, 26, 0.62);
font-weight: 500;
font-size: 11px;
line-height: 1.4;
text-align: center;
}
.pfs::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: rgba(91, 140, 248, 0);
transition: background-color 0.24s ease;
}
.pfs--revealed::before {
background: rgba(91, 140, 248, 0.14);
}
@media (max-width: 340px) {
.pfs__form {
max-width: 260px;
}
.pfs__input {
font-size: 14px;
padding-right: 32px;
}
}
@media (prefers-reduced-motion: reduce) {
.pfs__lidTop,
.pfs__lidBottom,
.pfs__pupil {
transition: none;
}
.pfs::before {
transition: none;
}
}(function () {
// 기법: 두 필드가 각자 토글 버튼을 갖지만 실제로 바꾸는 값은 단 하나(shared revealed)뿐이다.
// 어느 버튼을 눌러도 setRevealed() 하나가 두 input·두 눈 아이콘을 동시에 갱신하고, 눈 모프는
// 공유 eyeProgress 값 하나로 두 SVG 를 같은 프레임에 다시 그린다(paintEyes).
var root = document.getElementById('pfsRoot');
var form = document.getElementById('pfsForm');
var inputA = document.getElementById('pfsInputA');
var inputB = document.getElementById('pfsInputB');
var toggleA = document.getElementById('pfsToggleA');
var toggleB = document.getElementById('pfsToggleB');
var lidTopA = document.getElementById('pfsLidTopA');
var lidBottomA = document.getElementById('pfsLidBottomA');
var pupilA = document.getElementById('pfsPupilA');
var lidTopB = document.getElementById('pfsLidTopB');
var lidBottomB = document.getElementById('pfsLidBottomB');
var pupilB = document.getElementById('pfsPupilB');
var SYNC_EYE_MORPH_MS = 260;
var LABEL_SHOW = 'パスワードを表示';
var LABEL_HIDE = 'パスワードを隠す';
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var pwRand = seedRand(426031);
var POOL_ALL = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%*?-';
function makeFakePassword() {
var len = 8 + Math.floor(pwRand() * 5);
var out = '';
for (var i = 0; i < len; i += 1) out += POOL_ALL.charAt(Math.floor(pwRand() * POOL_ALL.length));
return out;
}
var FAKE_PW = makeFakePassword();
inputA.value = FAKE_PW;
inputB.value = FAKE_PW;
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
function paintEyes(progress) {
var eased = easeOutCubic(progress);
var topY = 12 - 8 * eased;
var botY = 12 + 8 * eased;
var d1 = 'M2,12 Q12,' + topY + ' 22,12';
var d2 = 'M2,12 Q12,' + botY + ' 22,12';
lidTopA.setAttribute('d', d1);
lidBottomA.setAttribute('d', d2);
pupilA.style.opacity = String(eased);
lidTopB.setAttribute('d', d1);
lidBottomB.setAttribute('d', d2);
pupilB.style.opacity = String(eased);
}
var eyeProgress = 0;
var eyeRaf = null;
var animT0 = null, animStart = 0, animTarget = 0;
paintEyes(eyeProgress);
function animateEyeStep(ts) {
if (animT0 === null) animT0 = ts;
var elapsed = ts - animT0;
var p = Math.min(1, elapsed / SYNC_EYE_MORPH_MS);
eyeProgress = animStart + (animTarget - animStart) * p;
paintEyes(eyeProgress);
if (p < 1) { eyeRaf = requestAnimationFrame(animateEyeStep); }
else { eyeRaf = null; eyeProgress = animTarget; paintEyes(eyeProgress); }
}
function animateEyeTo(target) {
if (eyeRaf) { cancelAnimationFrame(eyeRaf); eyeRaf = null; }
if (reduce) { eyeProgress = target; paintEyes(eyeProgress); return; }
animStart = eyeProgress;
animTarget = target;
animT0 = null;
eyeRaf = requestAnimationFrame(animateEyeStep);
}
var revealed = false;
function setRevealed(next) {
revealed = next;
var type = revealed ? 'text' : 'password';
inputA.type = type;
inputB.type = type;
root.classList.toggle('pfs--revealed', revealed);
var label = revealed ? LABEL_HIDE : LABEL_SHOW;
toggleA.setAttribute('aria-pressed', String(revealed));
toggleA.setAttribute('aria-label', label);
toggleB.setAttribute('aria-pressed', String(revealed));
toggleB.setAttribute('aria-label', label);
animateEyeTo(revealed ? 1 : 0);
}
function onToggle() {
root.classList.remove('is-demo');
setRevealed(!revealed);
}
toggleA.addEventListener('click', onToggle);
toggleB.addEventListener('click', onToggle);
form.addEventListener('submit', function (e) { e.preventDefault(); });
if (reduce) { root.classList.remove('is-demo'); }
function demoLoop() {
if (reduce) return;
setRevealed(true);
setTimeout(function () { setRevealed(false); }, 900);
setTimeout(demoLoop, 2000);
}
if (!reduce) { setTimeout(demoLoop, 220); }
})();import { useEffect, useRef } from "react";
import styles from "./PasswordRevealMultiFieldSyncToggle.module.scss";
type Box<T> = { current: T };
const SYNC_EYE_MORPH_MS = 260;
const LABEL_SHOW = "비밀번호 표시";
const LABEL_HIDE = "비밀번호 숨기기";
export default function PasswordRevealMultiFieldSyncToggle() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement | null>;
const formRef = useRef(null) as unknown as Box<HTMLFormElement | null>;
const inputARef = useRef(null) as unknown as Box<HTMLInputElement | null>;
const inputBRef = useRef(null) as unknown as Box<HTMLInputElement | null>;
const toggleARef = useRef(null) as unknown as Box<HTMLButtonElement | null>;
const toggleBRef = useRef(null) as unknown as Box<HTMLButtonElement | null>;
const lidTopARef = useRef(null) as unknown as Box<SVGPathElement | null>;
const lidBottomARef = useRef(null) as unknown as Box<SVGPathElement | null>;
const pupilARef = useRef(null) as unknown as Box<SVGCircleElement | null>;
const lidTopBRef = useRef(null) as unknown as Box<SVGPathElement | null>;
const lidBottomBRef = useRef(null) as unknown as Box<SVGPathElement | null>;
const pupilBRef = useRef(null) as unknown as Box<SVGCircleElement | null>;
useEffect(() => {
const root = rootRef.current;
const form = formRef.current;
const inputA = inputARef.current;
const inputB = inputBRef.current;
const toggleA = toggleARef.current;
const toggleB = toggleBRef.current;
const lidTopA = lidTopARef.current;
const lidBottomA = lidBottomARef.current;
const pupilA = pupilARef.current;
const lidTopB = lidTopBRef.current;
const lidBottomB = lidBottomBRef.current;
const pupilB = pupilBRef.current;
if (!root || !form || !inputA || !inputB || !toggleA || !toggleB || !lidTopA || !lidBottomA || !pupilA || !lidTopB || !lidBottomB || !pupilB) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const pwRand = seedRand(426032);
const POOL_ALL = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%*?-";
function makeFakePassword() {
const len = 8 + Math.floor(pwRand() * 5);
let out = "";
for (let i = 0; i < len; i += 1) out += POOL_ALL.charAt(Math.floor(pwRand() * POOL_ALL.length));
return out;
}
const FAKE_PW = makeFakePassword();
inputA.value = FAKE_PW;
inputB.value = FAKE_PW;
function easeOutCubic(t: number) { return 1 - Math.pow(1 - t, 3); }
function paintEyes(progress: number) {
const eased = easeOutCubic(progress);
const topY = 12 - 8 * eased;
const botY = 12 + 8 * eased;
const d1 = "M2,12 Q12," + topY + " 22,12";
const d2 = "M2,12 Q12," + botY + " 22,12";
lidTopA!.setAttribute("d", d1);
lidBottomA!.setAttribute("d", d2);
pupilA!.style.opacity = String(eased);
lidTopB!.setAttribute("d", d1);
lidBottomB!.setAttribute("d", d2);
pupilB!.style.opacity = String(eased);
}
let eyeProgress = 0;
let eyeRaf = 0;
let animT0: number | null = null;
let animStart = 0;
let animTarget = 0;
paintEyes(eyeProgress);
function animateEyeStep(ts: number) {
if (animT0 === null) animT0 = ts;
const elapsed = ts - animT0;
const p = Math.min(1, elapsed / SYNC_EYE_MORPH_MS);
eyeProgress = animStart + (animTarget - animStart) * p;
paintEyes(eyeProgress);
if (p < 1) { eyeRaf = requestAnimationFrame(animateEyeStep); }
else { eyeRaf = 0; eyeProgress = animTarget; paintEyes(eyeProgress); }
}
function animateEyeTo(target: number) {
if (eyeRaf) { cancelAnimationFrame(eyeRaf); eyeRaf = 0; }
if (reduce) { eyeProgress = target; paintEyes(eyeProgress); return; }
animStart = eyeProgress;
animTarget = target;
animT0 = null;
eyeRaf = requestAnimationFrame(animateEyeStep);
}
let revealed = false;
function setRevealed(next: boolean) {
revealed = next;
const type = revealed ? "text" : "password";
inputA!.type = type;
inputB!.type = type;
root!.classList.toggle(styles["is-revealed"], revealed);
const label = revealed ? LABEL_HIDE : LABEL_SHOW;
toggleA!.setAttribute("aria-pressed", String(revealed));
toggleA!.setAttribute("aria-label", label);
toggleB!.setAttribute("aria-pressed", String(revealed));
toggleB!.setAttribute("aria-label", label);
animateEyeTo(revealed ? 1 : 0);
}
function onToggle() {
root!.classList.remove(styles["is-demo"]);
setRevealed(!revealed);
}
toggleA.addEventListener("click", onToggle);
toggleB.addEventListener("click", onToggle);
function onSubmit(e: Event) { e.preventDefault(); }
form.addEventListener("submit", onSubmit);
if (reduce) { root.classList.remove(styles["is-demo"]); }
let loopTimer = 0;
let closeTimer = 0;
function demoLoop() {
if (reduce) return;
setRevealed(true);
closeTimer = window.setTimeout(function () { setRevealed(false); }, 900);
loopTimer = window.setTimeout(demoLoop, 2000);
}
let startTimer = 0;
if (!reduce) { startTimer = window.setTimeout(demoLoop, 220); }
return () => {
toggleA.removeEventListener("click", onToggle);
toggleB.removeEventListener("click", onToggle);
form.removeEventListener("submit", onSubmit);
if (eyeRaf) cancelAnimationFrame(eyeRaf);
window.clearTimeout(startTimer);
window.clearTimeout(loopTimer);
window.clearTimeout(closeTimer);
};
}, []);
return (
<main className={styles.stage}>
<div ref={rootRef} className={`${styles.pfs} ${styles["is-demo"]}`}>
<form ref={formRef} className={styles.form} noValidate>
<label className={styles.field}>
<span className={styles.labelText}>비밀번호</span>
<span className={styles.inputWrap}>
<input
ref={inputARef}
className={styles.input}
type="password"
autoComplete="new-password"
spellCheck={false}
defaultValue=""
/>
<button ref={toggleARef} className={styles.toggle} type="button" aria-pressed="false" aria-label="비밀번호 표시">
<svg className={styles.eye} viewBox="0 0 24 24" width={20} height={20} aria-hidden="true">
<path ref={lidTopARef} d="M2,12 Q12,4 22,12" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" />
<path ref={lidBottomARef} d="M2,12 Q12,20 22,12" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" />
<circle ref={pupilARef} cx={12} cy={12} r={3.2} fill="currentColor" />
</svg>
</button>
</span>
</label>
<label className={styles.field}>
<span className={styles.labelText}>비밀번호 확인</span>
<span className={styles.inputWrap}>
<input
ref={inputBRef}
className={styles.input}
type="password"
autoComplete="new-password"
spellCheck={false}
defaultValue=""
/>
<button ref={toggleBRef} className={styles.toggle} type="button" aria-pressed="false" aria-label="비밀번호 표시">
<svg className={styles.eye} viewBox="0 0 24 24" width={20} height={20} aria-hidden="true">
<path ref={lidTopBRef} d="M2,12 Q12,4 22,12" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" />
<path ref={lidBottomBRef} d="M2,12 Q12,20 22,12" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" />
<circle ref={pupilBRef} cx={12} cy={12} r={3.2} fill="currentColor" />
</svg>
</button>
</span>
</label>
<p className={styles.hint}>두 칸은 하나의 상태를 공유합니다</p>
</form>
</div>
</main>
);
}03 · 複数の入力欄が一斉に開く同期トグル — 計測 累積面積 76.029% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function paintEyes(progress) {
var eased = easeOutCubic(progress);
var topY = 12 - 8 * eased;
var botY = 12 + 8 * eased;
var d1 = 'M2,12 Q12,' + topY + ' 22,12';
var d2 = 'M2,12 Q12,' + botY + ' 22,12';
lidTopA.setAttribute('d', d1);
lidBottomA.setAttribute('d', d2);
pupilA.style.opacity = String(eased);
lidTopB.setAttribute('d', d1);
lidBottomB.setAttribute('d', d2);
pupilB.style.opacity = String(eased);
}
var eyeProgress = 0;
var eyeRaf = null;
var animT0 = null, animStart = 0, animTarget = 0;
paintEyes(eyeProgress);
function animateEyeStep(ts) {
03番は23フレーム中10フレームで動き、累積面積76.0%・平均強度65.2を記録しました。
04貼り付けを検知してせり出す案内バナー
この入力欄はキー入力の回数や文字数の急な変化ではなく、本物の paste イベントそのものを監視しています。handlePaste() は event.clipboardData を一切読み取らないので、バナーは「何かが貼り付けられた」ことだけを伝え、その中身は伝えません。PASTE_BANNER_VISIBLE_MS がバナーの表示時間を決め、その後 PASTE_BANNER_SLIDE_MS が退場にかかる時間を決めています。

<main class="stage">
<div class="pdb is-demo">
<label class="pdb__label" for="pdb-input">パスワード</label>
<div class="pdb__frame">
<input id="pdb-input" class="pdb__input" type="password" placeholder="パスワードを入力してください" autocomplete="current-password">
</div>
<p class="pdb__banner" id="pdbBanner" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.pdb {
width: min(300px, 86vw);
display: grid;
gap: $sp-2;
}
.pdb__label {
font-weight: 700;
font-size: 13px;
line-height: 1;
color: $subject-ink;
}
.pdb__frame { position: relative; }
.pdb__input {
box-sizing: border-box;
width: 100%;
border: 2px solid rgba($subject-ink, .18);
border-radius: $r-control;
padding: $sp-3 $sp-4;
font-weight: 500;
font-size: clamp(14px, 4vw, 16px);
line-height: 1;
font-family: inherit;
color: $subject-ink;
background: #fff;
letter-spacing: .06em;
&:focus { outline: none; border-color: $subject-blue-deep; }
}
.pdb__banner {
position: relative;
margin: 0;
padding: $sp-2 $sp-3 $sp-2 $sp-4;
border-radius: $r-sm;
background: #f3f1ea;
color: $subject-ink;
font-weight: 500;
font-size: 13px;
line-height: 1.4;
opacity: 0;
transform: translateY(-10px);
transition: transform var(--pdbSlideMs, 320ms) $ease-out, opacity var(--pdbSlideMs, 320ms) $ease-out;
&::before {
content: '';
position: absolute;
left: 0; top: 0; bottom: 0;
width: 4px;
border-radius: $r-xs 0 0 $r-xs;
background: $subject-blue-deep;
}
&.is-visible {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 340px) {
.pdb { width: min(260px, 88vw); }
}
@media (prefers-reduced-motion: reduce) {
.pdb__banner { transition: none; }
}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);
}
.pdb {
width: min(300px, 86vw);
display: grid;
gap: 8px;
}
.pdb__label {
font-weight: 700;
font-size: 13px;
line-height: 1;
color: #17141a;
}
.pdb__frame {
position: relative;
}
.pdb__input {
box-sizing: border-box;
width: 100%;
border: 2px solid rgba(23, 20, 26, 0.18);
border-radius: 12px;
padding: 12px 16px;
font-weight: 500;
font-size: clamp(14px, 4vw, 16px);
line-height: 1;
font-family: inherit;
color: #17141a;
background: #fff;
letter-spacing: 0.06em;
}
.pdb__input:focus {
outline: none;
border-color: #0a4fe8;
}
.pdb__banner {
position: relative;
margin: 0;
padding: 8px 12px 8px 16px;
border-radius: 8px;
background: #f3f1ea;
color: #17141a;
font-weight: 500;
font-size: 13px;
line-height: 1.4;
opacity: 0;
transform: translateY(-10px);
transition: transform var(--pdbSlideMs, 320ms) ease-out, opacity var(--pdbSlideMs, 320ms) ease-out;
}
.pdb__banner::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
border-radius: 4px 0 0 4px;
background: #0a4fe8;
}
.pdb__banner.is-visible {
opacity: 1;
transform: translateY(0);
}
@media (max-width: 340px) {
.pdb {
width: min(260px, 88vw);
}
}
@media (prefers-reduced-motion: reduce) {
.pdb__banner {
transition: none;
}
}(function () {
// 기법: 실제 input paste 이벤트를 그대로 트리거로 쓴다(값 자체는 읽지 않는다).
// 배너는 슬라이드+opacity 로 필드 아래에서 나타났다가 일정 시간 뒤 같은 방식으로 사라진다.
var PASTE_BANNER_SLIDE_MS = 320;
var PASTE_BANNER_VISIBLE_MS = 1400;
var BANNER_MESSAGE = 'パスワード管理アプリから貼り付けた値を確認してください';
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var root = document.querySelector('.pdb');
var input = document.getElementById('pdb-input');
var banner = document.getElementById('pdbBanner');
root.style.setProperty('--pdbSlideMs', PASTE_BANNER_SLIDE_MS + 'ms');
var hideTimer = null;
var clearTimer = null;
function handlePaste() {
clearTimeout(hideTimer);
clearTimeout(clearTimer);
banner.textContent = BANNER_MESSAGE;
banner.classList.add('is-visible');
hideTimer = setTimeout(function () {
banner.classList.remove('is-visible');
clearTimer = setTimeout(function () { banner.textContent = ''; }, PASTE_BANNER_SLIDE_MS);
}, PASTE_BANNER_VISIBLE_MS);
}
input.addEventListener('paste', function () { handlePaste(); });
var pasteRand = seedRand(426041);
var pool = 'ABCDEFGHJKMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789';
var fakeValue = '';
for (var i = 0; i < 10; i += 1) fakeValue += pool[Math.floor(pasteRand() * pool.length)];
function simulatePaste() {
input.value = fakeValue;
handlePaste();
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) root.classList.remove('is-demo');
if (root.classList.contains('is-demo')) {
setTimeout(function () {
simulatePaste();
setInterval(simulatePaste, 2000);
}, 200);
}
})();import { useEffect, useRef } from "react";
import styles from "./PasswordRevealPasteDetectBanner.module.scss";
export default function PasswordRevealPasteDetectBanner() {
const rootRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const bannerRef = useRef<HTMLParagraphElement>(null);
useEffect(() => {
const root = rootRef.current;
const input = inputRef.current;
const banner = bannerRef.current;
if (!root || !input || !banner) return;
const PASTE_BANNER_SLIDE_MS = 320;
const PASTE_BANNER_VISIBLE_MS = 1400;
const BANNER_MESSAGE = "비밀번호 관리자에서 붙여넣은 값을 확인하세요";
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
root.style.setProperty("--pdbSlideMs", PASTE_BANNER_SLIDE_MS + "ms");
let hideTimer: ReturnType<typeof setTimeout> | null = null;
let clearTimer: ReturnType<typeof setTimeout> | null = null;
function handlePaste() {
if (hideTimer) clearTimeout(hideTimer);
if (clearTimer) clearTimeout(clearTimer);
banner!.textContent = BANNER_MESSAGE;
banner!.classList.add(styles.isVisible);
hideTimer = setTimeout(() => {
banner!.classList.remove(styles.isVisible);
clearTimer = setTimeout(() => { banner!.textContent = ""; }, PASTE_BANNER_SLIDE_MS);
}, PASTE_BANNER_VISIBLE_MS);
}
function onPaste() { handlePaste(); }
input.addEventListener("paste", onPaste);
const pasteRand = seedRand(426041);
const pool = "ABCDEFGHJKMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789";
let fakeValue = "";
for (let i = 0; i < 10; i += 1) fakeValue += pool[Math.floor(pasteRand() * pool.length)];
function simulatePaste() {
input!.value = fakeValue;
handlePaste();
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) root.classList.remove("is-demo");
let loopTimer: ReturnType<typeof setTimeout> | null = null;
let loopInterval: ReturnType<typeof setInterval> | null = null;
if (root.classList.contains("is-demo")) {
loopTimer = setTimeout(() => {
simulatePaste();
loopInterval = setInterval(simulatePaste, 2000);
}, 200);
}
return () => {
input.removeEventListener("paste", onPaste);
if (hideTimer) clearTimeout(hideTimer);
if (clearTimer) clearTimeout(clearTimer);
if (loopTimer) clearTimeout(loopTimer);
if (loopInterval) clearInterval(loopInterval);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.pdb} is-demo`} ref={rootRef}>
<label className={styles.pdb__label} htmlFor="pdb-input-react">비밀번호</label>
<div className={styles.pdb__frame}>
<input
id="pdb-input-react"
ref={inputRef}
className={styles.pdb__input}
type="password"
placeholder="비밀번호를 입력하세요"
autoComplete="current-password"
/>
</div>
<p className={styles.pdb__banner} ref={bannerRef} aria-live="polite" />
</div>
</main>
);
}04 · 貼り付けを検知してせり出す案内バナー — 計測 累積面積 10.463% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function handlePaste() {
clearTimeout(hideTimer);
clearTimeout(clearTimer);
banner.textContent = BANNER_MESSAGE;
banner.classList.add('is-visible');
hideTimer = setTimeout(function () {
banner.classList.remove('is-visible');
clearTimer = setTimeout(function () { banner.textContent = ''; }, PASTE_BANNER_SLIDE_MS);
}, PASTE_BANNER_VISIBLE_MS);
}
input.addEventListener('paste', function () { handlePaste(); });
04番は23フレーム中12フレームで動き、累積面積10.5%・平均強度57.4を記録しました。
05ブラウザの自動入力を検知して広がるハイライトグロー
このパターンの裏側に自動入力検知APIは存在しません — ソース内のコメントにもそう明記されています。代わりに「疑似入力」ボタンが fillAndGlow() を呼び、欄を埋めると同時に glowTick() を起動します。この関数は毎フレーム上昇するサインカーブに指数的な減衰を掛け合わせているので、AUTOFILL_GLOW_PULSE_MS が制御しているのはループの周期ではなく一回分のパルスの長さだけです。

<main class="stage">
<div class="afg is-demo">
<label class="afg__label" for="afg-input">保存済みパスワード</label>
<div class="afg__frame">
<input id="afg-input" class="afg__input" type="password" placeholder="自動入力待ち">
</div>
<button type="button" class="afg__btn" id="afgBtn">自動入力を再現</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.afg {
position: relative;
width: min(300px, 86vw);
display: grid;
gap: $sp-3;
justify-items: center;
}
// 배경 톤: 이미 계산 중인 --glow 엔벌로프를 입력창 테두리 밖으로도 넓혀 카드 톤 전체가 같이 숨쉬게 한다.
.afg::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: rgba(110, 200, 255, calc(var(--glow, 0) * .22));
}
.afg__label {
align-self: flex-start;
font-weight: 700;
font-size: 13px;
line-height: 1;
color: rgba($subject-cream, .75);
}
.afg__frame { position: relative; width: 100%; }
.afg__input {
--glow: 0;
box-sizing: border-box;
width: 100%;
border: 2px solid rgba($subject-cream, .3);
border-radius: $r-control;
padding: $sp-3 $sp-4;
font-weight: 500;
font-size: clamp(14px, 4vw, 16px);
line-height: 1;
font-family: inherit;
color: $subject-cream;
background: rgba($subject-cream, .06);
letter-spacing: .1em;
box-shadow:
0 0 calc(var(--glow) * 26px) rgba(110, 200, 255, .9),
0 0 calc(var(--glow) * 8px) rgba(110, 200, 255, .7);
&:focus { outline: none; }
}
.afg__btn {
border: 0;
border-radius: $r-pill;
padding: $sp-2 $sp-5;
font-weight: 600;
font-size: 13px;
line-height: 1;
font-family: inherit;
color: $stage-ink;
background: $subject-cream;
cursor: pointer;
transition: transform $dur-quick $ease-pop;
&:active { transform: scale(.96); }
}
@media (max-width: 340px) {
.afg { width: min(260px, 88vw); }
}
@media (prefers-reduced-motion: reduce) {
.afg__btn { 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);
}
.afg {
position: relative;
width: min(300px, 86vw);
display: grid;
gap: 12px;
justify-items: center;
}
.afg::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: rgba(110, 200, 255, calc(var(--glow, 0) * 0.22));
}
.afg__label {
align-self: flex-start;
font-weight: 700;
font-size: 13px;
line-height: 1;
color: rgba(255, 247, 230, 0.75);
}
.afg__frame {
position: relative;
width: 100%;
}
.afg__input {
--glow: 0;
box-sizing: border-box;
width: 100%;
border: 2px solid rgba(255, 247, 230, 0.3);
border-radius: 12px;
padding: 12px 16px;
font-weight: 500;
font-size: clamp(14px, 4vw, 16px);
line-height: 1;
font-family: inherit;
color: #fff7e6;
background: rgba(255, 247, 230, 0.06);
letter-spacing: 0.1em;
box-shadow: 0 0 calc(var(--glow) * 26px) rgba(110, 200, 255, 0.9), 0 0 calc(var(--glow) * 8px) rgba(110, 200, 255, 0.7);
}
.afg__input:focus {
outline: none;
}
.afg__btn {
border: 0;
border-radius: 999px;
padding: 8px 20px;
font-weight: 600;
font-size: 13px;
line-height: 1;
font-family: inherit;
color: #17141a;
background: #fff7e6;
cursor: pointer;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.afg__btn:active {
transform: scale(0.96);
}
@media (max-width: 340px) {
.afg {
width: min(260px, 88vw);
}
}
@media (prefers-reduced-motion: reduce) {
.afg__btn {
transition: none;
}
}(function () {
// 기법: 실제 autofill 감지 API 는 쓰지 않는다("흉내" 버튼이 유일한 트리거).
// 글로우는 트리거 이후 경과 t 에 대한 계산 엔벌로프(sin 상승 * exp 감쇠)를 매 프레임
// --glow CSS 변수로 갱신해 box-shadow 가 그 변수를 읽게 한다.
var AUTOFILL_GLOW_PULSE_MS = 900;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var root = document.querySelector('.afg');
var input = document.getElementById('afg-input');
var btn = document.getElementById('afgBtn');
var pwRand = seedRand(426051);
var pool = 'ABCDEFGHJKMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#';
var seedValue = '';
for (var i = 0; i < 11; i += 1) seedValue += pool[Math.floor(pwRand() * pool.length)];
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var glowRaf = 0;
var glowStartTs = null;
function glowTick(ts) {
if (glowStartTs === null) glowStartTs = ts;
var t = ts - glowStartTs;
if (t > AUTOFILL_GLOW_PULSE_MS) {
input.style.setProperty('--glow', '0');
root.style.setProperty('--glow', '0');
glowRaf = 0;
glowStartTs = null;
return;
}
var envelope = Math.max(0, Math.sin(Math.PI * t / AUTOFILL_GLOW_PULSE_MS)) * Math.exp(-2.2 * t / AUTOFILL_GLOW_PULSE_MS);
input.style.setProperty('--glow', String(envelope));
root.style.setProperty('--glow', String(envelope));
glowRaf = requestAnimationFrame(glowTick);
}
function resetField() {
input.value = '';
}
function fillAndGlow() {
input.value = seedValue;
if (reduce) {
input.style.setProperty('--glow', '0');
return;
}
if (glowRaf) cancelAnimationFrame(glowRaf);
glowStartTs = null;
glowRaf = requestAnimationFrame(glowTick);
}
btn.addEventListener('click', fillAndGlow);
if (reduce) root.classList.remove('is-demo');
if (root.classList.contains('is-demo')) {
setTimeout(function () {
fillAndGlow();
setInterval(function () {
resetField();
fillAndGlow();
}, 2000);
}, 200);
}
})();import { useEffect, useRef } from "react";
import styles from "./PasswordRevealAutofillHighlightGlow.module.scss";
export default function PasswordRevealAutofillHighlightGlow() {
const rootRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const btnRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const root = rootRef.current;
const input = inputRef.current;
const btn = btnRef.current;
if (!root || !input || !btn) return;
const AUTOFILL_GLOW_PULSE_MS = 900;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const pwRand = seedRand(426051);
const pool = "ABCDEFGHJKMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#";
let seedValue = "";
for (let i = 0; i < 11; i += 1) seedValue += pool[Math.floor(pwRand() * pool.length)];
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let glowRaf = 0;
let glowStartTs: number | null = null;
function glowTick(ts: number) {
if (glowStartTs === null) glowStartTs = ts;
const t = ts - glowStartTs;
if (t > AUTOFILL_GLOW_PULSE_MS) {
input!.style.setProperty("--glow", "0");
root!.style.setProperty("--glow", "0");
glowRaf = 0;
glowStartTs = null;
return;
}
const envelope = Math.max(0, Math.sin(Math.PI * t / AUTOFILL_GLOW_PULSE_MS)) * Math.exp(-2.2 * t / AUTOFILL_GLOW_PULSE_MS);
input!.style.setProperty("--glow", String(envelope));
root!.style.setProperty("--glow", String(envelope));
glowRaf = requestAnimationFrame(glowTick);
}
function resetField() {
input!.value = "";
}
function fillAndGlow() {
input!.value = seedValue;
if (reduce) {
input!.style.setProperty("--glow", "0");
return;
}
if (glowRaf) cancelAnimationFrame(glowRaf);
glowStartTs = null;
glowRaf = requestAnimationFrame(glowTick);
}
function onClick() { fillAndGlow(); }
btn.addEventListener("click", onClick);
if (reduce) root.classList.remove("is-demo");
let loopTimer: ReturnType<typeof setTimeout> | null = null;
let loopInterval: ReturnType<typeof setInterval> | null = null;
if (root.classList.contains("is-demo")) {
loopTimer = setTimeout(() => {
fillAndGlow();
loopInterval = setInterval(() => {
resetField();
fillAndGlow();
}, 2000);
}, 200);
}
return () => {
btn.removeEventListener("click", onClick);
if (glowRaf) cancelAnimationFrame(glowRaf);
if (loopTimer) clearTimeout(loopTimer);
if (loopInterval) clearInterval(loopInterval);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.afg} is-demo`} ref={rootRef}>
<label className={styles.afg__label} htmlFor="afg-input-react">저장된 비밀번호</label>
<div className={styles.afg__frame}>
<input
id="afg-input-react"
ref={inputRef}
className={styles.afg__input}
type="password"
placeholder="자동입력 대기 중"
/>
</div>
<button type="button" className={styles.afg__btn} ref={btnRef}>자동입력 흉내</button>
</div>
</main>
);
}05 · ブラウザの自動入力を検知して広がるハイライトグロー — 計測 累積面積 23.938% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function glowTick(ts) {
if (glowStartTs === null) glowStartTs = ts;
var t = ts - glowStartTs;
if (t > AUTOFILL_GLOW_PULSE_MS) {
input.style.setProperty('--glow', '0');
root.style.setProperty('--glow', '0');
glowRaf = 0;
glowStartTs = null;
return;
}
var envelope = Math.max(0, Math.sin(Math.PI * t / AUTOFILL_GLOW_PULSE_MS)) * Math.exp(-2.2 * t / AUTOFILL_GLOW_PULSE_MS);
input.style.setProperty('--glow', String(envelope));
root.style.setProperty('--glow', String(envelope));
glowRaf = requestAnimationFrame(glowTick);
}
function resetField() {
input.value = '';
}
05番は23フレーム中11フレームで動き、累積面積23.9%・平均強度34.0を記録しました。
06生成ボタンで一文字ずつ現れるカスケード
generatePassword() は読み込み時に一度だけ seedRand() で組んだ候補プールから文字列を選びます。stepCascade() は経過時間を積み上げ、その値が GENERATE_CASCADE_STEP_MS を超えるたびに文字を一つずつ表示していきます。全文字が出そろうと同じ関数が COPY_MORPH_MS で計るクロスフェードへ切り替わりチェックマークへモーフしますが、そのコピーアイコンの裏に Clipboard API の呼び出しはありません。

<main class="stage">
<div class="gcc is-demo">
<label class="gcc__label" for="gcc-display">生成されたパスワード</label>
<div class="gcc__frame">
<div class="gcc__display" id="gccDisplay" aria-live="polite"></div>
<span class="gcc__status" aria-hidden="true">
<svg class="gcc__icon gcc__icon--copy" viewBox="0 0 256 256" width="22" height="22"><g fill="currentColor"><path d="M216 40v128h-48V88H88V40Z" opacity=".2"/><path d="M216 32H88a8 8 0 0 0-8 8v40H40a8 8 0 0 0-8 8v128a8 8 0 0 0 8 8h128a8 8 0 0 0 8-8v-40h40a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-56 176H48V96h112Zm48-48h-32V88a8 8 0 0 0-8-8H96V48h112Z"/></g></svg>
<span class="gcc__check">
<span class="gcc__checkBar gcc__checkBar--short"></span>
<span class="gcc__checkBar gcc__checkBar--long"></span>
</span>
</span>
</div>
<button type="button" class="gcc__gen" id="gccGenBtn">生成</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-yellow);
.gcc {
position: relative;
width: min(300px, 86vw);
display: grid;
gap: $sp-3;
justify-items: center;
}
// 배경 톤: 복사 확인 체크가 아이콘 한 칸에 머물지 않고 카드 톤으로 번진다(is-copied 신호 재사용).
.gcc::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: rgba(76, 212, 166, 0);
transition: background-color .26s ease;
}
.gcc.is-copied::before {
background: rgba(76, 212, 166, .16);
}
.gcc__label {
align-self: flex-start;
font-weight: 700;
font-size: 13px;
line-height: 1;
color: rgba($subject-ink, .7);
}
.gcc__frame {
position: relative;
width: 100%;
display: flex;
align-items: center;
gap: $sp-3;
box-sizing: border-box;
border: 2px solid rgba($subject-ink, .18);
border-radius: $r-control;
padding: $sp-3 $sp-4;
background: #fff;
}
.gcc__display {
flex: 1;
min-height: 1.3em;
font-weight: 600;
font-size: clamp(14px, 4vw, 16px);
line-height: 1.3;
font-family: "SFMono-Regular", Consolas, monospace;
color: $subject-ink;
letter-spacing: .04em;
word-break: break-all;
}
.gcc__status {
position: relative;
flex: none;
width: 22px;
height: 22px;
}
.gcc__icon {
position: absolute;
inset: 0;
width: 22px;
height: 22px;
color: $subject-ink;
}
.gcc__icon--copy { opacity: calc(1 - var(--copyMorph, 0)); }
.gcc__check {
position: absolute;
inset: 0;
opacity: var(--copyMorph, 0);
}
.gcc__checkBar {
position: absolute;
left: 5px;
top: 11px;
height: 2.4px;
border-radius: $r-pill;
background: $subject-ink;
transform-origin: left center;
}
.gcc__checkBar--short { width: 6px; transform: rotate(225deg); }
.gcc__checkBar--long { width: 12px; transform: rotate(-48deg); }
.gcc__gen {
border: 0;
border-radius: $r-pill;
padding: $sp-2 $sp-6;
font-weight: 700;
font-size: 13px;
line-height: 1;
font-family: inherit;
color: $subject-cream;
background: $subject-ink;
cursor: pointer;
transition: transform $dur-quick $ease-pop;
&:active { transform: scale(.96); }
}
@media (max-width: 340px) {
.gcc { width: min(260px, 88vw); }
}
@media (prefers-reduced-motion: reduce) {
.gcc__gen { transition: none; }
.gcc::before { transition: none; }
}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);
}
.gcc {
position: relative;
width: min(300px, 86vw);
display: grid;
gap: 12px;
justify-items: center;
}
.gcc::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: rgba(76, 212, 166, 0);
transition: background-color 0.26s ease;
}
.gcc.is-copied::before {
background: rgba(76, 212, 166, 0.16);
}
.gcc__label {
align-self: flex-start;
font-weight: 700;
font-size: 13px;
line-height: 1;
color: rgba(23, 20, 26, 0.7);
}
.gcc__frame {
position: relative;
width: 100%;
display: flex;
align-items: center;
gap: 12px;
box-sizing: border-box;
border: 2px solid rgba(23, 20, 26, 0.18);
border-radius: 12px;
padding: 12px 16px;
background: #fff;
}
.gcc__display {
flex: 1;
min-height: 1.3em;
font-weight: 600;
font-size: clamp(14px, 4vw, 16px);
line-height: 1.3;
font-family: "SFMono-Regular", Consolas, monospace;
color: #17141a;
letter-spacing: 0.04em;
word-break: break-all;
}
.gcc__status {
position: relative;
flex: none;
width: 22px;
height: 22px;
}
.gcc__icon {
position: absolute;
inset: 0;
width: 22px;
height: 22px;
color: #17141a;
}
.gcc__icon--copy {
opacity: calc(1 - var(--copyMorph, 0));
}
.gcc__check {
position: absolute;
inset: 0;
opacity: var(--copyMorph, 0);
}
.gcc__checkBar {
position: absolute;
left: 5px;
top: 11px;
height: 2.4px;
border-radius: 999px;
background: #17141a;
transform-origin: left center;
}
.gcc__checkBar--short {
width: 6px;
transform: rotate(225deg);
}
.gcc__checkBar--long {
width: 12px;
transform: rotate(-48deg);
}
.gcc__gen {
border: 0;
border-radius: 999px;
padding: 8px 24px;
font-weight: 700;
font-size: 13px;
line-height: 1;
font-family: inherit;
color: #fff7e6;
background: #17141a;
cursor: pointer;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.gcc__gen:active {
transform: scale(0.96);
}
@media (max-width: 340px) {
.gcc {
width: min(260px, 88vw);
}
}
@media (prefers-reduced-motion: reduce) {
.gcc__gen {
transition: none;
}
.gcc::before {
transition: none;
}
}(function () {
// 기법: 후보 문자열은 seedRand 로 모듈 초기화 시 미리 만든다(클릭마다 인덱스만 순환 — 결정적).
// 표시는 spawnAcc 류 rAF 누적 타이머로 한 글자씩 채우고, 다 채워지면 복사→체크 아이콘을
// opacity 크로스페이드로 모프한다. clipboard API 호출은 없다(시각 전용).
var GENERATE_CASCADE_STEP_MS = 60;
var COPY_MORPH_MS = 220;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var root = document.querySelector('.gcc');
var display = document.getElementById('gccDisplay');
var genBtn = document.getElementById('gccGenBtn');
var candRand = seedRand(426061);
var pool = 'ABCDEFGHJKMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$';
var CANDIDATES = [];
for (var c = 0; c < 4; c += 1) {
var len = 8 + Math.floor(candRand() * 3);
var s = '';
for (var j = 0; j < len; j += 1) s += pool[Math.floor(candRand() * pool.length)];
CANDIDATES.push(s);
}
var candIndex = -1;
var cascade = { target: '', revealed: 0, acc: 0, active: false, morphing: false, morphAcc: 0 };
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function generatePassword() {
candIndex = (candIndex + 1) % CANDIDATES.length;
var target = CANDIDATES[candIndex];
cascade.target = target;
cascade.acc = 0;
cascade.morphAcc = 0;
root.style.setProperty('--copyMorph', '0');
root.classList.remove('is-copied');
if (reduce) {
cascade.revealed = target.length;
cascade.active = false;
cascade.morphing = false;
display.textContent = target;
root.style.setProperty('--copyMorph', '1');
root.classList.add('is-copied');
return;
}
cascade.revealed = 0;
cascade.active = true;
cascade.morphing = false;
display.textContent = '';
}
function stepCascade(dtMs) {
if (cascade.active) {
cascade.acc += dtMs;
while (cascade.acc >= GENERATE_CASCADE_STEP_MS && cascade.revealed < cascade.target.length) {
cascade.acc -= GENERATE_CASCADE_STEP_MS;
cascade.revealed += 1;
display.textContent = cascade.target.slice(0, cascade.revealed);
}
if (cascade.revealed >= cascade.target.length) {
cascade.active = false;
cascade.morphing = true;
cascade.morphAcc = 0;
}
} else if (cascade.morphing) {
cascade.morphAcc += dtMs;
var p = Math.min(1, cascade.morphAcc / COPY_MORPH_MS);
root.style.setProperty('--copyMorph', String(p));
if (p >= 1) {
cascade.morphing = false;
root.classList.add('is-copied');
}
}
}
genBtn.addEventListener('click', generatePassword);
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) stepCascade(dt);
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
if (reduce) root.classList.remove('is-demo');
if (root.classList.contains('is-demo')) {
setTimeout(function () {
generatePassword();
setInterval(generatePassword, 2000);
}, 200);
}
})();import { useEffect, useRef } from "react";
import styles from "./PasswordRevealGenerateCopyCascade.module.scss";
export default function PasswordRevealGenerateCopyCascade() {
const rootRef = useRef<HTMLDivElement>(null);
const displayRef = useRef<HTMLDivElement>(null);
const btnRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const root = rootRef.current;
const display = displayRef.current;
const btn = btnRef.current;
if (!root || !display || !btn) return;
const GENERATE_CASCADE_STEP_MS = 60;
const COPY_MORPH_MS = 220;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const candRand = seedRand(426061);
const pool = "ABCDEFGHJKMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$";
const CANDIDATES: string[] = [];
for (let c = 0; c < 4; c += 1) {
const len = 8 + Math.floor(candRand() * 3);
let s = "";
for (let j = 0; j < len; j += 1) s += pool[Math.floor(candRand() * pool.length)];
CANDIDATES.push(s);
}
let candIndex = -1;
const cascade = { target: "", revealed: 0, acc: 0, active: false, morphing: false, morphAcc: 0 };
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function generatePassword() {
candIndex = (candIndex + 1) % CANDIDATES.length;
const target = CANDIDATES[candIndex];
cascade.target = target;
cascade.acc = 0;
cascade.morphAcc = 0;
root!.style.setProperty("--copyMorph", "0");
root!.classList.remove(styles.isCopied);
if (reduce) {
cascade.revealed = target.length;
cascade.active = false;
cascade.morphing = false;
display!.textContent = target;
root!.style.setProperty("--copyMorph", "1");
root!.classList.add(styles.isCopied);
return;
}
cascade.revealed = 0;
cascade.active = true;
cascade.morphing = false;
display!.textContent = "";
}
function stepCascade(dtMs: number) {
if (cascade.active) {
cascade.acc += dtMs;
while (cascade.acc >= GENERATE_CASCADE_STEP_MS && cascade.revealed < cascade.target.length) {
cascade.acc -= GENERATE_CASCADE_STEP_MS;
cascade.revealed += 1;
display!.textContent = cascade.target.slice(0, cascade.revealed);
}
if (cascade.revealed >= cascade.target.length) {
cascade.active = false;
cascade.morphing = true;
cascade.morphAcc = 0;
}
} else if (cascade.morphing) {
cascade.morphAcc += dtMs;
const p = Math.min(1, cascade.morphAcc / COPY_MORPH_MS);
root!.style.setProperty("--copyMorph", String(p));
if (p >= 1) {
cascade.morphing = false;
root!.classList.add(styles.isCopied);
}
}
}
function onClick() { generatePassword(); }
btn.addEventListener("click", onClick);
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) stepCascade(dt);
raf = requestAnimationFrame(tick);
}
raf = requestAnimationFrame(tick);
if (reduce) root.classList.remove("is-demo");
let loopTimer: ReturnType<typeof setTimeout> | null = null;
let loopInterval: ReturnType<typeof setInterval> | null = null;
if (root.classList.contains("is-demo")) {
loopTimer = setTimeout(() => {
generatePassword();
loopInterval = setInterval(generatePassword, 2000);
}, 200);
}
return () => {
btn.removeEventListener("click", onClick);
cancelAnimationFrame(raf);
if (loopTimer) clearTimeout(loopTimer);
if (loopInterval) clearInterval(loopInterval);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.gcc} is-demo`} ref={rootRef}>
<label className={styles.gcc__label} htmlFor="gcc-display-react">생성된 비밀번호</label>
<div className={styles.gcc__frame}>
<div id="gcc-display-react" className={styles.gcc__display} ref={displayRef} aria-live="polite" />
<span className={styles.gcc__status} aria-hidden="true">
<svg className={`${styles.gcc__icon} ${styles["gcc__icon--copy"]}`} viewBox="0 0 256 256" width="22" height="22">
<g fill="currentColor">
<path d="M216 40v128h-48V88H88V40Z" opacity=".2" />
<path d="M216 32H88a8 8 0 0 0-8 8v40H40a8 8 0 0 0-8 8v128a8 8 0 0 0 8 8h128a8 8 0 0 0 8-8v-40h40a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-56 176H48V96h112Zm48-48h-32V88a8 8 0 0 0-8-8H96V48h112Z" />
</g>
</svg>
<span className={styles.gcc__check}>
<span className={`${styles.gcc__checkBar} ${styles["gcc__checkBar--short"]}`} />
<span className={`${styles.gcc__checkBar} ${styles["gcc__checkBar--long"]}`} />
</span>
</span>
</div>
<button type="button" className={styles.gcc__gen} ref={btnRef}>생성</button>
</div>
</main>
);
}06 · 生成ボタンで一文字ずつ現れるカスケード — 計測 累積面積 14.485% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function stepCascade(dtMs) {
if (cascade.active) {
cascade.acc += dtMs;
while (cascade.acc >= GENERATE_CASCADE_STEP_MS && cascade.revealed < cascade.target.length) {
cascade.acc -= GENERATE_CASCADE_STEP_MS;
cascade.revealed += 1;
display.textContent = cascade.target.slice(0, cascade.revealed);
}
if (cascade.revealed >= cascade.target.length) {
cascade.active = false;
cascade.morphing = true;
cascade.morphAcc = 0;
}
} else if (cascade.morphing) {
cascade.morphAcc += dtMs;
var p = Math.min(1, cascade.morphAcc / COPY_MORPH_MS);
root.style.setProperty('--copyMorph', String(p));
if (p >= 1) {
cascade.morphing = false;
root.classList.add('is-copied');
}
}
}
genBtn.addEventListener('click', generatePassword);
06番は23フレーム中14フレームで動き、累積面積14.5%・平均強度126.1を記録しました。
07押している間だけ進行リングで開くホールド方式
startHold() は本物の pointerdown と Space キーの keydown の両方から呼ばれます。loop() はボタンを押し続けている時間を HOLD_DURATION_MS と比較してリングを満たし、その比率が1に達した瞬間だけ input.type を text に切り替えます。指を離す、ボタンの外へ指をずらす、キーを離す — どの終わり方でも同じ endHold() が呼ばれるので、マスクへ戻る経路は常に一つだけです。

<main class="stage">
<div class="phr is-demo" id="phrRoot">
<label class="phr__label" for="phrInput">パスワード</label>
<div class="phr__field">
<input class="phr__input" id="phrInput" type="password" aria-describedby="phrHint">
<button class="phr__btn" id="phrBtn" type="button" aria-pressed="false" aria-label="長押しでパスワードを表示">
<svg class="phr__ring" viewBox="0 0 44 44" width="40" height="40" aria-hidden="true">
<circle class="phr__ringTrack" cx="22" cy="22" r="18" fill="none" stroke-width="3"/>
<circle class="phr__ringFg" id="phrRingFg" cx="22" cy="22" r="18" fill="none" stroke-width="3" stroke-linecap="round"/>
</svg>
<svg class="phr__eye" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<path class="phr__lidTop" id="phrLidTop" d="M2,12 Q12,4 22,12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path class="phr__lidBottom" id="phrLidBottom" d="M2,12 Q12,20 22,12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<circle class="phr__pupil" id="phrPupil" cx="12" cy="12" r="3.2" fill="currentColor"/>
</svg>
</button>
</div>
<p class="phr__hint" id="phrHint">押している時間だけリングが満ち、満タンで文字が開き、離すと即座に再び閉じる。</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-orange);
.phr {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $sp-5;
padding: $sp-6;
box-sizing: border-box;
color: $subject-cream;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
text-align: center;
}
// 배경 톤: 링 안쪽에 갇혀 있던 hold-progress(0~1) 를 카드 톤으로도 흘려보낸다(계산 그대로 재사용).
.phr::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: rgba(91, 140, 248, calc(var(--phr-progress, 0) * .28));
}
.phr__label {
font-weight: 600;
font-size: 13px;
letter-spacing: .02em;
color: rgba(255, 247, 230, .72);
}
.phr__field {
display: flex;
align-items: center;
gap: $sp-3;
background: rgba(255, 247, 230, .08);
border: 1px solid rgba(255, 247, 230, .18);
border-radius: $r-control;
padding: $sp-2 $sp-3;
}
.phr__input {
background: transparent;
border: none;
outline: none;
color: $subject-cream;
font-size: 16px;
letter-spacing: .08em;
width: 9.5em;
font-family: inherit;
}
.phr__btn {
position: relative;
display: grid;
place-items: center;
width: 40px;
height: 40px;
padding: 0;
border: none;
border-radius: $r-pill;
background: transparent;
color: $subject-cream;
cursor: pointer;
touch-action: none;
}
.phr__btn:focus-visible {
outline: 2px solid $subject-cream;
outline-offset: 2px;
}
.phr__ring {
position: absolute;
inset: 0;
transform: rotate(-90deg);
}
.phr__ringTrack { stroke: rgba(255, 247, 230, .2); }
.phr__ringFg { stroke: $subject-cream; }
.phr__eye {
position: relative;
z-index: 1;
}
.phr__hint {
margin: 0;
font-size: 12px;
line-height: 1.4;
color: rgba(255, 247, 230, .62);
max-width: 22em;
}
@media (max-width: 340px) {
.phr { gap: $sp-3; padding: $sp-4; }
.phr__hint { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.phr__ringFg, .phr__lidTop, .phr__lidBottom, .phr__pupil { transition: none; }
}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);
}
.phr {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
padding: 24px;
box-sizing: border-box;
color: #fff7e6;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
text-align: center;
}
.phr::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: rgba(91, 140, 248, calc(var(--phr-progress, 0) * 0.28));
}
.phr__label {
font-weight: 600;
font-size: 13px;
letter-spacing: 0.02em;
color: rgba(255, 247, 230, 0.72);
}
.phr__field {
display: flex;
align-items: center;
gap: 12px;
background: rgba(255, 247, 230, 0.08);
border: 1px solid rgba(255, 247, 230, 0.18);
border-radius: 12px;
padding: 8px 12px;
}
.phr__input {
background: transparent;
border: none;
outline: none;
color: #fff7e6;
font-size: 16px;
letter-spacing: 0.08em;
width: 9.5em;
font-family: inherit;
}
.phr__btn {
position: relative;
display: grid;
place-items: center;
width: 40px;
height: 40px;
padding: 0;
border: none;
border-radius: 999px;
background: transparent;
color: #fff7e6;
cursor: pointer;
touch-action: none;
}
.phr__btn:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.phr__ring {
position: absolute;
inset: 0;
transform: rotate(-90deg);
}
.phr__ringTrack {
stroke: rgba(255, 247, 230, 0.2);
}
.phr__ringFg {
stroke: #fff7e6;
}
.phr__eye {
position: relative;
z-index: 1;
}
.phr__hint {
margin: 0;
font-size: 12px;
line-height: 1.4;
color: rgba(255, 247, 230, 0.62);
max-width: 22em;
}
@media (max-width: 340px) {
.phr {
gap: 12px;
padding: 16px;
}
.phr__hint {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.phr__ringFg, .phr__lidTop, .phr__lidBottom, .phr__pupil {
transition: none;
}
}(function () {
// 기법: pointerdown/keydown(Space) 로 홀드를 시작하면 rAF 루프가 경과시간을 HOLD_DURATION_MS
// 로 나눈 progress(0~1)를 계산해 SVG 링의 stroke-dashoffset 을 매 프레임 다시 쓴다. progress
// 가 1 에 닿는 순간에만 실제 input[type] 을 text 로 바꿔 노출하고, 손을 떼거나 취소되면
// progress·type·눈 모프를 전부 즉시 0(닫힘)으로 되돌린다 — 트윈이 아니라 "다 찼는가"라는
// 판정 하나로 열고 닫는다.
var root = document.getElementById('phrRoot');
var input = document.getElementById('phrInput');
var btn = document.getElementById('phrBtn');
var ringFg = document.getElementById('phrRingFg');
var lidTop = document.getElementById('phrLidTop');
var lidBottom = document.getElementById('phrLidBottom');
var pupil = document.getElementById('phrPupil');
var HOLD_DURATION_MS = 900;
var EYE_OPEN_MS = 220;
var RING_R = 18;
var CIRCUMFERENCE = 2 * Math.PI * RING_R;
ringFg.style.strokeDasharray = String(CIRCUMFERENCE);
ringFg.style.strokeDashoffset = String(CIRCUMFERENCE);
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var pwRand = seedRand(426071);
var PW_POOL = 'ABCDEFGHJKMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%';
function pickPassword(len) {
var out = '';
for (var i = 0; i < len; i += 1) out += PW_POOL[Math.floor(pwRand() * PW_POOL.length)];
return out;
}
input.value = pickPassword(10);
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
function setEye(progress) {
var eased = easeOutCubic(progress);
var lift = 8 * eased;
lidTop.setAttribute('d', 'M2,12 Q12,' + (12 - lift) + ' 22,12');
lidBottom.setAttribute('d', 'M2,12 Q12,' + (12 + lift) + ' 22,12');
pupil.style.opacity = String(eased);
}
setEye(0);
var holding = false;
var revealed = false;
var holdStart = 0;
var rafId = null;
function setRing(progress) {
ringFg.style.strokeDashoffset = String(CIRCUMFERENCE * (1 - progress));
root.style.setProperty('--phr-progress', String(progress));
}
function loop(ts) {
if (!holding) return;
var elapsed = ts - holdStart;
var progress = Math.max(0, Math.min(1, elapsed / HOLD_DURATION_MS));
setRing(progress);
if (progress >= 1 && !revealed) {
revealed = true;
input.type = 'text';
btn.setAttribute('aria-pressed', 'true');
}
var eyeProgress = revealed ? 1 : Math.max(0, Math.min(1, elapsed / EYE_OPEN_MS));
setEye(eyeProgress);
rafId = requestAnimationFrame(loop);
}
function startHold() {
if (holding) return;
holding = true;
revealed = false;
holdStart = performance.now();
rafId = requestAnimationFrame(loop);
}
function endHold() {
if (!holding) return;
holding = false;
revealed = false;
input.type = 'password';
btn.setAttribute('aria-pressed', 'false');
setRing(0);
setEye(0);
if (rafId) cancelAnimationFrame(rafId);
rafId = null;
}
btn.addEventListener('pointerdown', function (e) { e.preventDefault(); startHold(); });
btn.addEventListener('pointerup', endHold);
btn.addEventListener('pointercancel', endHold);
btn.addEventListener('pointerleave', endHold);
btn.addEventListener('keydown', function (e) {
if (e.code !== 'Space') return;
e.preventDefault();
if (e.repeat) return;
startHold();
});
btn.addEventListener('keyup', function (e) {
if (e.code !== 'Space') return;
e.preventDefault();
endHold();
});
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) root.classList.remove('is-demo');
function demoLoop() {
if (!root.classList.contains('is-demo')) return;
startHold();
setTimeout(function () {
endHold();
setTimeout(demoLoop, 1300);
}, HOLD_DURATION_MS + 400);
}
if (!reduce) setTimeout(demoLoop, 200);
})();import { useEffect, useRef } from "react";
import styles from "./PasswordRevealHoldToShowProgress.module.scss";
export default function PasswordRevealHoldToShowProgress() {
const rootRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const btnRef = useRef<HTMLButtonElement>(null);
const ringFgRef = useRef<SVGCircleElement>(null);
const lidTopRef = useRef<SVGPathElement>(null);
const lidBottomRef = useRef<SVGPathElement>(null);
const pupilRef = useRef<SVGCircleElement>(null);
useEffect(() => {
const root = rootRef.current;
const input = inputRef.current;
const btn = btnRef.current;
const ringFg = ringFgRef.current;
const lidTop = lidTopRef.current;
const lidBottom = lidBottomRef.current;
const pupil = pupilRef.current;
if (!root || !input || !btn || !ringFg || !lidTop || !lidBottom || !pupil) return;
const HOLD_DURATION_MS = 900;
const EYE_OPEN_MS = 220;
const RING_R = 18;
const CIRCUMFERENCE = 2 * Math.PI * RING_R;
ringFg.style.strokeDasharray = String(CIRCUMFERENCE);
ringFg.style.strokeDashoffset = String(CIRCUMFERENCE);
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const pwRand = seedRand(426072);
const PW_POOL = "ABCDEFGHJKMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%";
function pickPassword(len: number) {
let out = "";
for (let i = 0; i < len; i += 1) out += PW_POOL[Math.floor(pwRand() * PW_POOL.length)];
return out;
}
input.value = pickPassword(10);
function easeOutCubic(t: number) { return 1 - Math.pow(1 - t, 3); }
function setEye(progress: number) {
const eased = easeOutCubic(progress);
const lift = 8 * eased;
lidTop!.setAttribute("d", "M2,12 Q12," + (12 - lift) + " 22,12");
lidBottom!.setAttribute("d", "M2,12 Q12," + (12 + lift) + " 22,12");
pupil!.style.opacity = String(eased);
}
setEye(0);
let holding = false;
let revealed = false;
let holdStart = 0;
let rafId = 0;
function setRing(progress: number) {
ringFg!.style.strokeDashoffset = String(CIRCUMFERENCE * (1 - progress));
root!.style.setProperty('--phr-progress', String(progress));
}
function loop(ts: number) {
if (!holding) return;
const elapsed = ts - holdStart;
const progress = Math.max(0, Math.min(1, elapsed / HOLD_DURATION_MS));
setRing(progress);
if (progress >= 1 && !revealed) {
revealed = true;
input!.type = "text";
btn!.setAttribute("aria-pressed", "true");
}
const eyeProgress = revealed ? 1 : Math.max(0, Math.min(1, elapsed / EYE_OPEN_MS));
setEye(eyeProgress);
rafId = requestAnimationFrame(loop);
}
function startHold() {
if (holding) return;
holding = true;
revealed = false;
holdStart = performance.now();
rafId = requestAnimationFrame(loop);
}
function endHold() {
if (!holding) return;
holding = false;
revealed = false;
input!.type = "password";
btn!.setAttribute("aria-pressed", "false");
setRing(0);
setEye(0);
cancelAnimationFrame(rafId);
}
function onPointerDown(e: PointerEvent) { e.preventDefault(); startHold(); }
function onPointerEnd() { endHold(); }
function onKeyDown(e: KeyboardEvent) {
if (e.code !== "Space") return;
e.preventDefault();
if (e.repeat) return;
startHold();
}
function onKeyUp(e: KeyboardEvent) {
if (e.code !== "Space") return;
e.preventDefault();
endHold();
}
btn.addEventListener("pointerdown", onPointerDown);
btn.addEventListener("pointerup", onPointerEnd);
btn.addEventListener("pointercancel", onPointerEnd);
btn.addEventListener("pointerleave", onPointerEnd);
btn.addEventListener("keydown", onKeyDown);
btn.addEventListener("keyup", onKeyUp);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) root.classList.remove("is-demo");
let demoTimer = 0;
function demoLoop() {
if (!root!.classList.contains("is-demo")) return;
startHold();
demoTimer = window.setTimeout(function () {
endHold();
demoTimer = window.setTimeout(demoLoop, 1300);
}, HOLD_DURATION_MS + 400);
}
if (!reduce) demoTimer = window.setTimeout(demoLoop, 200);
return () => {
btn.removeEventListener("pointerdown", onPointerDown);
btn.removeEventListener("pointerup", onPointerEnd);
btn.removeEventListener("pointercancel", onPointerEnd);
btn.removeEventListener("pointerleave", onPointerEnd);
btn.removeEventListener("keydown", onKeyDown);
btn.removeEventListener("keyup", onKeyUp);
cancelAnimationFrame(rafId);
window.clearTimeout(demoTimer);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.phr} is-demo`} ref={rootRef}>
<label className={styles.phr__label} htmlFor="phrInputReact">비밀번호</label>
<div className={styles.phr__field}>
<input className={styles.phr__input} id="phrInputReact" type="password" aria-describedby="phrHintReact" ref={inputRef} readOnly />
<button className={styles.phr__btn} type="button" aria-pressed="false" aria-label="길게 눌러 비밀번호 보기" ref={btnRef}>
<svg className={styles.phr__ring} viewBox="0 0 44 44" width={40} height={40} aria-hidden="true">
<circle className={styles.phr__ringTrack} cx={22} cy={22} r={18} fill="none" strokeWidth={3} />
<circle className={styles.phr__ringFg} ref={ringFgRef} cx={22} cy={22} r={18} fill="none" strokeWidth={3} strokeLinecap="round" />
</svg>
<svg className={styles.phr__eye} viewBox="0 0 24 24" width={18} height={18} aria-hidden="true">
<path className={styles.phr__lidTop} ref={lidTopRef} d="M2,12 Q12,4 22,12" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" />
<path className={styles.phr__lidBottom} ref={lidBottomRef} d="M2,12 Q12,20 22,12" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" />
<circle className={styles.phr__pupil} ref={pupilRef} cx={12} cy={12} r={3.2} fill="currentColor" />
</svg>
</button>
</div>
<p className={styles.phr__hint} id="phrHintReact">누르고 있는 시간만큼 링이 채워지면 문자가 열리고, 손을 떼면 즉시 다시 닫힌다</p>
</div>
</main>
);
}07 · 押している間だけ進行リングで開くホールド方式 — 計測 累積面積 75.911% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function loop(ts) {
if (!holding) return;
var elapsed = ts - holdStart;
var progress = Math.max(0, Math.min(1, elapsed / HOLD_DURATION_MS));
setRing(progress);
if (progress >= 1 && !revealed) {
revealed = true;
input.type = 'text';
btn.setAttribute('aria-pressed', 'true');
}
var eyeProgress = revealed ? 1 : Math.max(0, Math.min(1, elapsed / EYE_OPEN_MS));
setEye(eyeProgress);
rafId = requestAnimationFrame(loop);
}
function startHold() {
if (holding) return;
holding = true;
revealed = false;
holdStart = performance.now();
rafId = requestAnimationFrame(loop);
}
function endHold() {
07番は23フレーム中10フレームで動き、累積面積75.9%・平均強度92.4を記録しました。
08ショートカットで切り替えるとフォーカスリングが明るくなるキーボード経路
input.type を切り替える関数は toggleReveal() ただ一つで、目のアイコンボタンのクリックも Ctrl+Shift+H の keydown も同じ関数を呼んだ後に flashFocusRing() を実行します。その中の step() は --ring-alpha というカスタムプロパティを FOCUS_RING_FLASH_MS かけて減衰させるので、ボタンだけでなくフィールド自体がポインター操作なしのショートカットに目に見える形で応えます。

<main class="stage">
<div class="pkf is-demo" id="pkfRoot">
<label class="pkf__label" for="pkfInput">パスワード</label>
<div class="pkf__field">
<input class="pkf__input" id="pkfInput" type="password" aria-describedby="pkfHint">
<button class="pkf__eyeBtn" id="pkfEyeBtn" type="button" aria-pressed="false" aria-label="パスワードを表示">
<svg class="pkf__eye" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<path class="pkf__eyeOpen" d="M2,12 Q12,4 22,12 Q12,20 2,12Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>
<circle class="pkf__eyeOpen" cx="12" cy="12" r="3.2" fill="currentColor"/>
<path class="pkf__eyeShut" d="M2,12 Q12,18 22,12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
</div>
<p class="pkf__hint" id="pkfHint">
<span class="pkf__hintText">ショートカットでも切り替えられる</span>
<span class="pkf__combo"><kbd class="pkf__kbd">Ctrl</kbd>+<kbd class="pkf__kbd">Shift</kbd>+<kbd class="pkf__kbd">H</kbd></span>
</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.pkf {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $stage-paper;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $sp-5;
padding: $sp-6;
box-sizing: border-box;
color: $subject-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
text-align: center;
}
.pkf__label {
font-weight: 600;
font-size: 13px;
letter-spacing: .02em;
color: rgba(23, 20, 26, .64);
}
.pkf__field {
display: flex;
align-items: center;
gap: $sp-3;
background: rgba(23, 20, 26, .04);
border: 1px solid rgba(23, 20, 26, .16);
border-radius: $r-control;
padding: $sp-2 $sp-3;
--ring-alpha: 0;
}
.pkf__input {
--ring-alpha: 0;
background: transparent;
border: none;
outline: none;
color: $subject-ink;
font-size: 16px;
letter-spacing: .08em;
width: 9.5em;
font-family: inherit;
box-shadow: 0 0 0 calc(2px + var(--ring-alpha) * 4px) rgba(47, 109, 246, calc(.22 + var(--ring-alpha) * .58));
border-radius: $r-sm;
}
.pkf__eyeBtn {
display: grid;
place-items: center;
width: 32px;
height: 32px;
padding: 0;
border: none;
border-radius: $r-pill;
background: transparent;
color: $subject-ink;
cursor: pointer;
}
.pkf__eyeBtn:focus-visible {
outline: 2px solid $subject-blue;
outline-offset: 2px;
}
.pkf__eyeShut { opacity: 0; }
.pkf__eyeOpen { opacity: 1; }
.pkf.is-open .pkf__eyeShut { opacity: 1; }
.pkf.is-open .pkf__eyeOpen { opacity: 0; }
.pkf__eyeOpen, .pkf__eyeShut { transition: opacity $dur-quick $ease-out; }
// 배경 톤: 포커스 링에 이미 쓰는 파란 톤을 is-open 카드 전체로도 넓힌다(새 색 도입 없음).
.pkf::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: rgba(47, 109, 246, 0);
transition: background-color .22s ease;
}
.pkf.is-open::before {
background: rgba(47, 109, 246, .12);
}
.pkf__hint {
margin: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-1;
font-size: 12px;
line-height: 1.4;
color: rgba(23, 20, 26, .58);
}
.pkf__combo {
display: inline-flex;
align-items: center;
gap: $sp-1;
}
.pkf__kbd {
font-family: inherit;
font-size: 11px;
font-weight: 600;
color: $subject-ink;
background: rgba(23, 20, 26, .08);
border: 1px solid rgba(23, 20, 26, .2);
border-radius: $r-xs;
padding: $sp-1 $sp-1;
line-height: 1.3;
}
@media (max-width: 340px) {
.pkf { gap: $sp-3; padding: $sp-4; }
.pkf__hint { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.pkf__input { transition: none; }
.pkf__eyeOpen, .pkf__eyeShut { transition: none; }
.pkf::before { transition: none; }
}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);
}
.pkf {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #ffffff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
padding: 24px;
box-sizing: border-box;
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
text-align: center;
}
.pkf__label {
font-weight: 600;
font-size: 13px;
letter-spacing: 0.02em;
color: rgba(23, 20, 26, 0.64);
}
.pkf__field {
display: flex;
align-items: center;
gap: 12px;
background: rgba(23, 20, 26, 0.04);
border: 1px solid rgba(23, 20, 26, 0.16);
border-radius: 12px;
padding: 8px 12px;
--ring-alpha: 0;
}
.pkf__input {
--ring-alpha: 0;
background: transparent;
border: none;
outline: none;
color: #17141a;
font-size: 16px;
letter-spacing: 0.08em;
width: 9.5em;
font-family: inherit;
box-shadow: 0 0 0 calc(2px + var(--ring-alpha) * 4px) rgba(47, 109, 246, calc(0.22 + var(--ring-alpha) * 0.58));
border-radius: 8px;
}
.pkf__eyeBtn {
display: grid;
place-items: center;
width: 32px;
height: 32px;
padding: 0;
border: none;
border-radius: 999px;
background: transparent;
color: #17141a;
cursor: pointer;
}
.pkf__eyeBtn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.pkf__eyeShut {
opacity: 0;
}
.pkf__eyeOpen {
opacity: 1;
}
.pkf.is-open .pkf__eyeShut {
opacity: 1;
}
.pkf.is-open .pkf__eyeOpen {
opacity: 0;
}
.pkf__eyeOpen, .pkf__eyeShut {
transition: opacity 220ms ease-out;
}
.pkf::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: rgba(47, 109, 246, 0);
transition: background-color 0.22s ease;
}
.pkf.is-open::before {
background: rgba(47, 109, 246, 0.12);
}
.pkf__hint {
margin: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
font-size: 12px;
line-height: 1.4;
color: rgba(23, 20, 26, 0.58);
}
.pkf__combo {
display: inline-flex;
align-items: center;
gap: 4px;
}
.pkf__kbd {
font-family: inherit;
font-size: 11px;
font-weight: 600;
color: #17141a;
background: rgba(23, 20, 26, 0.08);
border: 1px solid rgba(23, 20, 26, 0.2);
border-radius: 4px;
padding: 4px 4px;
line-height: 1.3;
}
@media (max-width: 340px) {
.pkf {
gap: 12px;
padding: 16px;
}
.pkf__hint {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.pkf__input {
transition: none;
}
.pkf__eyeOpen, .pkf__eyeShut {
transition: none;
}
.pkf::before {
transition: none;
}
}(function () {
// 기법: input 자체에 건 keydown 이 Ctrl+Shift+H 를 잡아 toggleReveal() 로 type 을 바꾸고
// flashFocusRing() 이 호출 시각부터 rAF 로 t 를 늘려 envelope = max(0, 1 - t/FOCUS_RING_FLASH_MS)
// (선형 감쇠 — 튕김 없는 단조 감쇠라 사인·지수보다 이 값에 맞다)를 계산해 CSS 변수
// --ring-alpha 에 매 프레임 써 넣는다. scss 는 box-shadow 확장을 그 변수로만 읽는다.
var root = document.getElementById('pkfRoot');
var input = document.getElementById('pkfInput');
var eyeBtn = document.getElementById('pkfEyeBtn');
var FOCUS_RING_FLASH_MS = 500;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var pwRand = seedRand(426081);
var PW_POOL = 'ABCDEFGHJKMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%';
function pickPassword(len) {
var out = '';
for (var i = 0; i < len; i += 1) out += PW_POOL[Math.floor(pwRand() * PW_POOL.length)];
return out;
}
input.value = pickPassword(11);
function toggleReveal() {
var willShow = input.type === 'password';
input.type = willShow ? 'text' : 'password';
eyeBtn.setAttribute('aria-pressed', String(willShow));
root.classList.toggle('is-open', willShow);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var flashStart = 0;
var flashRaf = null;
var reduceFlashTimer = null;
function step(ts) {
var t = ts - flashStart;
var envelope = Math.max(0, 1 - t / FOCUS_RING_FLASH_MS);
input.style.setProperty('--ring-alpha', String(envelope));
if (envelope > 0) {
flashRaf = requestAnimationFrame(step);
} else {
flashRaf = null;
}
}
function flashFocusRing() {
if (reduce) {
input.style.setProperty('--ring-alpha', '1');
window.clearTimeout(reduceFlashTimer);
reduceFlashTimer = window.setTimeout(function () {
input.style.setProperty('--ring-alpha', '0');
}, FOCUS_RING_FLASH_MS);
return;
}
flashStart = performance.now();
if (flashRaf !== null) return;
flashRaf = requestAnimationFrame(step);
}
input.addEventListener('keydown', function (e) {
if (e.ctrlKey && e.shiftKey && e.code === 'KeyH') {
e.preventDefault();
toggleReveal();
flashFocusRing();
}
});
eyeBtn.addEventListener('click', function () {
toggleReveal();
flashFocusRing();
input.focus({ preventScroll: true });
});
if (reduce) root.classList.remove('is-demo');
function demoLoop() {
if (!root.classList.contains('is-demo')) return;
toggleReveal();
flashFocusRing();
setTimeout(demoLoop, 2000);
}
if (!reduce) setTimeout(demoLoop, 200);
})();import { useEffect, useRef } from "react";
import styles from "./PasswordRevealKeyboardShortcutFocusRing.module.scss";
export default function PasswordRevealKeyboardShortcutFocusRing() {
const rootRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const eyeBtnRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const root = rootRef.current;
const input = inputRef.current;
const eyeBtn = eyeBtnRef.current;
if (!root || !input || !eyeBtn) return;
const FOCUS_RING_FLASH_MS = 500;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const pwRand = seedRand(426082);
const PW_POOL = "ABCDEFGHJKMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%";
function pickPassword(len: number) {
let out = "";
for (let i = 0; i < len; i += 1) out += PW_POOL[Math.floor(pwRand() * PW_POOL.length)];
return out;
}
input.value = pickPassword(11);
function toggleReveal() {
const willShow = input!.type === "password";
input!.type = willShow ? "text" : "password";
eyeBtn!.setAttribute("aria-pressed", String(willShow));
root!.classList.toggle("is-open", willShow);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let flashStart = 0;
let flashRaf = 0;
let flashing = false;
let reduceFlashTimer = 0;
function step(ts: number) {
const t = ts - flashStart;
const envelope = Math.max(0, 1 - t / FOCUS_RING_FLASH_MS);
input!.style.setProperty("--ring-alpha", String(envelope));
if (envelope > 0) {
flashRaf = requestAnimationFrame(step);
} else {
flashing = false;
}
}
function flashFocusRing() {
if (reduce) {
input!.style.setProperty("--ring-alpha", "1");
window.clearTimeout(reduceFlashTimer);
reduceFlashTimer = window.setTimeout(function () {
input!.style.setProperty("--ring-alpha", "0");
}, FOCUS_RING_FLASH_MS);
return;
}
flashStart = performance.now();
if (flashing) return;
flashing = true;
flashRaf = requestAnimationFrame(step);
}
function onKeyDown(e: KeyboardEvent) {
if (e.ctrlKey && e.shiftKey && e.code === "KeyH") {
e.preventDefault();
toggleReveal();
flashFocusRing();
}
}
function onEyeClick() {
toggleReveal();
flashFocusRing();
input!.focus({ preventScroll: true });
}
input.addEventListener("keydown", onKeyDown);
eyeBtn.addEventListener("click", onEyeClick);
if (reduce) root.classList.remove("is-demo");
let demoTimer = 0;
function demoLoop() {
if (!root!.classList.contains("is-demo")) return;
toggleReveal();
flashFocusRing();
demoTimer = window.setTimeout(demoLoop, 2000);
}
if (!reduce) demoTimer = window.setTimeout(demoLoop, 200);
return () => {
input.removeEventListener("keydown", onKeyDown);
eyeBtn.removeEventListener("click", onEyeClick);
cancelAnimationFrame(flashRaf);
window.clearTimeout(reduceFlashTimer);
window.clearTimeout(demoTimer);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.pkf} is-demo`} ref={rootRef}>
<label className={styles.pkf__label} htmlFor="pkfInputReact">비밀번호</label>
<div className={styles.pkf__field}>
<input className={styles.pkf__input} id="pkfInputReact" type="password" aria-describedby="pkfHintReact" ref={inputRef} readOnly />
<button className={styles.pkf__eyeBtn} type="button" aria-pressed="false" aria-label="비밀번호 보기" ref={eyeBtnRef}>
<svg className={styles.pkf__eye} viewBox="0 0 24 24" width={18} height={18} aria-hidden="true">
<path className={styles.pkf__eyeOpen} d="M2,12 Q12,4 22,12 Q12,20 2,12Z" fill="none" stroke="currentColor" strokeWidth={2} strokeLinejoin="round" />
<circle className={styles.pkf__eyeOpen} cx={12} cy={12} r={3.2} fill="currentColor" />
<path className={styles.pkf__eyeShut} d="M2,12 Q12,18 22,12" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" />
</svg>
</button>
</div>
<p className={styles.pkf__hint} id="pkfHintReact">
<span className={styles.pkf__hintText}>단축키로도 전환할 수 있다</span>
<span className={styles.pkf__combo}>
<kbd className={styles.pkf__kbd}>Ctrl</kbd>+<kbd className={styles.pkf__kbd}>Shift</kbd>+<kbd className={styles.pkf__kbd}>H</kbd>
</span>
</p>
</div>
</main>
);
}08 · ショートカットで切り替えるとフォーカスリングが明るくなるキーボード経路 — 計測 累積面積 75.756% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function toggleReveal() {
var willShow = input.type === 'password';
input.type = willShow ? 'text' : 'password';
eyeBtn.setAttribute('aria-pressed', String(willShow));
root.classList.toggle('is-open', willShow);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var flashStart = 0;
var flashRaf = null;
var reduceFlashTimer = null;
function step(ts) {
var t = ts - flashStart;
var envelope = Math.max(0, 1 - t / FOCUS_RING_FLASH_MS);
input.style.setProperty('--ring-alpha', String(envelope));
if (envelope > 0) {
flashRaf = requestAnimationFrame(step);
} else {
flashRaf = null;
}
}
function flashFocusRing() {
08番は23フレーム中10フレームで動き、累積面積75.8%・平均強度29.0を記録しました。
09複数の方式を一つのフォームにまとめた会員登録ヒーロー場面
heroSimulateAutofill() と heroRunCascade() は、01番・05番・06番の仕組みをそれぞれ改名して、別々のデモ欄ではなく同じフォームのパスワード欄一つに向けたものです。01番の toggleEye() によるモーフも同じ pwInput 上で並行して動きます。HERO_CASCADE_STEP_MS だけがこの場面で新しく足された定数で、それ以外はすべて既存の三つの仕組みを組み合わせただけで、作り直されたものではありません。

<main class="stage">
<div class="phs is-demo" id="phsRoot">
<form class="phs__card" id="phsForm">
<p class="phs__title">会員登録</p>
<div class="phs__row">
<label class="phs__label" for="phsEmail">メールアドレス</label>
<div class="phs__staticField" id="phsEmail">[email protected]</div>
</div>
<div class="phs__row">
<label class="phs__label" for="phsPw">パスワード</label>
<div class="phs__field" id="phsField">
<input class="phs__input" id="phsPw" type="password" aria-describedby="phsHint">
<button class="phs__eyeBtn" id="phsEyeBtn" type="button" aria-pressed="false" aria-label="パスワードを表示">
<svg class="phs__eye" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<path class="phs__lidTop" id="phsLidTop" d="M2,12 Q12,4 22,12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path class="phs__lidBottom" id="phsLidBottom" d="M2,12 Q12,20 22,12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<circle class="phs__pupil" id="phsPupil" cx="12" cy="12" r="3.2" fill="currentColor"/>
</svg>
</button>
</div>
</div>
<div class="phs__actions">
<button class="phs__autofillBtn" id="phsAutofillBtn" type="button">自動入力を再現</button>
<button class="phs__generateBtn" id="phsGenerateBtn" type="button">
<span>生成</span>
<span class="phs__icoWrap" aria-hidden="true">
<svg class="phs__copyIcon" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 40v128h-48V88H88V40Z" opacity=".2"/><path d="M216 32H88a8 8 0 0 0-8 8v40H40a8 8 0 0 0-8 8v128a8 8 0 0 0 8 8h128a8 8 0 0 0 8-8v-40h40a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-56 176H48V96h112Zm48-48h-32V88a8 8 0 0 0-8-8H96V48h112Z"/></g></svg>
<span class="phs__checkIcon">
<span class="phs__checkBar phs__checkBar--short"></span>
<span class="phs__checkBar phs__checkBar--long"></span>
</span>
</span>
</button>
</div>
<p class="phs__hint" id="phsHint">自動入力を模倣ボタンと生成ボタンは、実際のクリックが呼ぶのと同じ関数をそのまま呼び出す。</p>
</form>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.phs {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $stage-ink;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
padding: $sp-5;
}
.phs__card {
width: 100%;
max-width: 21em;
display: flex;
flex-direction: column;
gap: $sp-3;
margin: 0;
color: $subject-cream;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.phs__title {
margin: 0;
font-weight: 700;
font-size: 15px;
letter-spacing: .02em;
}
.phs__row {
display: flex;
flex-direction: column;
gap: $sp-1;
}
.phs__label {
font-weight: 600;
font-size: 11px;
color: rgba(255, 247, 230, .64);
}
.phs__staticField {
--glow: 0;
border-radius: $r-control;
padding: $sp-2 $sp-3;
background: rgba(255, 247, 230, .07);
border: 1px solid rgba(255, 247, 230, .16);
color: rgba(255, 247, 230, .8);
font-size: 13px;
box-shadow: 0 0 0 calc(1px + var(--glow) * 3px) rgba(255, 210, 63, calc(.2 + var(--glow) * .6));
}
.phs__field {
--glow: 0;
display: flex;
align-items: center;
gap: $sp-2;
border-radius: $r-control;
padding: $sp-2 $sp-3;
background: rgba(255, 247, 230, .07);
border: 1px solid rgba(255, 247, 230, .16);
box-shadow: 0 0 0 calc(1px + var(--glow) * 3px) rgba(255, 210, 63, calc(.2 + var(--glow) * .6));
}
.phs__input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: $subject-cream;
font-size: 13px;
letter-spacing: .06em;
font-family: inherit;
min-width: 0;
}
.phs__eyeBtn {
display: grid;
place-items: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
border-radius: $r-pill;
background: transparent;
color: $subject-cream;
cursor: pointer;
flex: none;
}
.phs__eyeBtn:focus-visible {
outline: 2px solid $subject-cream;
outline-offset: 2px;
}
.phs__actions {
display: flex;
gap: $sp-2;
margin-top: $sp-1;
}
.phs__autofillBtn, .phs__generateBtn {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: $sp-1;
border: none;
border-radius: $r-control;
padding: $sp-2 $sp-3;
font-family: inherit;
font-weight: 600;
font-size: 12px;
cursor: pointer;
}
.phs__autofillBtn {
background: rgba(255, 247, 230, .1);
color: $subject-cream;
}
.phs__generateBtn {
background: $subject-cream;
color: $subject-ink;
}
.phs__autofillBtn:focus-visible, .phs__generateBtn:focus-visible {
outline: 2px solid $subject-cream;
outline-offset: 2px;
}
.phs__icoWrap {
position: relative;
display: inline-grid;
place-items: center;
width: 14px;
height: 14px;
}
.phs__copyIcon {
width: 14px;
height: 14px;
opacity: 1;
transition: opacity $dur-quick $ease-out;
}
.phs__checkIcon {
position: absolute;
inset: 0;
opacity: 0;
transition: opacity $dur-quick $ease-out;
}
.phs__checkBar {
position: absolute;
left: 1px;
top: 7px;
height: 2px;
background: currentColor;
border-radius: $r-xs;
transform-origin: left center;
}
.phs__checkBar--short { width: 5px; transform: rotate(225deg); }
.phs__checkBar--long { width: 10px; transform: rotate(-48deg); }
.phs.is-generated .phs__copyIcon { opacity: 0; }
.phs.is-generated .phs__checkIcon { opacity: 1; }
// 배경 톤: 필드 안쪽 골드 glow 와 같은 톤을 is-generated 순간 카드 전체로 넓힌다(새 색 없음).
.phs::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: rgba(255, 210, 63, 0);
transition: background-color .3s ease;
}
.phs.is-generated::before {
background: rgba(255, 210, 63, .1);
}
.phs__hint {
margin: $sp-1 0 0;
font-size: 11px;
line-height: 1.4;
color: rgba(255, 247, 230, .56);
}
@media (max-width: 340px) {
.phs { padding: $sp-3; }
.phs__card { gap: $sp-2; }
.phs__hint { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.phs__copyIcon, .phs__checkIcon { transition: none; }
.phs::before { 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);
}
.phs {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
padding: 20px;
}
.phs__card {
width: 100%;
max-width: 21em;
display: flex;
flex-direction: column;
gap: 12px;
margin: 0;
color: #fff7e6;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.phs__title {
margin: 0;
font-weight: 700;
font-size: 15px;
letter-spacing: 0.02em;
}
.phs__row {
display: flex;
flex-direction: column;
gap: 4px;
}
.phs__label {
font-weight: 600;
font-size: 11px;
color: rgba(255, 247, 230, 0.64);
}
.phs__staticField {
--glow: 0;
border-radius: 12px;
padding: 8px 12px;
background: rgba(255, 247, 230, 0.07);
border: 1px solid rgba(255, 247, 230, 0.16);
color: rgba(255, 247, 230, 0.8);
font-size: 13px;
box-shadow: 0 0 0 calc(1px + var(--glow) * 3px) rgba(255, 210, 63, calc(0.2 + var(--glow) * 0.6));
}
.phs__field {
--glow: 0;
display: flex;
align-items: center;
gap: 8px;
border-radius: 12px;
padding: 8px 12px;
background: rgba(255, 247, 230, 0.07);
border: 1px solid rgba(255, 247, 230, 0.16);
box-shadow: 0 0 0 calc(1px + var(--glow) * 3px) rgba(255, 210, 63, calc(0.2 + var(--glow) * 0.6));
}
.phs__input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: #fff7e6;
font-size: 13px;
letter-spacing: 0.06em;
font-family: inherit;
min-width: 0;
}
.phs__eyeBtn {
display: grid;
place-items: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
border-radius: 999px;
background: transparent;
color: #fff7e6;
cursor: pointer;
flex: none;
}
.phs__eyeBtn:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.phs__actions {
display: flex;
gap: 8px;
margin-top: 4px;
}
.phs__autofillBtn, .phs__generateBtn {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
border: none;
border-radius: 12px;
padding: 8px 12px;
font-family: inherit;
font-weight: 600;
font-size: 12px;
cursor: pointer;
}
.phs__autofillBtn {
background: rgba(255, 247, 230, 0.1);
color: #fff7e6;
}
.phs__generateBtn {
background: #fff7e6;
color: #17141a;
}
.phs__autofillBtn:focus-visible, .phs__generateBtn:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.phs__icoWrap {
position: relative;
display: inline-grid;
place-items: center;
width: 14px;
height: 14px;
}
.phs__copyIcon {
width: 14px;
height: 14px;
opacity: 1;
transition: opacity 220ms ease-out;
}
.phs__checkIcon {
position: absolute;
inset: 0;
opacity: 0;
transition: opacity 220ms ease-out;
}
.phs__checkBar {
position: absolute;
left: 1px;
top: 7px;
height: 2px;
background: currentColor;
border-radius: 4px;
transform-origin: left center;
}
.phs__checkBar--short {
width: 5px;
transform: rotate(225deg);
}
.phs__checkBar--long {
width: 10px;
transform: rotate(-48deg);
}
.phs.is-generated .phs__copyIcon {
opacity: 0;
}
.phs.is-generated .phs__checkIcon {
opacity: 1;
}
.phs::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: rgba(255, 210, 63, 0);
transition: background-color 0.3s ease;
}
.phs.is-generated::before {
background: rgba(255, 210, 63, 0.1);
}
.phs__hint {
margin: 4px 0 0;
font-size: 11px;
line-height: 1.4;
color: rgba(255, 247, 230, 0.56);
}
@media (max-width: 340px) {
.phs {
padding: 12px;
}
.phs__card {
gap: 8px;
}
.phs__hint {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.phs__copyIcon, .phs__checkIcon {
transition: none;
}
.phs::before {
transition: none;
}
}(function () {
// 기법: 눈 모프·글로우 펄스·生成 캐스케이드 세 계산을 이 파일 안에서 독립적으로 다시 구현한다
// (01/05/06 과 이름이 겹치지 않도록 hero* 접두 함수·HERO_* 상수로 새로 짓는다). 눈은 progress
// 를 목표까지 rAF 로 보간해 베지어 ctrlY 를 다시 계산하고, 글로우는 sin(경과비*PI)*exp(-경과비*k)
// 엔벌로프로 자동입력 하이라이트를 표현하며, 생성은 누적 타이머로 글자를 한 자씩 채운 뒤
// 복사 아이콘을 체크로 크로스페이드한다.
var root = document.getElementById('phsRoot');
var form = document.getElementById('phsForm');
var emailField = document.getElementById('phsEmail');
var pwField = document.getElementById('phsField');
var pwInput = document.getElementById('phsPw');
var eyeBtn = document.getElementById('phsEyeBtn');
var lidTop = document.getElementById('phsLidTop');
var lidBottom = document.getElementById('phsLidBottom');
var pupil = document.getElementById('phsPupil');
var autofillBtn = document.getElementById('phsAutofillBtn');
var generateBtn = document.getElementById('phsGenerateBtn');
var HERO_EYE_MORPH_MS = 260;
var HERO_GLOW_PULSE_MS = 900;
var HERO_CASCADE_STEP_MS = 70;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var pwRand = seedRand(426091);
var cascadeRand = seedRand(426092);
var PW_POOL = 'ABCDEFGHJKMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%';
function pickPassword(len, rand) {
var out = '';
for (var i = 0; i < len; i += 1) out += PW_POOL[Math.floor(rand() * PW_POOL.length)];
return out;
}
pwInput.value = pickPassword(9, pwRand);
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
function heroMorphEye(progress) {
var eased = easeOutCubic(progress);
var lift = 8 * eased;
lidTop.setAttribute('d', 'M2,12 Q12,' + (12 - lift) + ' 22,12');
lidBottom.setAttribute('d', 'M2,12 Q12,' + (12 + lift) + ' 22,12');
pupil.style.opacity = String(eased);
}
heroMorphEye(0);
var eyeProgress = 0, eyeFrom = 0, eyeTarget = 0, eyeStart = 0, eyeRafId = null;
function eyeStep(ts) {
var t = Math.min(1, (ts - eyeStart) / HERO_EYE_MORPH_MS);
eyeProgress = eyeFrom + (eyeTarget - eyeFrom) * easeOutCubic(t);
heroMorphEye(eyeProgress);
if (t < 1) { eyeRafId = requestAnimationFrame(eyeStep); } else { eyeRafId = null; }
}
function animateEyeTo(target) {
eyeFrom = eyeProgress;
eyeTarget = target;
eyeStart = performance.now();
if (eyeRafId) cancelAnimationFrame(eyeRafId);
eyeRafId = requestAnimationFrame(eyeStep);
}
function toggleEye() {
var willShow = pwInput.type === 'password';
pwInput.type = willShow ? 'text' : 'password';
eyeBtn.setAttribute('aria-pressed', String(willShow));
animateEyeTo(willShow ? 1 : 0);
}
var glowStart = 0, glowRafId = null;
function setGlow(v) {
emailField.style.setProperty('--glow', String(v));
pwField.style.setProperty('--glow', String(v));
}
function glowStep(ts) {
var frac = (ts - glowStart) / HERO_GLOW_PULSE_MS;
if (frac >= 1) { setGlow(0); glowRafId = null; return; }
var envelope = Math.max(0, Math.sin(frac * Math.PI) * Math.exp(-frac * 2.2));
setGlow(envelope);
glowRafId = requestAnimationFrame(glowStep);
}
function heroSimulateAutofill() {
glowStart = performance.now();
if (glowRafId) return;
glowRafId = requestAnimationFrame(glowStep);
}
var cascadeTimer = null;
function heroRunCascade() {
var target = pickPassword(9, cascadeRand);
var i = 0;
pwInput.type = 'text';
eyeBtn.setAttribute('aria-pressed', 'true');
animateEyeTo(1);
pwInput.value = '';
root.classList.remove('is-generated');
window.clearTimeout(cascadeTimer);
function step() {
i += 1;
pwInput.value = target.slice(0, i);
if (i < target.length) {
cascadeTimer = window.setTimeout(step, HERO_CASCADE_STEP_MS);
} else {
root.classList.add('is-generated');
cascadeTimer = window.setTimeout(function () {
root.classList.remove('is-generated');
}, 500);
}
}
step();
}
eyeBtn.addEventListener('click', function () { toggleEye(); });
autofillBtn.addEventListener('click', function () { heroSimulateAutofill(); });
generateBtn.addEventListener('click', function () { heroRunCascade(); });
form.addEventListener('submit', function (e) { e.preventDefault(); });
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) root.classList.remove('is-demo');
var demoTimers = [];
function clearDemoTimers() {
for (var i = 0; i < demoTimers.length; i += 1) window.clearTimeout(demoTimers[i]);
demoTimers = [];
}
function demoLoop() {
if (!root.classList.contains('is-demo')) return;
clearDemoTimers();
demoTimers.push(window.setTimeout(function () { toggleEye(); }, 150));
demoTimers.push(window.setTimeout(function () { toggleEye(); }, 650));
demoTimers.push(window.setTimeout(function () { heroSimulateAutofill(); }, 900));
demoTimers.push(window.setTimeout(function () { heroRunCascade(); }, 1500));
demoTimers.push(window.setTimeout(function () {
pwInput.type = 'password';
eyeBtn.setAttribute('aria-pressed', 'false');
animateEyeTo(0);
}, 2600));
demoTimers.push(window.setTimeout(demoLoop, 2800));
}
if (!reduce) demoTimers.push(window.setTimeout(demoLoop, 200));
})();import { useEffect, useRef } from "react";
import styles from "./PasswordRevealHeroSignupFormScene.module.scss";
export default function PasswordRevealHeroSignupFormScene() {
const rootRef = useRef<HTMLDivElement>(null);
const formRef = useRef<HTMLFormElement>(null);
const emailFieldRef = useRef<HTMLDivElement>(null);
const pwFieldRef = useRef<HTMLDivElement>(null);
const pwInputRef = useRef<HTMLInputElement>(null);
const eyeBtnRef = useRef<HTMLButtonElement>(null);
const lidTopRef = useRef<SVGPathElement>(null);
const lidBottomRef = useRef<SVGPathElement>(null);
const pupilRef = useRef<SVGCircleElement>(null);
const autofillBtnRef = useRef<HTMLButtonElement>(null);
const generateBtnRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const root = rootRef.current;
const form = formRef.current;
const emailField = emailFieldRef.current;
const pwField = pwFieldRef.current;
const pwInput = pwInputRef.current;
const eyeBtn = eyeBtnRef.current;
const lidTop = lidTopRef.current;
const lidBottom = lidBottomRef.current;
const pupil = pupilRef.current;
const autofillBtn = autofillBtnRef.current;
const generateBtn = generateBtnRef.current;
if (!root || !form || !emailField || !pwField || !pwInput || !eyeBtn || !lidTop || !lidBottom || !pupil || !autofillBtn || !generateBtn) return;
const HERO_EYE_MORPH_MS = 260;
const HERO_GLOW_PULSE_MS = 900;
const HERO_CASCADE_STEP_MS = 70;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const pwRand = seedRand(426093);
const cascadeRand = seedRand(426094);
const PW_POOL = "ABCDEFGHJKMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%";
function pickPassword(len: number, rand: () => number) {
let out = "";
for (let i = 0; i < len; i += 1) out += PW_POOL[Math.floor(rand() * PW_POOL.length)];
return out;
}
pwInput.value = pickPassword(9, pwRand);
function easeOutCubic(t: number) { return 1 - Math.pow(1 - t, 3); }
function heroMorphEye(progress: number) {
const eased = easeOutCubic(progress);
const lift = 8 * eased;
lidTop!.setAttribute("d", "M2,12 Q12," + (12 - lift) + " 22,12");
lidBottom!.setAttribute("d", "M2,12 Q12," + (12 + lift) + " 22,12");
pupil!.style.opacity = String(eased);
}
heroMorphEye(0);
let eyeProgress = 0, eyeFrom = 0, eyeTarget = 0, eyeStart = 0, eyeRafId = 0;
function eyeStep(ts: number) {
const t = Math.min(1, (ts - eyeStart) / HERO_EYE_MORPH_MS);
eyeProgress = eyeFrom + (eyeTarget - eyeFrom) * easeOutCubic(t);
heroMorphEye(eyeProgress);
if (t < 1) { eyeRafId = requestAnimationFrame(eyeStep); }
}
function animateEyeTo(target: number) {
eyeFrom = eyeProgress;
eyeTarget = target;
eyeStart = performance.now();
cancelAnimationFrame(eyeRafId);
eyeRafId = requestAnimationFrame(eyeStep);
}
function toggleEye() {
const willShow = pwInput!.type === "password";
pwInput!.type = willShow ? "text" : "password";
eyeBtn!.setAttribute("aria-pressed", String(willShow));
animateEyeTo(willShow ? 1 : 0);
}
let glowStart = 0, glowRafId = 0, glowing = false;
function setGlow(v: number) {
emailField!.style.setProperty("--glow", String(v));
pwField!.style.setProperty("--glow", String(v));
}
function glowStep(ts: number) {
const frac = (ts - glowStart) / HERO_GLOW_PULSE_MS;
if (frac >= 1) { setGlow(0); glowing = false; return; }
const envelope = Math.max(0, Math.sin(frac * Math.PI) * Math.exp(-frac * 2.2));
setGlow(envelope);
glowRafId = requestAnimationFrame(glowStep);
}
function heroSimulateAutofill() {
glowStart = performance.now();
if (glowing) return;
glowing = true;
glowRafId = requestAnimationFrame(glowStep);
}
let cascadeTimer = 0;
function heroRunCascade() {
const target = pickPassword(9, cascadeRand);
let i = 0;
pwInput!.type = "text";
eyeBtn!.setAttribute("aria-pressed", "true");
animateEyeTo(1);
pwInput!.value = "";
root!.classList.remove("is-generated");
window.clearTimeout(cascadeTimer);
function step() {
i += 1;
pwInput!.value = target.slice(0, i);
if (i < target.length) {
cascadeTimer = window.setTimeout(step, HERO_CASCADE_STEP_MS);
} else {
root!.classList.add("is-generated");
cascadeTimer = window.setTimeout(function () {
root!.classList.remove("is-generated");
}, 500);
}
}
step();
}
function onEyeClick() { toggleEye(); }
function onAutofillClick() { heroSimulateAutofill(); }
function onGenerateClick() { heroRunCascade(); }
function onSubmit(e: Event) { e.preventDefault(); }
eyeBtn.addEventListener("click", onEyeClick);
autofillBtn.addEventListener("click", onAutofillClick);
generateBtn.addEventListener("click", onGenerateClick);
form.addEventListener("submit", onSubmit);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) root.classList.remove("is-demo");
let demoTimers: number[] = [];
function clearDemoTimers() {
for (let i = 0; i < demoTimers.length; i += 1) window.clearTimeout(demoTimers[i]);
demoTimers = [];
}
function demoLoop() {
if (!root!.classList.contains("is-demo")) return;
clearDemoTimers();
demoTimers.push(window.setTimeout(function () { toggleEye(); }, 150));
demoTimers.push(window.setTimeout(function () { toggleEye(); }, 650));
demoTimers.push(window.setTimeout(function () { heroSimulateAutofill(); }, 900));
demoTimers.push(window.setTimeout(function () { heroRunCascade(); }, 1500));
demoTimers.push(window.setTimeout(function () {
pwInput!.type = "password";
eyeBtn!.setAttribute("aria-pressed", "false");
animateEyeTo(0);
}, 2600));
demoTimers.push(window.setTimeout(demoLoop, 2800));
}
if (!reduce) demoTimers.push(window.setTimeout(demoLoop, 200));
return () => {
eyeBtn.removeEventListener("click", onEyeClick);
autofillBtn.removeEventListener("click", onAutofillClick);
generateBtn.removeEventListener("click", onGenerateClick);
form.removeEventListener("submit", onSubmit);
cancelAnimationFrame(eyeRafId);
cancelAnimationFrame(glowRafId);
window.clearTimeout(cascadeTimer);
clearDemoTimers();
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.phs} is-demo`} ref={rootRef}>
<form className={styles.phs__card} ref={formRef}>
<p className={styles.phs__title}>회원가입</p>
<div className={styles.phs__row}>
<label className={styles.phs__label} htmlFor="phsEmailReact">이메일</label>
<div className={styles.phs__staticField} id="phsEmailReact" ref={emailFieldRef}>[email protected]</div>
</div>
<div className={styles.phs__row}>
<label className={styles.phs__label} htmlFor="phsPwReact">비밀번호</label>
<div className={styles.phs__field} ref={pwFieldRef}>
<input className={styles.phs__input} id="phsPwReact" type="password" aria-describedby="phsHintReact" ref={pwInputRef} readOnly />
<button className={styles.phs__eyeBtn} type="button" aria-pressed="false" aria-label="비밀번호 보기" ref={eyeBtnRef}>
<svg className={styles.phs__eye} viewBox="0 0 24 24" width={18} height={18} aria-hidden="true">
<path className={styles.phs__lidTop} ref={lidTopRef} d="M2,12 Q12,4 22,12" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" />
<path className={styles.phs__lidBottom} ref={lidBottomRef} d="M2,12 Q12,20 22,12" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" />
<circle className={styles.phs__pupil} ref={pupilRef} cx={12} cy={12} r={3.2} fill="currentColor" />
</svg>
</button>
</div>
</div>
<div className={styles.phs__actions}>
<button className={styles.phs__autofillBtn} type="button" ref={autofillBtnRef}>자동입력 흉내</button>
<button className={styles.phs__generateBtn} type="button" ref={generateBtnRef}>
<span>생성</span>
<span className={styles.phs__icoWrap} aria-hidden="true">
<svg className={styles.phs__copyIcon} width="1em" height="1em" viewBox="0 0 256 256">
<g fill="currentColor">
<path d="M216 40v128h-48V88H88V40Z" opacity=".2" />
<path d="M216 32H88a8 8 0 0 0-8 8v40H40a8 8 0 0 0-8 8v128a8 8 0 0 0 8 8h128a8 8 0 0 0 8-8v-40h40a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-56 176H48V96h112Zm48-48h-32V88a8 8 0 0 0-8-8H96V48h112Z" />
</g>
</svg>
<span className={styles.phs__checkIcon}>
<span className={`${styles.phs__checkBar} ${styles["phs__checkBar--short"]}`} />
<span className={`${styles.phs__checkBar} ${styles["phs__checkBar--long"]}`} />
</span>
</span>
</button>
</div>
<p className={styles.phs__hint} id="phsHintReact">자동입력 흉내와 생성 버튼은 실제 클릭이 부르는 함수를 그대로 다시 부른다</p>
</form>
</div>
</main>
);
}09 · 複数の方式を一つのフォームにまとめた会員登録ヒーロー場面 — 計測 累積面積 75.046% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function heroSimulateAutofill() {
glowStart = performance.now();
if (glowRafId) return;
glowRafId = requestAnimationFrame(glowStep);
}
var cascadeTimer = null;
function heroRunCascade() {
var target = pickPassword(9, cascadeRand);
var i = 0;
pwInput.type = 'text';
eyeBtn.setAttribute('aria-pressed', 'true');
animateEyeTo(1);
pwInput.value = '';
root.classList.remove('is-generated');
window.clearTimeout(cascadeTimer);
function step() {
i += 1;
pwInput.value = target.slice(0, i);
if (i < target.length) {
cascadeTimer = window.setTimeout(step, HERO_CASCADE_STEP_MS);
} else {
root.classList.add('is-generated');
cascadeTimer = window.setTimeout(function () {
09番は23フレーム中16フレームで動き、累積面積75.0%・平均強度75.9を記録しました。
どのパスワード欄にどの9パターンを使うべきか
どんなログインフォームにもパスワード表示/非表示 アイコンは必要ですが、この9つのうちどれが合うかは実際に何を守りたいかで変わります。01番のクロスフェードする目は普通のログイン欄に向いていて、02番のスプリングバッジは会員登録フォームのパスワードステップでCaps Lockのつけっぱなしを送信前に警告したい場面で力を発揮します。03番の共有トグルはパスワード欄と確認用パスワード欄が並んでいる場所にふさわしく、04番の貼り付けバナーはパスワードマネージャーが本物のキー入力ではなく貼り付けで自動入力する欄のために作られています。05番は保存済みの認証情報が正しい欄に入ったことを確認し、06番は会員登録フローに生成とコピーのステップを与え、07番のホールド方式は共有画面やプレゼン画面で一瞬だけパスワードを見せたい場面に合います。08番はマウスに触れずにパスワード 目のアイコン どっちを押すべきかという疑問にキーボードだけで答え、09番はこれらのいくつかを一つのランディングページの会員登録ヒーローにまとめています。このような欄の下によく置かれる強度フィードバックについては、パスワード強度チェック UI 9選 — 強度の値が計算される仕組み が姉妹編にあたり、フォーム バリデーション UI 9選 — 打ち込んだ瞬間に判定するエラー表示 は同じ入力欄まわりのエラー表示のより近い親戚です。
9つのパスワード表示パターンを面積と強度で測る
これらの9つのうち多くは状態が変わるたびにカード状の背景ごと色を変えて塗り直すため、以下の表では9つ中6つが累積面積75%を超えています — 累積面積は固定カメラの各フレームのうち実際に塗り直された割合の平均で、強度はその塗り直しが起きた場所の平均ピクセル差です。
| パターン | 累積面積 | 平均強度 | 動いたフレーム |
|---|---|---|---|
| 01 目のアイコンモーフ | 76.0% | 44.6 | 14/23 |
| 02 Caps Lock警告スプリング | 75.9% | 49.3 | 15/23 |
| 03 複数欄同期トグル | 76.0% | 65.2 | 10/23 |
| 04 貼り付け検知バナー | 10.5% | 57.4 | 12/23 |
| 05 自動入力ハイライトグロー | 23.9% | 34.0 | 11/23 |
| 06 生成コピーカスケード | 14.5% | 126.1 | 14/23 |
| 07 ホールド進行リング | 75.9% | 92.4 | 10/23 |
| 08 キーボードショートカットフォーカスリング | 75.8% | 29.0 | 10/23 |
| 09 会員登録ヒーロー場面 | 75.0% | 75.9 | 16/23 |
値が反応を変える場所 — Caps Lockバッジのオーバーシュート・自動入力グローのパルス・生成カスケードの速度
決め手になる数値は主に三つです。CAPSLOCK_BADGE_OVERSHOOT_PX は02番のバッジが静止位置をどれだけ超えて跳ねてからスプリングで戻るかを決め、AUTOFILL_GLOW_PULSE_MS は05番の欄まわりに広がって消えるサインカーブ×減衰のパルス一回分の長さを決め、GENERATE_CASCADE_STEP_MS は06番が一文字ごとに空ける間隔をミリ秒単位で決めています。三つとも形そのものは変えず、目がその変化を見つめる時間の長さだけを変えます — それこそが、考え抜かれたパスワード表示 アイコンと、ただクラスを切り替えるだけのアイコンを分ける最大の違いです。
どこで壊れるか — ブラウザの強制マスキング・スクリーンリーダー通知の欠落・ホールド操作の衝突
パスワード欄のマスクは type 属性に完全に紐づいており、type が password のままではどんなCSSも点を文字に見せることはできません。だからこそ、この9つのうち01・02・03・07・08・09の六つは実際に input.type を password と text の間で切り替えていますが、04・05・06の三つは、そもそもマスクされていない欄の上で自分の技法を見せています。見せたいのは「表示」そのものではないからです。9つのうち aria-live 領域で状態変化を読み上げているのは02・04・06の三つだけなので、それ以外の六つをトグルする際、スクリーンリーダーのユーザーはボタン自身の aria-pressed の値だけを頼りにすることになり、別途の読み上げ確認はありません。07番のホールド操作は keydown に明示的な e.repeat チェックを入れて自分自身の繰り返しイベントを防いでいます — このガードがなければOS自体のキーリピートが繰り返しイベントのたびに startHold() を再起動してしまい、一回のキー保持でリングを一度だけ満たすことができなくなります。ボタンの外へポインターがずれた場合も同じ理由で pointerleave が拾います。これらの9パターンを支える index.html・style.scss の組は、xnzbmd2q、どの番号が本物の type 属性を切り替え、どれが見た目だけそう見せているのかを確かめる一番早い方法です。
変形3つとアクセシビリティ
| 変形 | 値の変化 | 感触 |
|---|---|---|
| Caps Lockバッジのオーバーシュートを調整する | 02 CAPSLOCK_BADGE_OVERSHOOT_PX 6 → 14 | 値を下げるとバッジはほとんど跳ねずに収まり、値を上げるとバッジはより大きく跳ね上がってから元の位置に戻る。 |
| 自動入力グローのパルス周期を調整する | 05 AUTOFILL_GLOW_PULSE_MS 900 → 400 | 値を上げるとグローはゆっくり柔らかく広がって消え、値を下げるとグローは点滅のように素早く光る。 |
| 生成カスケードの速度を調整する | 06 GENERATE_CASCADE_STEP_MS 60 → 20 | 値を上げると文字は一つずつゆっくりはっきり現れ、値を下げると文字列全体がほぼ一度に流れ出るように現れる。 |
prefers-reduced-motion は9本すべてのスクリプトの冒頭でチェックされていて、個別のプロパティをただ止めるのではなく、それぞれが最終状態へ一気に飛びます — 02番のバッジはスプリングの通過なしに最終位置へ現れ、06番のカスケードは GENERATE_CASCADE_STEP_MS ごとの一文字ずつではなく文字列全体を一段階で表示します。ここでのトグルはすべて本物の <button type="button"> なので、EnterとSpaceはブラウザ自身のボタンの仕様を通じて動作し、独自実装のkeydownハンドラには頼っていません。aria-pressed はそのすべてで欄が現在マスク解除されているかどうかを映しています。08番はさらにもう一つの経路を足していて、フィールド上の Ctrl+Shift+H ショートカットがクリックと同じ toggleReveal() を呼んだ後 --ring-alpha カスタムプロパティを明るくするので、キーボードだけの訪問者もマウスクリックと同じ反応をフィールド自身から見ることができます。<input type="password">・:autofill・prefers-reduced-motion・requestAnimationFrame() はいずれもMDNに文書化されています。
パスワード表示/非表示 アイコンはボタンと何が違うのか?
パスワード 目のアイコン 名前を調べてボタンとの違いにたどり着いた方への答えは、この9つのすべてに共通しています。実物の入力要素の type を password と text の間で切り替え、隠しフィールドやCSSの小細工ではなく単一の進行値だけがアイコンとフィールド双方の動きを同時に駆動する、という一点です。アイコンの見た目だけが変わって欄自体はマスクされたまま、あるいはアイコンが気づかないまま欄だけがマスク解除される — それが二つがずれてしまう原因で、上の9パターンはどれもアイコンの状態と欄の type を一つの共有変数に結びつけているのでずれようがありません。同じ「値ひとつを共有する」考え方は隣接する認証UIにも通じていて、認証コード 入力画面が直面する9つの状況 バリデーションまで対応 では似た単一の状態値がマス目ごとのマスクとバリデーションの両方を結んでいます。
FAQ — 作る前によくある質問
type を password から text へ切り替えること自体がアクセシビリティ上の問題になりますか?
いいえ — その切り替え自体は標準的でアクセシブルな表示方法で、支援技術にもマスク済みの欄とは異なるものとしてすでに認識されます。この9つが標準の上に加えているのはトグル操作そのものを伝えるための合図で、aria-live で読み上げているのは02・04・06の三つだけです。アクセシビリティの穴は type の切り替えそのものではなく、残り六つに読み上げ通知がないことにあります。
9つのうちどれが最もフレームを塗り直していますか、またその理由は?
01番と03番が累積面積76.0%で並んでいます。どちらもキー入力のたびにマスクされた点と実際の文字からなるオーバーレイ全体を欄の横幅いっぱいでクロスフェードさせていて、小さな一部分だけをアニメーションさせているわけではないため、buildOverlay() や paintEyes() が走るたびに欄のほぼ全体が塗り直されます。
貼り付け検知バナーはパスワード欄に貼り付けられた中身を読み取っていますか?
いいえ — handlePaste() は入力欄の本物の paste イベントで発火しますが、event.clipboardData も欄自体の値も一切読み取りません。バナーが伝えるのは貼り付けが発生した事実だけで、パスワードマネージャーの貼り付けによる自動入力の挙動を説明するにはそれで十分であり、中身を露出することはありません。








