02캡스락 켜짐을 스프링으로 알리는 경고 배지
캡스락 상태 값이 켜짐으로 바뀌는 순간 입력창 옆 배지가 오버슈트를 그리며 튀어 오른다.
쓰임회원가입 비밀번호 입력 단계
CAPSLOCK_BADGE_OVERSHOOT_PXstepSpringpcs__badge--active

<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 캡스락 켜짐을 스프링으로 알리는 경고 배지 — 측정 누적면적 75.869% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 표시 아이콘 9가지 — 입력창 속 문자가 도트로 바뀌는 계산
이 데모가 속한 글
비밀번호 표시 아이콘 9가지 — 입력창 속 문자가 도트로 바뀌는 계산비밀번호 표시 아이콘 밑에는 아홉 갈래로 갈라지는 골목이 있다. 01번 골목은 글자와 점이 자리를 바꾸는 교차로이고, 05번 골목은 빛이 벽을 타고 번지는 통로이며, 06번 골목은 글자가 한 걸음씩 걸어 들어오는 계 이 글의 데모 전부 보기


