02대기 파동 — 인증 대기 상태에서 원형 파동의 반경 값이 감쇠 함수로 점점 커지며 옅어지는 방식
대기 상태가 이어지는 동안 파동 반경 값이 감쇠 함수를 따라 커지고, 반경 값이 커질수록 불투명도 값은 반대로 줄어듭니다.
쓰임생체 인증 대기 화면, 결제 승인 대기 화면
LAUNCH_GAP$subject-blue-deepPULSE_MAX_R

<main class="stage">
<div class="pulse-wrap">
<button type="button" class="pulse is-demo" id="pulse" aria-pressed="true">
<svg class="pulse__icon" viewBox="0 0 120 120" aria-hidden="true">
<circle class="pulse__ring" data-slot="0" cx="60" cy="60" r="0"></circle>
<circle class="pulse__ring" data-slot="1" cx="60" cy="60" r="0"></circle>
<circle class="pulse__ring" data-slot="2" cx="60" cy="60" r="0"></circle>
<circle class="pulse__ring" data-slot="3" cx="60" cy="60" r="0"></circle>
<circle class="pulse__core" cx="60" cy="60" r="14"></circle>
<path class="pulse__mark" d="M52,64 A8 8 0 0 1 68,64" />
</svg>
<span class="pulse__label">인증 대기</span>
</button>
<p class="pulse__status" id="status">인증 대기 중</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 index.html 의 tick() 에 있다 — 펄스마다 뜬 뒤 지난 시간(age)에 지수 감쇠식을 걸어
// 반지름·불투명도를 매 프레임 다시 계산한다. 여기는 무대·모양만 맡는다.
@include stage($stage-paper);
.pulse-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.pulse {
appearance: none;
border: none;
background: transparent;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
cursor: pointer;
padding: $sp-4;
border-radius: $r-card;
&:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 4px; }
}
.pulse__icon {
width: clamp(120px, 30vw, 160px);
height: auto;
overflow: visible;
}
.pulse__ring {
fill: none;
stroke: $subject-blue-light;
stroke-width: 2;
opacity: 0;
}
.pulse__core {
fill: $subject-blue-deep;
}
.pulse__mark {
fill: none;
stroke: $subject-cream;
stroke-width: 2;
stroke-linecap: round;
}
.pulse__label {
color: $stage-ink;
font-weight: 600;
font-size: 13px;
line-height: 1.2;
letter-spacing: .02em;
}
.pulse__status {
margin: 0;
color: rgba($stage-ink, .58);
font-weight: 500;
font-size: 12px;
line-height: 1.2;
}
@media (max-width: 340px) {
.stage { padding: $sp-2; }
.pulse-wrap { gap: $sp-2; }
.pulse { padding: $sp-2 $sp-3; gap: $sp-2; }
.pulse__icon { width: 104px; }
.pulse__label { font-size: 12px; }
.pulse__status { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.pulse__ring { transition: none; }
}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);
}
.pulse-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.pulse {
appearance: none;
border: none;
background: transparent;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
cursor: pointer;
padding: 16px;
border-radius: 16px;
}
.pulse:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 4px;
}
.pulse__icon {
width: clamp(120px, 30vw, 160px);
height: auto;
overflow: visible;
}
.pulse__ring {
fill: none;
stroke: #5b8cf8;
stroke-width: 2;
opacity: 0;
}
.pulse__core {
fill: #0a4fe8;
}
.pulse__mark {
fill: none;
stroke: #fff7e6;
stroke-width: 2;
stroke-linecap: round;
}
.pulse__label {
color: #17141a;
font-weight: 600;
font-size: 13px;
line-height: 1.2;
letter-spacing: 0.02em;
}
.pulse__status {
margin: 0;
color: rgba(23, 20, 26, 0.58);
font-weight: 500;
font-size: 12px;
line-height: 1.2;
}
@media (max-width: 340px) {
.stage {
padding: 8px;
}
.pulse-wrap {
gap: 8px;
}
.pulse {
padding: 8px 12px;
gap: 8px;
}
.pulse__icon {
width: 104px;
}
.pulse__label {
font-size: 12px;
}
.pulse__status {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.pulse__ring {
transition: none;
}
}(function () {
// 계산 겹: 감쇠 함수 — 새 펄스가 뜬 뒤 지난 시간(age)에 지수 감쇠식을 걸어
// 반지름은 PULSE_MAX_R 로 점근하고(1 - e^(-age/TAU)), 밝기는 그 반지름 비율만큼 옅어진다.
// 트윈(펄스 개수·타이밍)이 아니라 매 프레임 이 식을 다시 계산해서 그린다.
var PULSE_MAX_R = 40; // 변형 02: PULSE_MAX_R 40 → 70 (펄스 최대 반경)
var TAU = 260; // 감쇠 시간 상수(ms) — 클수록 천천히 옅어진다
var LAUNCH_GAP = 550; // 새 펄스를 띄우는 간격(ms)
var CUTOFF = TAU * 4.4; // 이 age 를 넘으면 슬롯을 반납한다(반지름이 사실상 MAX_R 에 도달)
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var root = document.getElementById('pulse');
var status = document.getElementById('status');
var rings = Array.prototype.slice.call(document.querySelectorAll('.pulse__ring'));
var STRINGS = { listening: '인증 대기 중', paused: '일시 정지됨' };
var isDemo = true;
var listening = true;
var slots = rings.map(function (el) { return { el: el, start: null }; });
var slotCursor = 0;
var lastLaunch = 0;
function spawn(now) {
var slot = slots[slotCursor % slots.length];
slotCursor++;
slot.start = now;
}
function tick(now) {
for (var i = 0; i < slots.length; i++) {
var slot = slots[i];
if (slot.start === null) { continue; }
var age = now - slot.start;
if (age > CUTOFF) {
slot.start = null;
slot.el.style.opacity = '0';
continue;
}
var radius = PULSE_MAX_R * (1 - Math.exp(-age / TAU));
var opacity = Math.max(0, 1 - radius / PULSE_MAX_R);
slot.el.setAttribute('r', radius.toFixed(2));
slot.el.style.opacity = opacity.toFixed(3);
}
}
function human() { if (isDemo) { root.classList.remove('is-demo'); isDemo = false; } }
function toggle() {
listening = !listening;
root.setAttribute('aria-pressed', String(listening));
status.textContent = listening ? STRINGS.listening : STRINGS.paused;
}
root.addEventListener('click', function () { human(); toggle(); });
root.addEventListener('keydown', function (e) {
if (e.key === ' ' || e.key === 'Enter') { human(); }
});
function loop(ts) {
var active = listening && (isDemo ? !reduce : true);
if (active && ts - lastLaunch >= LAUNCH_GAP) {
lastLaunch = ts;
spawn(ts);
}
tick(ts);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// RadialPulse — vanilla 판(run/371/demo/02-biometric-scan-radial-pulse/index.html) 의 React 포트.
// 계산 겹(감쇠 함수)은 useEffect 안에 있다: 펄스가 뜬 뒤 지난 시간(age)에 지수 감쇠식을 걸어
// 반지름은 PULSE_MAX_R 로 점근하고, 불투명도는 그 반지름 비율만큼 옅어진다.
import { useEffect, useRef, useState } from "react";
import styles from "./RadialPulse.module.scss";
type Box<T> = { current: T };
const STRINGS: Record<string, string> = { listening: "인증 대기 중", paused: "일시 정지됨" };
export default function RadialPulse() {
const rootRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const ringRefs = useRef([]) as unknown as Box<SVGCircleElement[]>;
const [status, setStatus] = useState(STRINGS.listening);
const [pressed, setPressed] = useState(true);
useEffect(() => {
const PULSE_MAX_R = 40; // 변형 02: PULSE_MAX_R 40 → 70
const TAU = 260;
const LAUNCH_GAP = 550;
const CUTOFF = TAU * 4.4;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const root = rootRef.current;
const rings = ringRefs.current;
if (!root || rings.length === 0) return;
let isDemo = true;
let listening = true;
let slotCursor = 0;
let lastLaunch = 0;
let rafId = 0;
const slots = rings.map((el) => ({ el, start: null as number | null }));
function spawn(now: number) {
const slot = slots[slotCursor % slots.length];
slotCursor++;
slot.start = now;
}
function tick() {
for (let i = 0; i < slots.length; i++) {
const slot = slots[i];
if (slot.start === null) continue;
const age = performance.now() - slot.start;
if (age > CUTOFF) {
slot.start = null;
slot.el.style.opacity = "0";
continue;
}
const radius = PULSE_MAX_R * (1 - Math.exp(-age / TAU));
const opacity = Math.max(0, 1 - radius / PULSE_MAX_R);
slot.el.setAttribute("r", radius.toFixed(2));
slot.el.style.opacity = opacity.toFixed(3);
}
}
function human() { if (isDemo) { root.classList.remove(styles.isDemo); isDemo = false; } }
function toggle() {
listening = !listening;
setPressed(listening);
setStatus(listening ? STRINGS.listening : STRINGS.paused);
}
function onClick() { human(); toggle(); }
function onKeyDown(e: KeyboardEvent) { if (e.key === " " || e.key === "Enter") human(); }
root.addEventListener("click", onClick);
root.addEventListener("keydown", onKeyDown);
function loop(ts: number) {
const active = listening && (isDemo ? !reduce : true);
if (active && ts - lastLaunch >= LAUNCH_GAP) {
lastLaunch = ts;
spawn(ts);
}
tick();
rafId = requestAnimationFrame(loop);
}
rafId = requestAnimationFrame(loop);
return () => {
root.removeEventListener("click", onClick);
root.removeEventListener("keydown", onKeyDown);
cancelAnimationFrame(rafId);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.pulseWrap}>
<button
type="button"
className={styles.pulse + " " + styles.isDemo}
ref={rootRef}
aria-pressed={pressed}
>
<svg className={styles.pulseIcon} viewBox="0 0 120 120" aria-hidden="true">
{[0, 1, 2, 3].map((i) => (
<circle
key={i}
className={styles.pulseRing}
cx={60}
cy={60}
r={0}
ref={(el: SVGCircleElement | null) => { if (el) ringRefs.current[i] = el; }}
/>
))}
<circle className={styles.pulseCore} cx={60} cy={60} r={14} />
<path className={styles.pulseMark} d="M52,64 A8 8 0 0 1 68,64" />
</svg>
<span className={styles.pulseLabel}>인증 대기</span>
</button>
<p className={styles.pulseStatus}>{status}</p>
</div>
</main>
);
}02 대기 파동 — 인증 대기 상태에서 원형 파동의 반경 값이 감쇠 함수로 점점 커지며 옅어지는 방식 — 측정 누적면적 3.397% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 지문 인식 애니메이션 9종 — 스캔 진행률 값으로 갈리는 인증 버튼
이 데모가 속한 글
지문 인식 애니메이션 9종 — 스캔 진행률 값으로 갈리는 인증 버튼아홉 가지 지문 인식 애니메이션 중 하나는 스캔 진행률 값이 지문 능선을 순서대로 밝히고, 하나는 실패 시 스프링 감쇠 값으로 흔들리다 멈추며, 하나는 압력 값이 커질수록 리플 반경과 진폭 값이 함께 커집니다. 이 글의 데모 전부 보기

