04비밀번호 칸에서 눈 가리기
비밀번호 칸에 글자를 넣기 시작하면 두 팔이 올라와 눈을 덮습니다. 보기 단추를 켜면 팔이 내려가고 눈이 다시 나옵니다.
쓰임로그인·가입 폼에서 비밀번호를 입력하는 동안 안심시키는 장치
rotatetransform-originaria-hiddentype="password"

<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="ico-eye-closed" viewBox="0 0 256 256" fill="currentColor"><path d="M224 104c-16.81 20.81-47.63 48-96 48s-79.19-27.19-96-48c16.81-20.81 47.63-48 96-48s79.19 27.19 96 48" opacity=".2"/><path d="M228 175a8 8 0 0 1-10.92-3l-19-33.2A123.2 123.2 0 0 1 162 155.46l5.87 35.22a8 8 0 0 1-6.58 9.21a8.4 8.4 0 0 1-1.29.11a8 8 0 0 1-7.88-6.69l-5.77-34.58a133 133 0 0 1-36.68 0l-5.77 34.58A8 8 0 0 1 96 200a8.4 8.4 0 0 1-1.32-.11a8 8 0 0 1-6.58-9.21l5.9-35.22a123.2 123.2 0 0 1-36.06-16.69L39 172a8 8 0 1 1-13.94-8l20-35a153.5 153.5 0 0 1-19.3-20a8 8 0 1 1 12.46-10c16.6 20.54 45.64 45 89.78 45s73.18-24.49 89.78-45a8 8 0 1 1 12.44 10a153.5 153.5 0 0 1-19.3 20l20 35a8 8 0 0 1-2.92 11"/></symbol>
</defs>
</svg>
<main class="stage">
<div class="mc is-demo" id="mc">
<p class="mc__hint">
<svg class="mc__ico" aria-hidden="true"><use href="#ico-eye-closed"/></svg>
<span>비밀번호를 넣으면 눈을 가려 줍니다</span>
</p>
<div class="mc__row">
<!-- 캐릭터는 장식이라 스크린낭독기에서는 없는 셈으로 한다(칩 aria-hidden).
실제 상태 변화는 보기 단추의 aria-pressed 가 알린다. -->
<div class="mc__char" aria-hidden="true">
<span class="mc__body">
<i class="mc__eye mc__eye--l"></i>
<i class="mc__eye mc__eye--r"></i>
<i class="mc__cheek mc__cheek--l"></i>
<i class="mc__cheek mc__cheek--r"></i>
<i class="mc__mouth"></i>
<i class="mc__arm mc__arm--l"></i>
<i class="mc__arm mc__arm--r"></i>
</span>
</div>
<form class="mc__form" id="mcForm" autocomplete="off">
<label class="mc__label" for="mcPw">비밀번호</label>
<span class="mc__field">
<!-- 칩 type="password" — 보기 단추를 켜면 text 로, 끄면 다시 password 로 돌아온다 -->
<input class="mc__input" id="mcPw" type="password" placeholder="비밀번호를 입력하세요" autocomplete="off">
<button class="mc__reveal" id="mcReveal" type="button" aria-pressed="false" aria-label="비밀번호 보기">
<svg class="mc__revealIco" aria-hidden="true"><use href="#ico-eye-closed"/></svg>
</button>
<!-- 미리보기에서만 글자가 들어오는 흉내를 내는 겹침. 정적 경로(사람이 직접 입력)에서는 보이지 않는다 -->
<span class="mc__ghost" aria-hidden="true">•••••</span>
</span>
<p class="mc__note">보기 단추를 켜면 팔이 내려갑니다</p>
</form>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 비밀번호 칸에서 눈 가리기 — 아이디어형. 글자가 들어오기 시작하면 두 팔이 올라와 눈을 덮는다.
// 팔은 위로 미끄러지는 게 아니라 어깨를 축으로 도는 것이다 — 그래서 transform-origin 을
// 팔 윗점의 어깨 관절(5px 5px)에 둔다(칩 rotate · transform-origin). 몸은 01 마스코트와 같은
// 타원 한 개, 이목구비도 같은 자리 — 같은 캐릭터로 보여야 한다.
// 눈은 팔이 닿는 순간 steps 컷으로 감았다 떠서 반쯤 감긴 유령 프레임을 만들지 않는다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
.mc { display: flex; flex-direction: column; align-items: center; gap: $sp-3; width: 100%; max-width: 340px; }
.mc__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); color: $subject-ink;
}
.mc__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $color; }
.mc__hint span { flex: 1 1 auto; min-width: 0; }
.mc__row { display: flex; align-items: flex-end; justify-content: center; gap: $sp-4; width: 100%; }
// ── 캐릭터 (01 과 같은 몸, 팔만 추가) ──
.mc__char { position: relative; width: 104px; height: 124px; flex: 0 0 auto; }
.mc__body {
position: relative; display: block; width: 88px; height: 96px;
border-radius: 50% 50% 46% 46%;
background: $color;
transform-origin: 50% 100%;
box-shadow: $shadow-press;
}
.mc__eye {
position: absolute; top: 40px; width: 10px; height: 12px;
border-radius: 50%; background: $stage-paper;
}
.mc__eye--l { left: 22px; }
.mc__eye--r { right: 22px; }
.mc__cheek {
position: absolute; top: 56px; width: 12px; height: 6px;
border-radius: $r-pill; background: $subject-pink; opacity: .9;
}
.mc__cheek--l { left: 14px; }
.mc__cheek--r { right: 14px; }
.mc__mouth {
position: absolute; top: 60px; left: 50%; width: 16px; height: 8px;
margin-left: -8px;
border-bottom: 3px solid $stage-paper;
border-radius: 0 0 $r-pill $r-pill;
}
// 팔 — 어깨에서 돈다. transform-origin 을 팔 윗점(어깨 관절)에 두는 이유:
// 중심 원점이면 팔이 제자리에서 회전하는 게 아니라 궤도를 그리며 밀려 보인다.
.mc__arm {
position: absolute; top: 42px; width: 10px; height: 40px;
border-radius: $r-pill;
background: $subject-blue-deep;
transform-origin: 5px 5px;
transition-property: transform;
transition-duration: $dur-base;
transition-timing-function: $easing;
}
.mc__arm--l { left: 2px; transform: rotate(28deg); }
.mc__arm--r { right: 2px; transform: rotate(-28deg); }
// 입력이 차면 팔이 얼굴 쪽으로 돌아 올라가 눈을 덮는다
.mc.is-cover .mc__arm--l { transform: rotate(-95deg); }
.mc.is-cover .mc__arm--r { transform: rotate(95deg); }
// 눈은 트윈 없이 바로 감긴다 — 중간 반쯤 감긴 프레임을 두지 않는다
.mc.is-cover .mc__eye { transform: scaleY(.1); }
// ── 폼 ──
.mc__form { display: flex; flex-direction: column; gap: $sp-2; flex: 1 1 auto; min-width: 0; max-width: 212px; }
.mc__label { font-weight: 700; font-size: 12px; color: $subject-ink; }
.mc__field { position: relative; display: block; }
.mc__input {
box-sizing: border-box; display: block; width: 100%;
padding: $sp-2 44px $sp-2 $sp-3;
border: 2px solid rgba($subject-ink, .35);
border-radius: $r-sm;
background: $stage-paper;
font-weight: 500; font-size: 13px; line-height: 1.4; color: $subject-ink;
}
.mc__input:focus {
border-color: $color;
box-shadow: 0 0 0 3px rgba($color, .25);
outline: none;
}
.mc__reveal {
position: absolute; right: 4px; top: 50%;
transform: translateY(-50%);
display: grid; place-items: center;
width: 28px; height: 28px; padding: 0; margin: 0;
border: 0; border-radius: $r-control;
background: transparent; color: $subject-ink;
cursor: pointer;
}
.mc__revealIco { width: 18px; height: 18px; }
.mc__reveal:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
// 보기가 켜졌다 — 파란 면 위 글자·아이콘은 대비가 깊은 쪽으로 (면 $subject-blue-deep 위 $stage-paper)
.mc__reveal[aria-pressed="true"] { background: $subject-blue-deep; color: $stage-paper; }
// 미리보기 겹침 — 정적 경로에선 아예 그리지 않는다
.mc__ghost { display: none; }
.mc__note {
margin: 0;
font-weight: 500; font-size: 11px; line-height: 1.5;
color: rgba($subject-ink, .72);
}
// ── 미리보기 루프 (설정 초 2s 안에 한 바퀴) ──
// 0~24% 글자 들어옴(컷) · 24~36% 팔 올라감 · 34~54% 눈 감김(컷) · 54% 보기 켬(컷)
// · 56~70% 팔 내려옴 · 84% 보기 끔 · 88% 글자 지움 — 복귀가 90% 안에 끝난다.
// 들어있는 글자 흉내는 clip-path 를 steps 컷으로 잘라 한 글자씩 보이게 한다(타자기 컷).
@keyframes mcBreath {
0%, 100% { transform: scaleY(1); }
50% { transform: scaleY(1.03); }
}
@keyframes mcArmL {
0%, 24% { transform: rotate(28deg); }
36% { transform: rotate(-99deg); }
42% { transform: rotate(-92deg); }
48%, 56% { transform: rotate(-99deg); }
70%, 100% { transform: rotate(28deg); }
}
@keyframes mcArmR {
0%, 24% { transform: rotate(-28deg); }
36% { transform: rotate(99deg); }
42% { transform: rotate(92deg); }
48%, 56% { transform: rotate(99deg); }
70%, 100% { transform: rotate(-28deg); }
}
// 눈 감김·뜸은 컷 — 반쯤 감긴 유령 프레임 금지
@keyframes mcWink {
0%, 32% { transform: scaleY(1); }
34%, 54% { transform: scaleY(.1); }
56%, 100% { transform: scaleY(1); }
}
@keyframes mcGhost {
0%, 5% { clip-path: inset(0 100% 0 0); }
7%, 9% { clip-path: inset(0 80% 0 0); }
11%, 13% { clip-path: inset(0 60% 0 0); }
15%, 17% { clip-path: inset(0 40% 0 0); }
19%, 21% { clip-path: inset(0 20% 0 0); }
23%, 86% { clip-path: inset(0 0 0 0); }
88%, 100% { clip-path: inset(0 100% 0 0); }
}
// 단추 켜짐도 컷 — 색 보간이 아니라 이산 교체라 중간값이 없다
@keyframes mcReveal {
0%, 52% { background-color: transparent; color: $subject-ink; }
54%, 84% { background-color: $subject-blue-deep; color: $stage-paper; }
86%, 100% { background-color: transparent; color: $subject-ink; }
}
.mc.is-demo .mc__body {
animation-name: mcBreath;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.mc.is-demo .mc__arm--l {
animation-name: mcArmL;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.mc.is-demo .mc__arm--r {
animation-name: mcArmR;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.mc.is-demo .mc__eye {
animation-name: mcWink;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.mc.is-demo .mc__reveal {
animation-name: mcReveal;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.mc.is-demo .mc__ghost {
display: flex; align-items: center;
position: absolute; top: 50%; left: 14px; height: 60%;
transform: translateY(-50%);
font-weight: 500; font-size: 13px; letter-spacing: 2px; color: $subject-ink;
animation-name: mcGhost;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
// 겹침 글자와 placeholder 가 겹쳐 읽히지 않게 미리보기 동안만 감춘다
.mc.is-demo .mc__input::placeholder { color: transparent; }
// ── 좁은 폭 (320px 무대) ── 캐릭터는 01 의 축소판 값과 같게
@media (max-width: 340px) {
.mc { gap: $sp-2; }
.mc__row { gap: $sp-2; }
.mc__char { width: 92px; height: 104px; }
.mc__body { width: 76px; height: 82px; }
.mc__eye { top: 34px; width: 9px; height: 11px; }
.mc__eye--l { left: 18px; }
.mc__eye--r { right: 18px; }
.mc__cheek { top: 48px; }
.mc__cheek--l { left: 10px; }
.mc__cheek--r { right: 10px; }
.mc__mouth { top: 52px; }
.mc__arm { top: 36px; width: 8px; height: 34px; transform-origin: 4px 4px; }
.mc__arm--l { left: 0; }
.mc__arm--r { right: 0; }
.mc__form { max-width: none; }
.mc__label { font-size: 11px; }
.mc__input { font-size: 12px; }
.mc__note { font-size: 10px; }
}
// reduce — 팔이 눈을 덮은 대표 장면 한 장으로 세운다 (poster 도 이 장면이 대표)
@media (prefers-reduced-motion: reduce) {
.mc.is-demo .mc__body,
.mc.is-demo .mc__arm--l,
.mc.is-demo .mc__arm--r,
.mc.is-demo .mc__eye,
.mc.is-demo .mc__reveal,
.mc.is-demo .mc__ghost { animation: none !important; }
.mc.is-demo .mc__arm--l { transform: rotate(-99deg) !important; }
.mc.is-demo .mc__arm--r { transform: rotate(99deg) !important; }
.mc.is-demo .mc__eye { transform: scaleY(.1) !important; }
.mc.is-demo .mc__ghost { clip-path: inset(0 0 0 0) !important; }
}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);
}
.mc {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 100%;
max-width: 340px;
}
.mc__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
color: #17141a;
}
.mc__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #2f6df6;
}
.mc__hint span {
flex: 1 1 auto;
min-width: 0;
}
.mc__row {
display: flex;
align-items: flex-end;
justify-content: center;
gap: 16px;
width: 100%;
}
.mc__char {
position: relative;
width: 104px;
height: 124px;
flex: 0 0 auto;
}
.mc__body {
position: relative;
display: block;
width: 88px;
height: 96px;
border-radius: 50% 50% 46% 46%;
background: #2f6df6;
transform-origin: 50% 100%;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.mc__eye {
position: absolute;
top: 40px;
width: 10px;
height: 12px;
border-radius: 50%;
background: #ffffff;
}
.mc__eye--l {
left: 22px;
}
.mc__eye--r {
right: 22px;
}
.mc__cheek {
position: absolute;
top: 56px;
width: 12px;
height: 6px;
border-radius: 999px;
background: #ff9ebb;
opacity: 0.9;
}
.mc__cheek--l {
left: 14px;
}
.mc__cheek--r {
right: 14px;
}
.mc__mouth {
position: absolute;
top: 60px;
left: 50%;
width: 16px;
height: 8px;
margin-left: -8px;
border-bottom: 3px solid #ffffff;
border-radius: 0 0 999px 999px;
}
.mc__arm {
position: absolute;
top: 42px;
width: 10px;
height: 40px;
border-radius: 999px;
background: #0a4fe8;
transform-origin: 5px 5px;
transition-property: transform;
transition-duration: 300ms;
transition-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
}
.mc__arm--l {
left: 2px;
transform: rotate(28deg);
}
.mc__arm--r {
right: 2px;
transform: rotate(-28deg);
}
.mc.is-cover .mc__arm--l {
transform: rotate(-95deg);
}
.mc.is-cover .mc__arm--r {
transform: rotate(95deg);
}
.mc.is-cover .mc__eye {
transform: scaleY(0.1);
}
.mc__form {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1 1 auto;
min-width: 0;
max-width: 212px;
}
.mc__label {
font-weight: 700;
font-size: 12px;
color: #17141a;
}
.mc__field {
position: relative;
display: block;
}
.mc__input {
box-sizing: border-box;
display: block;
width: 100%;
padding: 8px 44px 8px 12px;
border: 2px solid rgba(23, 20, 26, 0.35);
border-radius: 8px;
background: #ffffff;
font-weight: 500;
font-size: 13px;
line-height: 1.4;
color: #17141a;
}
.mc__input:focus {
border-color: #2f6df6;
box-shadow: 0 0 0 3px rgba(47, 109, 246, 0.25);
outline: none;
}
.mc__reveal {
position: absolute;
right: 4px;
top: 50%;
transform: translateY(-50%);
display: grid;
place-items: center;
width: 28px;
height: 28px;
padding: 0;
margin: 0;
border: 0;
border-radius: 12px;
background: transparent;
color: #17141a;
cursor: pointer;
}
.mc__revealIco {
width: 18px;
height: 18px;
}
.mc__reveal:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.mc__reveal[aria-pressed=true] {
background: #0a4fe8;
color: #ffffff;
}
.mc__ghost {
display: none;
}
.mc__note {
margin: 0;
font-weight: 500;
font-size: 11px;
line-height: 1.5;
color: rgba(23, 20, 26, 0.72);
}
@keyframes mcBreath {
0%, 100% {
transform: scaleY(1);
}
50% {
transform: scaleY(1.03);
}
}
@keyframes mcArmL {
0%, 24% {
transform: rotate(28deg);
}
36% {
transform: rotate(-99deg);
}
42% {
transform: rotate(-92deg);
}
48%, 56% {
transform: rotate(-99deg);
}
70%, 100% {
transform: rotate(28deg);
}
}
@keyframes mcArmR {
0%, 24% {
transform: rotate(-28deg);
}
36% {
transform: rotate(99deg);
}
42% {
transform: rotate(92deg);
}
48%, 56% {
transform: rotate(99deg);
}
70%, 100% {
transform: rotate(-28deg);
}
}
@keyframes mcWink {
0%, 32% {
transform: scaleY(1);
}
34%, 54% {
transform: scaleY(0.1);
}
56%, 100% {
transform: scaleY(1);
}
}
@keyframes mcGhost {
0%, 5% {
clip-path: inset(0 100% 0 0);
}
7%, 9% {
clip-path: inset(0 80% 0 0);
}
11%, 13% {
clip-path: inset(0 60% 0 0);
}
15%, 17% {
clip-path: inset(0 40% 0 0);
}
19%, 21% {
clip-path: inset(0 20% 0 0);
}
23%, 86% {
clip-path: inset(0 0 0 0);
}
88%, 100% {
clip-path: inset(0 100% 0 0);
}
}
@keyframes mcReveal {
0%, 52% {
background-color: transparent;
color: #17141a;
}
54%, 84% {
background-color: #0a4fe8;
color: #ffffff;
}
86%, 100% {
background-color: transparent;
color: #17141a;
}
}
.mc.is-demo .mc__body {
animation-name: mcBreath;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.mc.is-demo .mc__arm--l {
animation-name: mcArmL;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.mc.is-demo .mc__arm--r {
animation-name: mcArmR;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.mc.is-demo .mc__eye {
animation-name: mcWink;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.mc.is-demo .mc__reveal {
animation-name: mcReveal;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.mc.is-demo .mc__ghost {
display: flex;
align-items: center;
position: absolute;
top: 50%;
left: 14px;
height: 60%;
transform: translateY(-50%);
font-weight: 500;
font-size: 13px;
letter-spacing: 2px;
color: #17141a;
animation-name: mcGhost;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.mc.is-demo .mc__input::placeholder {
color: transparent;
}
@media (max-width: 340px) {
.mc {
gap: 8px;
}
.mc__row {
gap: 8px;
}
.mc__char {
width: 92px;
height: 104px;
}
.mc__body {
width: 76px;
height: 82px;
}
.mc__eye {
top: 34px;
width: 9px;
height: 11px;
}
.mc__eye--l {
left: 18px;
}
.mc__eye--r {
right: 18px;
}
.mc__cheek {
top: 48px;
}
.mc__cheek--l {
left: 10px;
}
.mc__cheek--r {
right: 10px;
}
.mc__mouth {
top: 52px;
}
.mc__arm {
top: 36px;
width: 8px;
height: 34px;
transform-origin: 4px 4px;
}
.mc__arm--l {
left: 0;
}
.mc__arm--r {
right: 0;
}
.mc__form {
max-width: none;
}
.mc__label {
font-size: 11px;
}
.mc__input {
font-size: 12px;
}
.mc__note {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.mc.is-demo .mc__body,
.mc.is-demo .mc__arm--l,
.mc.is-demo .mc__arm--r,
.mc.is-demo .mc__eye,
.mc.is-demo .mc__reveal,
.mc.is-demo .mc__ghost {
animation: none !important;
}
.mc.is-demo .mc__arm--l {
transform: rotate(-99deg) !important;
}
.mc.is-demo .mc__arm--r {
transform: rotate(99deg) !important;
}
.mc.is-demo .mc__eye {
transform: scaleY(0.1) !important;
}
.mc.is-demo .mc__ghost {
clip-path: inset(0 0 0 0) !important;
}
}// 비밀번호 칸에서 눈 가리기 — input 이벤트로 팔을 올리고, 보기 단추로 내린다.
// 데모 미리보기(.is-demo)는 사람이 처음 손댈 때 떼고 실제 상태로 넘어간다.
(function () {
var root = document.getElementById('mc');
var form = document.getElementById('mcForm');
var input = document.getElementById('mcPw');
var btn = document.getElementById('mcReveal');
var LABEL_SHOW = '비밀번호 보기';
var LABEL_HIDE = '비밀번호 숨기기';
function wake() { root.classList.remove('is-demo'); }
// 팔을 올릴 조건 — 글자가 하나라도 있고, 보기가 꺼져 있을 때
function sync() {
var shown = btn.getAttribute('aria-pressed') === 'true';
root.classList.toggle('is-cover', input.value !== '' && !shown);
}
form.addEventListener('submit', function (e) { e.preventDefault(); });
input.addEventListener('input', function () { wake(); sync(); });
input.addEventListener('focus', wake);
root.addEventListener('pointerenter', wake);
btn.addEventListener('click', function () {
wake();
var on = btn.getAttribute('aria-pressed') !== 'true';
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
input.type = on ? 'text' : 'password';
btn.setAttribute('aria-label', on ? LABEL_HIDE : LABEL_SHOW);
sync();
});
}());// EyesCoverPassword — vanilla 판(run/300/demo/04-mascot-eyes-cover-password/index.html) 의 React 포트.
// 글자가 들어오면 두 팔이 어깨를 축으로 올라와 눈을 덮고, 보기 단추를 켜면 내려간다.
// 캐릭터는 장식(aria-hidden), 상태는 단추의 aria-pressed 가 알린다. 값·수치는 vanilla 와 같다.
import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./EyesCoverPassword.module.scss";
export type EyesCoverPasswordProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// 로컬 이벤트 타입 — ambient 에 React 이벤트 제네릭이 없다 (292 실측 계열)
type InputEvt = { target: { value: string } };
type SubmitEvt = { preventDefault(): void };
const LABEL_SHOW = "비밀번호 보기";
const LABEL_HIDE = "비밀번호 숨기기";
const HINT_TEXT = "비밀번호를 넣으면 눈을 가려 줍니다";
const LABEL_TEXT = "비밀번호";
const PLACEHOLDER = "비밀번호를 입력하세요";
const NOTE_TEXT = "보기 단추를 켜면 팔이 내려갑니다";
const GHOST_TEXT = "•••••";
export default function EyesCoverPassword({
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: EyesCoverPasswordProps) {
const [value, setValue] = useState("");
const [shown, setShown] = useState(false);
const [demo, setDemo] = useState(true);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cover = value !== "" && !shown;
const rootCls = cover
? `${styles.mc} ${styles["is-cover"]}`
: styles.mc;
return (
<main className={styles.stage}>
<div
className={demo ? `${rootCls} ${styles["is-demo"]}` : rootCls}
style={vars}
onPointerEnter={() => setDemo(false)}
>
<p className={styles.mc__hint}>
<svg className={styles.mc__ico} aria-hidden="true">
<use href="#ico-eye-closed" />
</svg>
<span>{HINT_TEXT}</span>
</p>
<div className={styles.mc__row}>
{/* 캐릭터는 장식 — 상태는 보기 단추의 aria-pressed 로 알린다 */}
<div className={styles.mc__char} aria-hidden={true}>
<span className={styles.mc__body}>
<i className={`${styles.mc__eye} ${styles["mc__eye--l"]}`}></i>
<i className={`${styles.mc__eye} ${styles["mc__eye--r"]}`}></i>
<i className={`${styles.mc__cheek} ${styles["mc__cheek--l"]}`}></i>
<i className={`${styles.mc__cheek} ${styles["mc__cheek--r"]}`}></i>
<i className={styles.mc__mouth}></i>
<i className={`${styles.mc__arm} ${styles["mc__arm--l"]}`}></i>
<i className={`${styles.mc__arm} ${styles["mc__arm--r"]}`}></i>
</span>
</div>
<form
className={styles.mc__form}
autoComplete="off"
onSubmit={(e: SubmitEvt) => e.preventDefault()}
>
<label className={styles.mc__label} htmlFor="mc-pw">{LABEL_TEXT}</label>
<span className={styles.mc__field}>
<input
id="mc-pw"
className={styles.mc__input}
type={shown ? "text" : "password"}
placeholder={PLACEHOLDER}
autoComplete="off"
value={value}
onFocus={() => setDemo(false)}
onChange={(e: InputEvt) => { setDemo(false); setValue(e.target.value); }}
/>
<button
type="button"
className={styles.mc__reveal}
aria-pressed={shown ? "true" : "false"}
aria-label={shown ? LABEL_HIDE : LABEL_SHOW}
onClick={() => { setDemo(false); setShown((s) => !s); }}
>
<svg className={styles.mc__revealIco} aria-hidden="true">
<use href="#ico-eye-closed" />
</svg>
</button>
{/* 미리보기에서만 글자가 들어오는 흉내를 내는 겹침 */}
<span className={styles.mc__ghost} aria-hidden={true}>{GHOST_TEXT}</span>
</span>
<p className={styles.mc__note}>{NOTE_TEXT}</p>
</form>
</div>
</div>
</main>
);
}04 비밀번호 칸에서 눈 가리기 — 측정 누적면적 2.806% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 마스코트 애니메이션 9종 — 인사부터 잠들기까지
이 데모가 속한 글
마스코트 애니메이션 9종 — 인사부터 잠들기까지마스코트 애니메이션은 서비스 화면에 사는 캐릭터가 상황마다 다른 몸짓과 표정으로 사람에게 말을 거는 움직임입니다. 여기 실은 아홉 개는 그림 파일도 라이브러리도 없이 타원 하나에 눈·볼·입만 얹어 CSS 로 그렸고, 이 글의 데모 전부 보기


