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 オリジナル · SCSS と React は zip に同梱 · 指紋認証 アニメーション 9選 — スキャン進捗値で変わる生体認証ボタン
このデモが属する記事
指紋認証 アニメーション 9選 — スキャン進捗値で変わる生体認証ボタン9つの指紋認証 アニメーションのうち、1つはスキャン進捗の値が指紋の稜線を順に光らせ、1つは失敗時にスプリング減衰の値で揺れて止まり、1つは押す圧力の値が増すほどリップルの半径と振幅の値が共に大きくなります。 この記事のデモをすべて見る

