04Covering eyes at the password field
As soon as characters go into the password field, both arms come up and cover the eyes. Turning on the reveal button lowers them again.
Best forA reassuring touch on a login or sign-up form while a password is being typed
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>The mascot hides its eyes while you type</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">Password</label>
<span class="mc__field">
<!-- 칩 type="password" — 보기 단추를 켜면 text 로, 끄면 다시 password 로 돌아온다 -->
<input class="mc__input" id="mcPw" type="password" placeholder="Enter a password" autocomplete="off">
<button class="mc__reveal" id="mcReveal" type="button" aria-pressed="false" aria-label="Show password">
<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">Show lowers the arms again</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;
}
}// Password 칸에서 눈 가리기 — 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 = 'Show password';
var LABEL_HIDE = 'Hide password';
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 Covering eyes at the password field — Measured area 2.806% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · Mascot Animation: 9 CSS Parts from Wave to Sleep
The post this demo lives in
Mascot Animation: 9 CSS Parts from Wave to SleepA mascot animation is a small drawn character that reacts to what the screen is doing: waving at a new visitor, pointing See all demos in this post


