07押している間だけ進行リングで開くホールド方式
ボタンを押している時間に合わせて進行リングが満ち、満タンで文字が開き、離すと再びマスクされる。
使いどころ共有画面で一瞬だけ見せるパスワード欄
HOLD_DURATION_MSphrRingFgEYE_OPEN_MS

<main class="stage">
<div class="phr is-demo" id="phrRoot">
<label class="phr__label" for="phrInput">パスワード</label>
<div class="phr__field">
<input class="phr__input" id="phrInput" type="password" aria-describedby="phrHint">
<button class="phr__btn" id="phrBtn" type="button" aria-pressed="false" aria-label="長押しでパスワードを表示">
<svg class="phr__ring" viewBox="0 0 44 44" width="40" height="40" aria-hidden="true">
<circle class="phr__ringTrack" cx="22" cy="22" r="18" fill="none" stroke-width="3"/>
<circle class="phr__ringFg" id="phrRingFg" cx="22" cy="22" r="18" fill="none" stroke-width="3" stroke-linecap="round"/>
</svg>
<svg class="phr__eye" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<path class="phr__lidTop" id="phrLidTop" d="M2,12 Q12,4 22,12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path class="phr__lidBottom" id="phrLidBottom" d="M2,12 Q12,20 22,12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<circle class="phr__pupil" id="phrPupil" cx="12" cy="12" r="3.2" fill="currentColor"/>
</svg>
</button>
</div>
<p class="phr__hint" id="phrHint">押している時間だけリングが満ち、満タンで文字が開き、離すと即座に再び閉じる。</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-orange);
.phr {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-ink;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $sp-5;
padding: $sp-6;
box-sizing: border-box;
color: $subject-cream;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
text-align: center;
}
// 배경 톤: 링 안쪽에 갇혀 있던 hold-progress(0~1) 를 카드 톤으로도 흘려보낸다(계산 그대로 재사용).
.phr::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: rgba(91, 140, 248, calc(var(--phr-progress, 0) * .28));
}
.phr__label {
font-weight: 600;
font-size: 13px;
letter-spacing: .02em;
color: rgba(255, 247, 230, .72);
}
.phr__field {
display: flex;
align-items: center;
gap: $sp-3;
background: rgba(255, 247, 230, .08);
border: 1px solid rgba(255, 247, 230, .18);
border-radius: $r-control;
padding: $sp-2 $sp-3;
}
.phr__input {
background: transparent;
border: none;
outline: none;
color: $subject-cream;
font-size: 16px;
letter-spacing: .08em;
width: 9.5em;
font-family: inherit;
}
.phr__btn {
position: relative;
display: grid;
place-items: center;
width: 40px;
height: 40px;
padding: 0;
border: none;
border-radius: $r-pill;
background: transparent;
color: $subject-cream;
cursor: pointer;
touch-action: none;
}
.phr__btn:focus-visible {
outline: 2px solid $subject-cream;
outline-offset: 2px;
}
.phr__ring {
position: absolute;
inset: 0;
transform: rotate(-90deg);
}
.phr__ringTrack { stroke: rgba(255, 247, 230, .2); }
.phr__ringFg { stroke: $subject-cream; }
.phr__eye {
position: relative;
z-index: 1;
}
.phr__hint {
margin: 0;
font-size: 12px;
line-height: 1.4;
color: rgba(255, 247, 230, .62);
max-width: 22em;
}
@media (max-width: 340px) {
.phr { gap: $sp-3; padding: $sp-4; }
.phr__hint { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.phr__ringFg, .phr__lidTop, .phr__lidBottom, .phr__pupil { 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);
}
.phr {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
padding: 24px;
box-sizing: border-box;
color: #fff7e6;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
text-align: center;
}
.phr::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: rgba(91, 140, 248, calc(var(--phr-progress, 0) * 0.28));
}
.phr__label {
font-weight: 600;
font-size: 13px;
letter-spacing: 0.02em;
color: rgba(255, 247, 230, 0.72);
}
.phr__field {
display: flex;
align-items: center;
gap: 12px;
background: rgba(255, 247, 230, 0.08);
border: 1px solid rgba(255, 247, 230, 0.18);
border-radius: 12px;
padding: 8px 12px;
}
.phr__input {
background: transparent;
border: none;
outline: none;
color: #fff7e6;
font-size: 16px;
letter-spacing: 0.08em;
width: 9.5em;
font-family: inherit;
}
.phr__btn {
position: relative;
display: grid;
place-items: center;
width: 40px;
height: 40px;
padding: 0;
border: none;
border-radius: 999px;
background: transparent;
color: #fff7e6;
cursor: pointer;
touch-action: none;
}
.phr__btn:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.phr__ring {
position: absolute;
inset: 0;
transform: rotate(-90deg);
}
.phr__ringTrack {
stroke: rgba(255, 247, 230, 0.2);
}
.phr__ringFg {
stroke: #fff7e6;
}
.phr__eye {
position: relative;
z-index: 1;
}
.phr__hint {
margin: 0;
font-size: 12px;
line-height: 1.4;
color: rgba(255, 247, 230, 0.62);
max-width: 22em;
}
@media (max-width: 340px) {
.phr {
gap: 12px;
padding: 16px;
}
.phr__hint {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.phr__ringFg, .phr__lidTop, .phr__lidBottom, .phr__pupil {
transition: none;
}
}(function () {
// 기법: pointerdown/keydown(Space) 로 홀드를 시작하면 rAF 루프가 경과시간을 HOLD_DURATION_MS
// 로 나눈 progress(0~1)를 계산해 SVG 링의 stroke-dashoffset 을 매 프레임 다시 쓴다. progress
// 가 1 에 닿는 순간에만 실제 input[type] 을 text 로 바꿔 노출하고, 손을 떼거나 취소되면
// progress·type·눈 모프를 전부 즉시 0(닫힘)으로 되돌린다 — 트윈이 아니라 "다 찼는가"라는
// 판정 하나로 열고 닫는다.
var root = document.getElementById('phrRoot');
var input = document.getElementById('phrInput');
var btn = document.getElementById('phrBtn');
var ringFg = document.getElementById('phrRingFg');
var lidTop = document.getElementById('phrLidTop');
var lidBottom = document.getElementById('phrLidBottom');
var pupil = document.getElementById('phrPupil');
var HOLD_DURATION_MS = 900;
var EYE_OPEN_MS = 220;
var RING_R = 18;
var CIRCUMFERENCE = 2 * Math.PI * RING_R;
ringFg.style.strokeDasharray = String(CIRCUMFERENCE);
ringFg.style.strokeDashoffset = String(CIRCUMFERENCE);
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var pwRand = seedRand(426071);
var PW_POOL = 'ABCDEFGHJKMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%';
function pickPassword(len) {
var out = '';
for (var i = 0; i < len; i += 1) out += PW_POOL[Math.floor(pwRand() * PW_POOL.length)];
return out;
}
input.value = pickPassword(10);
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
function setEye(progress) {
var eased = easeOutCubic(progress);
var lift = 8 * eased;
lidTop.setAttribute('d', 'M2,12 Q12,' + (12 - lift) + ' 22,12');
lidBottom.setAttribute('d', 'M2,12 Q12,' + (12 + lift) + ' 22,12');
pupil.style.opacity = String(eased);
}
setEye(0);
var holding = false;
var revealed = false;
var holdStart = 0;
var rafId = null;
function setRing(progress) {
ringFg.style.strokeDashoffset = String(CIRCUMFERENCE * (1 - progress));
root.style.setProperty('--phr-progress', String(progress));
}
function loop(ts) {
if (!holding) return;
var elapsed = ts - holdStart;
var progress = Math.max(0, Math.min(1, elapsed / HOLD_DURATION_MS));
setRing(progress);
if (progress >= 1 && !revealed) {
revealed = true;
input.type = 'text';
btn.setAttribute('aria-pressed', 'true');
}
var eyeProgress = revealed ? 1 : Math.max(0, Math.min(1, elapsed / EYE_OPEN_MS));
setEye(eyeProgress);
rafId = requestAnimationFrame(loop);
}
function startHold() {
if (holding) return;
holding = true;
revealed = false;
holdStart = performance.now();
rafId = requestAnimationFrame(loop);
}
function endHold() {
if (!holding) return;
holding = false;
revealed = false;
input.type = 'password';
btn.setAttribute('aria-pressed', 'false');
setRing(0);
setEye(0);
if (rafId) cancelAnimationFrame(rafId);
rafId = null;
}
btn.addEventListener('pointerdown', function (e) { e.preventDefault(); startHold(); });
btn.addEventListener('pointerup', endHold);
btn.addEventListener('pointercancel', endHold);
btn.addEventListener('pointerleave', endHold);
btn.addEventListener('keydown', function (e) {
if (e.code !== 'Space') return;
e.preventDefault();
if (e.repeat) return;
startHold();
});
btn.addEventListener('keyup', function (e) {
if (e.code !== 'Space') return;
e.preventDefault();
endHold();
});
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) root.classList.remove('is-demo');
function demoLoop() {
if (!root.classList.contains('is-demo')) return;
startHold();
setTimeout(function () {
endHold();
setTimeout(demoLoop, 1300);
}, HOLD_DURATION_MS + 400);
}
if (!reduce) setTimeout(demoLoop, 200);
})();import { useEffect, useRef } from "react";
import styles from "./PasswordRevealHoldToShowProgress.module.scss";
export default function PasswordRevealHoldToShowProgress() {
const rootRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const btnRef = useRef<HTMLButtonElement>(null);
const ringFgRef = useRef<SVGCircleElement>(null);
const lidTopRef = useRef<SVGPathElement>(null);
const lidBottomRef = useRef<SVGPathElement>(null);
const pupilRef = useRef<SVGCircleElement>(null);
useEffect(() => {
const root = rootRef.current;
const input = inputRef.current;
const btn = btnRef.current;
const ringFg = ringFgRef.current;
const lidTop = lidTopRef.current;
const lidBottom = lidBottomRef.current;
const pupil = pupilRef.current;
if (!root || !input || !btn || !ringFg || !lidTop || !lidBottom || !pupil) return;
const HOLD_DURATION_MS = 900;
const EYE_OPEN_MS = 220;
const RING_R = 18;
const CIRCUMFERENCE = 2 * Math.PI * RING_R;
ringFg.style.strokeDasharray = String(CIRCUMFERENCE);
ringFg.style.strokeDashoffset = String(CIRCUMFERENCE);
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const pwRand = seedRand(426072);
const PW_POOL = "ABCDEFGHJKMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%";
function pickPassword(len: number) {
let out = "";
for (let i = 0; i < len; i += 1) out += PW_POOL[Math.floor(pwRand() * PW_POOL.length)];
return out;
}
input.value = pickPassword(10);
function easeOutCubic(t: number) { return 1 - Math.pow(1 - t, 3); }
function setEye(progress: number) {
const eased = easeOutCubic(progress);
const lift = 8 * eased;
lidTop!.setAttribute("d", "M2,12 Q12," + (12 - lift) + " 22,12");
lidBottom!.setAttribute("d", "M2,12 Q12," + (12 + lift) + " 22,12");
pupil!.style.opacity = String(eased);
}
setEye(0);
let holding = false;
let revealed = false;
let holdStart = 0;
let rafId = 0;
function setRing(progress: number) {
ringFg!.style.strokeDashoffset = String(CIRCUMFERENCE * (1 - progress));
root!.style.setProperty('--phr-progress', String(progress));
}
function loop(ts: number) {
if (!holding) return;
const elapsed = ts - holdStart;
const progress = Math.max(0, Math.min(1, elapsed / HOLD_DURATION_MS));
setRing(progress);
if (progress >= 1 && !revealed) {
revealed = true;
input!.type = "text";
btn!.setAttribute("aria-pressed", "true");
}
const eyeProgress = revealed ? 1 : Math.max(0, Math.min(1, elapsed / EYE_OPEN_MS));
setEye(eyeProgress);
rafId = requestAnimationFrame(loop);
}
function startHold() {
if (holding) return;
holding = true;
revealed = false;
holdStart = performance.now();
rafId = requestAnimationFrame(loop);
}
function endHold() {
if (!holding) return;
holding = false;
revealed = false;
input!.type = "password";
btn!.setAttribute("aria-pressed", "false");
setRing(0);
setEye(0);
cancelAnimationFrame(rafId);
}
function onPointerDown(e: PointerEvent) { e.preventDefault(); startHold(); }
function onPointerEnd() { endHold(); }
function onKeyDown(e: KeyboardEvent) {
if (e.code !== "Space") return;
e.preventDefault();
if (e.repeat) return;
startHold();
}
function onKeyUp(e: KeyboardEvent) {
if (e.code !== "Space") return;
e.preventDefault();
endHold();
}
btn.addEventListener("pointerdown", onPointerDown);
btn.addEventListener("pointerup", onPointerEnd);
btn.addEventListener("pointercancel", onPointerEnd);
btn.addEventListener("pointerleave", onPointerEnd);
btn.addEventListener("keydown", onKeyDown);
btn.addEventListener("keyup", onKeyUp);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) root.classList.remove("is-demo");
let demoTimer = 0;
function demoLoop() {
if (!root!.classList.contains("is-demo")) return;
startHold();
demoTimer = window.setTimeout(function () {
endHold();
demoTimer = window.setTimeout(demoLoop, 1300);
}, HOLD_DURATION_MS + 400);
}
if (!reduce) demoTimer = window.setTimeout(demoLoop, 200);
return () => {
btn.removeEventListener("pointerdown", onPointerDown);
btn.removeEventListener("pointerup", onPointerEnd);
btn.removeEventListener("pointercancel", onPointerEnd);
btn.removeEventListener("pointerleave", onPointerEnd);
btn.removeEventListener("keydown", onKeyDown);
btn.removeEventListener("keyup", onKeyUp);
cancelAnimationFrame(rafId);
window.clearTimeout(demoTimer);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.phr} is-demo`} ref={rootRef}>
<label className={styles.phr__label} htmlFor="phrInputReact">비밀번호</label>
<div className={styles.phr__field}>
<input className={styles.phr__input} id="phrInputReact" type="password" aria-describedby="phrHintReact" ref={inputRef} readOnly />
<button className={styles.phr__btn} type="button" aria-pressed="false" aria-label="길게 눌러 비밀번호 보기" ref={btnRef}>
<svg className={styles.phr__ring} viewBox="0 0 44 44" width={40} height={40} aria-hidden="true">
<circle className={styles.phr__ringTrack} cx={22} cy={22} r={18} fill="none" strokeWidth={3} />
<circle className={styles.phr__ringFg} ref={ringFgRef} cx={22} cy={22} r={18} fill="none" strokeWidth={3} strokeLinecap="round" />
</svg>
<svg className={styles.phr__eye} viewBox="0 0 24 24" width={18} height={18} aria-hidden="true">
<path className={styles.phr__lidTop} ref={lidTopRef} d="M2,12 Q12,4 22,12" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" />
<path className={styles.phr__lidBottom} ref={lidBottomRef} d="M2,12 Q12,20 22,12" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" />
<circle className={styles.phr__pupil} ref={pupilRef} cx={12} cy={12} r={3.2} fill="currentColor" />
</svg>
</button>
</div>
<p className={styles.phr__hint} id="phrHintReact">누르고 있는 시간만큼 링이 채워지면 문자가 열리고, 손을 떼면 즉시 다시 닫힌다</p>
</div>
</main>
);
}07 押している間だけ進行リングで開くホールド方式 — 計測 累積面積 75.911% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワード 目のアイコン 9パターン — 文字とドットの入れ替え計算
このデモが属する記事
パスワード 目のアイコン 9パターン — 文字とドットの入れ替え計算「パスワード 目のアイコン」の作り方は9通りある。目のアイコンの下には九つに枝分かれする路地がある。01番の路地は文字と点が入れ替わる交差点で、05番の路地は光が壁を伝って広がる通路で、06番の路地は文字が一歩ずつ歩いて入ってくる階段だ。 この記事のデモをすべて見る


