01글자와 점이 자리를 바꾸는 눈 아이콘 모프
눈 아이콘을 누르면 마스킹 점과 실제 문자가 같은 자리에서 교차 페이드로 서로 바뀐다.
쓰임로그인 폼 비밀번호 필드
EYE_MORPH_MSbuildOverlaypem__ch
<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 표시 아이콘 9가지 — 입력창 속 문자가 도트로 바뀌는 계산
이 데모가 속한 글
비밀번호 표시 아이콘 9가지 — 입력창 속 문자가 도트로 바뀌는 계산비밀번호 표시 아이콘 밑에는 아홉 갈래로 갈라지는 골목이 있다. 01번 골목은 글자와 점이 자리를 바꾸는 교차로이고, 05번 골목은 빛이 벽을 타고 번지는 통로이며, 06번 골목은 글자가 한 걸음씩 걸어 들어오는 계 이 글의 데모 전부 보기


