06생성 버튼을 누르면 한 글자씩 나타나는 캐스케이드
생성 버튼을 누르면 새 문자열 값이 한 글자씩 순서대로 나타나고 끝나면 복사 아이콘이 체크로 모프된다.
쓰임강력한 비밀번호 자동 생성 필드
GENERATE_CASCADE_STEP_MSCOPY_MORPH_MSgeneratePassword

<main class="stage">
<div class="gcc is-demo">
<label class="gcc__label" for="gcc-display">생성된 비밀번호</label>
<div class="gcc__frame">
<div class="gcc__display" id="gccDisplay" aria-live="polite"></div>
<span class="gcc__status" aria-hidden="true">
<svg class="gcc__icon gcc__icon--copy" viewBox="0 0 256 256" width="22" height="22"><g fill="currentColor"><path d="M216 40v128h-48V88H88V40Z" opacity=".2"/><path d="M216 32H88a8 8 0 0 0-8 8v40H40a8 8 0 0 0-8 8v128a8 8 0 0 0 8 8h128a8 8 0 0 0 8-8v-40h40a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-56 176H48V96h112Zm48-48h-32V88a8 8 0 0 0-8-8H96V48h112Z"/></g></svg>
<span class="gcc__check">
<span class="gcc__checkBar gcc__checkBar--short"></span>
<span class="gcc__checkBar gcc__checkBar--long"></span>
</span>
</span>
</div>
<button type="button" class="gcc__gen" id="gccGenBtn">생성</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-yellow);
.gcc {
position: relative;
width: min(300px, 86vw);
display: grid;
gap: $sp-3;
justify-items: center;
}
// 배경 톤: 복사 확인 체크가 아이콘 한 칸에 머물지 않고 카드 톤으로 번진다(is-copied 신호 재사용).
.gcc::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: rgba(76, 212, 166, 0);
transition: background-color .26s ease;
}
.gcc.is-copied::before {
background: rgba(76, 212, 166, .16);
}
.gcc__label {
align-self: flex-start;
font-weight: 700;
font-size: 13px;
line-height: 1;
color: rgba($subject-ink, .7);
}
.gcc__frame {
position: relative;
width: 100%;
display: flex;
align-items: center;
gap: $sp-3;
box-sizing: border-box;
border: 2px solid rgba($subject-ink, .18);
border-radius: $r-control;
padding: $sp-3 $sp-4;
background: #fff;
}
.gcc__display {
flex: 1;
min-height: 1.3em;
font-weight: 600;
font-size: clamp(14px, 4vw, 16px);
line-height: 1.3;
font-family: "SFMono-Regular", Consolas, monospace;
color: $subject-ink;
letter-spacing: .04em;
word-break: break-all;
}
.gcc__status {
position: relative;
flex: none;
width: 22px;
height: 22px;
}
.gcc__icon {
position: absolute;
inset: 0;
width: 22px;
height: 22px;
color: $subject-ink;
}
.gcc__icon--copy { opacity: calc(1 - var(--copyMorph, 0)); }
.gcc__check {
position: absolute;
inset: 0;
opacity: var(--copyMorph, 0);
}
.gcc__checkBar {
position: absolute;
left: 5px;
top: 11px;
height: 2.4px;
border-radius: $r-pill;
background: $subject-ink;
transform-origin: left center;
}
.gcc__checkBar--short { width: 6px; transform: rotate(225deg); }
.gcc__checkBar--long { width: 12px; transform: rotate(-48deg); }
.gcc__gen {
border: 0;
border-radius: $r-pill;
padding: $sp-2 $sp-6;
font-weight: 700;
font-size: 13px;
line-height: 1;
font-family: inherit;
color: $subject-cream;
background: $subject-ink;
cursor: pointer;
transition: transform $dur-quick $ease-pop;
&:active { transform: scale(.96); }
}
@media (max-width: 340px) {
.gcc { width: min(260px, 88vw); }
}
@media (prefers-reduced-motion: reduce) {
.gcc__gen { transition: none; }
.gcc::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);
}
.gcc {
position: relative;
width: min(300px, 86vw);
display: grid;
gap: 12px;
justify-items: center;
}
.gcc::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: rgba(76, 212, 166, 0);
transition: background-color 0.26s ease;
}
.gcc.is-copied::before {
background: rgba(76, 212, 166, 0.16);
}
.gcc__label {
align-self: flex-start;
font-weight: 700;
font-size: 13px;
line-height: 1;
color: rgba(23, 20, 26, 0.7);
}
.gcc__frame {
position: relative;
width: 100%;
display: flex;
align-items: center;
gap: 12px;
box-sizing: border-box;
border: 2px solid rgba(23, 20, 26, 0.18);
border-radius: 12px;
padding: 12px 16px;
background: #fff;
}
.gcc__display {
flex: 1;
min-height: 1.3em;
font-weight: 600;
font-size: clamp(14px, 4vw, 16px);
line-height: 1.3;
font-family: "SFMono-Regular", Consolas, monospace;
color: #17141a;
letter-spacing: 0.04em;
word-break: break-all;
}
.gcc__status {
position: relative;
flex: none;
width: 22px;
height: 22px;
}
.gcc__icon {
position: absolute;
inset: 0;
width: 22px;
height: 22px;
color: #17141a;
}
.gcc__icon--copy {
opacity: calc(1 - var(--copyMorph, 0));
}
.gcc__check {
position: absolute;
inset: 0;
opacity: var(--copyMorph, 0);
}
.gcc__checkBar {
position: absolute;
left: 5px;
top: 11px;
height: 2.4px;
border-radius: 999px;
background: #17141a;
transform-origin: left center;
}
.gcc__checkBar--short {
width: 6px;
transform: rotate(225deg);
}
.gcc__checkBar--long {
width: 12px;
transform: rotate(-48deg);
}
.gcc__gen {
border: 0;
border-radius: 999px;
padding: 8px 24px;
font-weight: 700;
font-size: 13px;
line-height: 1;
font-family: inherit;
color: #fff7e6;
background: #17141a;
cursor: pointer;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.gcc__gen:active {
transform: scale(0.96);
}
@media (max-width: 340px) {
.gcc {
width: min(260px, 88vw);
}
}
@media (prefers-reduced-motion: reduce) {
.gcc__gen {
transition: none;
}
.gcc::before {
transition: none;
}
}(function () {
// 기법: 후보 문자열은 seedRand 로 모듈 초기화 시 미리 만든다(클릭마다 인덱스만 순환 — 결정적).
// 표시는 spawnAcc 류 rAF 누적 타이머로 한 글자씩 채우고, 다 채워지면 복사→체크 아이콘을
// opacity 크로스페이드로 모프한다. clipboard API 호출은 없다(시각 전용).
var GENERATE_CASCADE_STEP_MS = 60;
var COPY_MORPH_MS = 220;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var root = document.querySelector('.gcc');
var display = document.getElementById('gccDisplay');
var genBtn = document.getElementById('gccGenBtn');
var candRand = seedRand(426061);
var pool = 'ABCDEFGHJKMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$';
var CANDIDATES = [];
for (var c = 0; c < 4; c += 1) {
var len = 8 + Math.floor(candRand() * 3);
var s = '';
for (var j = 0; j < len; j += 1) s += pool[Math.floor(candRand() * pool.length)];
CANDIDATES.push(s);
}
var candIndex = -1;
var cascade = { target: '', revealed: 0, acc: 0, active: false, morphing: false, morphAcc: 0 };
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function generatePassword() {
candIndex = (candIndex + 1) % CANDIDATES.length;
var target = CANDIDATES[candIndex];
cascade.target = target;
cascade.acc = 0;
cascade.morphAcc = 0;
root.style.setProperty('--copyMorph', '0');
root.classList.remove('is-copied');
if (reduce) {
cascade.revealed = target.length;
cascade.active = false;
cascade.morphing = false;
display.textContent = target;
root.style.setProperty('--copyMorph', '1');
root.classList.add('is-copied');
return;
}
cascade.revealed = 0;
cascade.active = true;
cascade.morphing = false;
display.textContent = '';
}
function stepCascade(dtMs) {
if (cascade.active) {
cascade.acc += dtMs;
while (cascade.acc >= GENERATE_CASCADE_STEP_MS && cascade.revealed < cascade.target.length) {
cascade.acc -= GENERATE_CASCADE_STEP_MS;
cascade.revealed += 1;
display.textContent = cascade.target.slice(0, cascade.revealed);
}
if (cascade.revealed >= cascade.target.length) {
cascade.active = false;
cascade.morphing = true;
cascade.morphAcc = 0;
}
} else if (cascade.morphing) {
cascade.morphAcc += dtMs;
var p = Math.min(1, cascade.morphAcc / COPY_MORPH_MS);
root.style.setProperty('--copyMorph', String(p));
if (p >= 1) {
cascade.morphing = false;
root.classList.add('is-copied');
}
}
}
genBtn.addEventListener('click', generatePassword);
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) stepCascade(dt);
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
if (reduce) root.classList.remove('is-demo');
if (root.classList.contains('is-demo')) {
setTimeout(function () {
generatePassword();
setInterval(generatePassword, 2000);
}, 200);
}
})();import { useEffect, useRef } from "react";
import styles from "./PasswordRevealGenerateCopyCascade.module.scss";
export default function PasswordRevealGenerateCopyCascade() {
const rootRef = useRef<HTMLDivElement>(null);
const displayRef = useRef<HTMLDivElement>(null);
const btnRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const root = rootRef.current;
const display = displayRef.current;
const btn = btnRef.current;
if (!root || !display || !btn) return;
const GENERATE_CASCADE_STEP_MS = 60;
const COPY_MORPH_MS = 220;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const candRand = seedRand(426061);
const pool = "ABCDEFGHJKMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$";
const CANDIDATES: string[] = [];
for (let c = 0; c < 4; c += 1) {
const len = 8 + Math.floor(candRand() * 3);
let s = "";
for (let j = 0; j < len; j += 1) s += pool[Math.floor(candRand() * pool.length)];
CANDIDATES.push(s);
}
let candIndex = -1;
const cascade = { target: "", revealed: 0, acc: 0, active: false, morphing: false, morphAcc: 0 };
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function generatePassword() {
candIndex = (candIndex + 1) % CANDIDATES.length;
const target = CANDIDATES[candIndex];
cascade.target = target;
cascade.acc = 0;
cascade.morphAcc = 0;
root!.style.setProperty("--copyMorph", "0");
root!.classList.remove(styles.isCopied);
if (reduce) {
cascade.revealed = target.length;
cascade.active = false;
cascade.morphing = false;
display!.textContent = target;
root!.style.setProperty("--copyMorph", "1");
root!.classList.add(styles.isCopied);
return;
}
cascade.revealed = 0;
cascade.active = true;
cascade.morphing = false;
display!.textContent = "";
}
function stepCascade(dtMs: number) {
if (cascade.active) {
cascade.acc += dtMs;
while (cascade.acc >= GENERATE_CASCADE_STEP_MS && cascade.revealed < cascade.target.length) {
cascade.acc -= GENERATE_CASCADE_STEP_MS;
cascade.revealed += 1;
display!.textContent = cascade.target.slice(0, cascade.revealed);
}
if (cascade.revealed >= cascade.target.length) {
cascade.active = false;
cascade.morphing = true;
cascade.morphAcc = 0;
}
} else if (cascade.morphing) {
cascade.morphAcc += dtMs;
const p = Math.min(1, cascade.morphAcc / COPY_MORPH_MS);
root!.style.setProperty("--copyMorph", String(p));
if (p >= 1) {
cascade.morphing = false;
root!.classList.add(styles.isCopied);
}
}
}
function onClick() { generatePassword(); }
btn.addEventListener("click", onClick);
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) stepCascade(dt);
raf = requestAnimationFrame(tick);
}
raf = requestAnimationFrame(tick);
if (reduce) root.classList.remove("is-demo");
let loopTimer: ReturnType<typeof setTimeout> | null = null;
let loopInterval: ReturnType<typeof setInterval> | null = null;
if (root.classList.contains("is-demo")) {
loopTimer = setTimeout(() => {
generatePassword();
loopInterval = setInterval(generatePassword, 2000);
}, 200);
}
return () => {
btn.removeEventListener("click", onClick);
cancelAnimationFrame(raf);
if (loopTimer) clearTimeout(loopTimer);
if (loopInterval) clearInterval(loopInterval);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.gcc} is-demo`} ref={rootRef}>
<label className={styles.gcc__label} htmlFor="gcc-display-react">생성된 비밀번호</label>
<div className={styles.gcc__frame}>
<div id="gcc-display-react" className={styles.gcc__display} ref={displayRef} aria-live="polite" />
<span className={styles.gcc__status} aria-hidden="true">
<svg className={`${styles.gcc__icon} ${styles["gcc__icon--copy"]}`} viewBox="0 0 256 256" width="22" height="22">
<g fill="currentColor">
<path d="M216 40v128h-48V88H88V40Z" opacity=".2" />
<path d="M216 32H88a8 8 0 0 0-8 8v40H40a8 8 0 0 0-8 8v128a8 8 0 0 0 8 8h128a8 8 0 0 0 8-8v-40h40a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-56 176H48V96h112Zm48-48h-32V88a8 8 0 0 0-8-8H96V48h112Z" />
</g>
</svg>
<span className={styles.gcc__check}>
<span className={`${styles.gcc__checkBar} ${styles["gcc__checkBar--short"]}`} />
<span className={`${styles.gcc__checkBar} ${styles["gcc__checkBar--long"]}`} />
</span>
</span>
</div>
<button type="button" className={styles.gcc__gen} ref={btnRef}>생성</button>
</div>
</main>
);
}06 생성 버튼을 누르면 한 글자씩 나타나는 캐스케이드 — 측정 누적면적 14.485% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 표시 아이콘 9가지 — 입력창 속 문자가 도트로 바뀌는 계산
이 데모가 속한 글
비밀번호 표시 아이콘 9가지 — 입력창 속 문자가 도트로 바뀌는 계산비밀번호 표시 아이콘 밑에는 아홉 갈래로 갈라지는 골목이 있다. 01번 골목은 글자와 점이 자리를 바꾸는 교차로이고, 05번 골목은 빛이 벽을 타고 번지는 통로이며, 06번 골목은 글자가 한 걸음씩 걸어 들어오는 계 이 글의 데모 전부 보기


