03複数の入力欄が一斉に開く同期トグル
パスワードと確認用パスワードの欄が一つのトグル値を共有し、両方が同時に同じ状態へ切り替わる。
使いどころパスワードと確認欄がある会員登録フォーム
SYNC_EYE_MORPH_MSpaintEyespfs__hint

<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 に同梱 · パスワード 目のアイコン 9パターン — 文字とドットの入れ替え計算
このデモが属する記事
パスワード 目のアイコン 9パターン — 文字とドットの入れ替え計算「パスワード 目のアイコン」の作り方は9通りある。目のアイコンの下には九つに枝分かれする路地がある。01番の路地は文字と点が入れ替わる交差点で、05番の路地は光が壁を伝って広がる通路で、06番の路地は文字が一歩ずつ歩いて入ってくる階段だ。 この記事のデモをすべて見る


