指紋認証 アニメーション 9選 — スキャン進捗値で変わる生体認証ボタン
9つの指紋認証 アニメーションのうち、1つはスキャン進捗の値が指紋の稜線を順に光らせ、1つは失敗時にスプリング減衰の値で揺れて止まり、1つは押す圧力の値が増すほどリップルの半径と振幅の値が共に大きくなります。
01 biometric-scan-sweep-line
02 biometric-scan-radial-pulse
03 biometric-scan-fail-shake
04 biometric-scan-success-morph
05 biometric-scan-pressure-ripple
06 biometric-scan-hologram-flicker
07 biometric-scan-ring-overshoot
08 biometric-scan-grid-glow
09 biometric-scan-idle-breathing
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 スキャンラインスイープ — スキャン進捗の値が上から下へ移動し、通過した位置の指紋稜線の明るさの値が順に上がる方式
- 02 待機パルス — 認証待機状態で円形パルスの半径の値が減衰関数で徐々に大きくなりながら薄くなる方式
- 03 失敗振動の減衰 — 認証失敗時にアイコンがスプリング減衰の値に沿って左右に揺れ、値が0に近づくと止まる方式
- 04 成功モーフィング — 認証に成功すると指紋アイコンのパスの値がチェックマークのパスの値へ滑らかに補間される方式
- 05 圧力リップル — スキャン面を押す圧力の値が増すほど同心円リップルの半径と振幅の値が共に大きくなる方式
- 06 ホログラムフリッカー — 2Dノイズの値を基にスキャンアイコンの明滅強度の値が微細に揺れる方式
- 07 リングオーバーシュート — 円形プログレスリングが角度の値で満ちていき、完了の瞬間に慣性の値の分だけ少し行き過ぎてから戻る方式
- 08 グリッドスキャングロー — スキャン領域をグリッドに分け、セルごとに異なる遅延の値で順に光る方式
- 09 待機呼吸 — 入力がない間アイコンの大きさの値がサインイージング曲線に沿って大きくなったり小さくなったりを繰り返す方式
この9つのうちどれも、実際の指紋センサーAPIや顔認証APIを呼び出してはいません。すべてはひとつの数値が動かすシミュレーションで、進捗の割合、経過age、揺れのオフセット、モーフィングの補間値、押す圧力、ノイズのサンプル値、スプリングの位置、対角線の遅延、呼吸の位相のどれかひとつを見ればその場面の仕組みがわかります。この指紋認証 ボタンの9パターンを選ぶとき、見た目の印象だけで選ぶより、どの数値が認証状態に反応しているかを先に知っておくとコードを読む時間が短くなります。
9つのパターンの解剖 — どの値が認証状態に反応するか
01のapply()はスキャンラインのscanY位置を稜線ごとの活性化比率(scanY - apex) / GLOW_BANDに変換し、各稜線は自分の頂点(apex)をラインが通過したあとだけ不透明度が上がります。02のtick()は開いているリングスロットのage(経過時間)を毎フレーム進め、半径を1 - Math.exp(-age / TAU)という指数関数でPULSE_MAX_Rへ近づけます — 直線的な増加ではなく、最初は速く後半は緩やかになる近似です。03のapply()は減衰する指数関数とコサインを掛け合わせたAMPLITUDE * Math.exp(-t / decayMs) * Math.cos(t * FREQ)そのものが揺れのオフセットで、CSSキーフレームの代わりにこの式を毎フレーム計算しています。04のbuildPath()は10個の点をcirclePoint()の座標と手作業で配置したCHECK_PTS配列のあいだで単一の進捗値によって補間し、SVGのd文字列を毎フレーム作り直します。05のbuildPath()はBASE_R + AMPLITUDE * pressure * Math.sin(WAVE_K * theta - t * WAVE_SPEED)という式でサイン波のリングをサンプリングし、実際に指で押しているあいだはpressureがPointerEvent.pressureからそのまま入ります。06のnoise()はノイズ場をサンプリングするのではなく、周波数の異なる3つのサイン波を重み付けして合計しているだけの決定的な計算です。07のautoLoop()は毎フレームスプリングを積分し、force = (target - autoPos) * STIFFNESS - autoVel * DAMPINGという式でtargetをFLIP_MSごとに2つの値のあいだで切り替えるため、リングは完全には静止しません。08のrender()は各グリッドセルの行と列の合計から遅延delay = cells[i].diag * DIAG_STEPを計算し、それをMath.sinのパルス窓に渡します。09のrender()はscale = 1 + AMPLITUDE * Math.sin(t * OMEGA)という単一の値でアイコン全体を拡大縮小しながら、稜線ごとにRIDGE_PHASE_STEPだけ位相をずらして明るさの波が外側へ広がって見えるようにします。9つのうち8つ — 05を除く全て — はclickとkeydownで実際の訪問者を検知し、05だけはkeydownを残しつつclickの代わりにpointerdown・pointermove・pointerupを使います。連続的な圧力の値を読む必要があるからです。どの場面でも最初の実際の操作でhuman()が呼ばれ、.is-demoクラスが外れてオートプレイのループが止まります(01・03・04・05・07はisDemo && !reduce、02・06はリスナーの引数に組み込んだ同等の判定、08・09はsetActive()が同じ役目を果たします)。オートプレイのトリガーも各場面ごとの毎フレーム計算も、すべてrequestAnimationFrameで駆動されており、Math.randomは9つのファイルのどこでも実行されません。9つともmatchMediaで一度だけ判定する独自のprefers-reduced-motion: reduce分岐をJS側に持ち、Sass側にもtransition: noneのブロックを備えていますが、その分岐が実際に何をするか——タイマーを飛ばすのか、時間を圧縮するのか、静止した1フレームを描くのか——は項目によって異なり、以下の各項目で確認できます。
パターン別コード
01スキャンラインスイープ — スキャン進捗の値が上から下へ移動し、通過した位置の指紋稜線の明るさの値が順に上がる方式
スキャン進捗の値はBASE_MS / SWEEP_SPEEDミリ秒かけてアイコンの上から下まで移動し、その値が各稜線自身の頂点(apex)を通過した瞬間から、その稜線の明るさがGLOW_BANDの距離のなかで満opacityへ向かって上がります。稜線ごとに自分の頂点だけを見て判定するため、上にある稜線ほど早く明るくなり終わり、下の稜線はまだ動いていない——これが「順番に光る」ように見える理由です。ログイン画面の生体認証待機ボタンやアプリのロック解除画面に合う方式です。

<main class="stage">
<div class="scan-wrap">
<button type="button" class="scan is-demo" id="scan" aria-pressed="false">
<svg class="scan__icon" viewBox="0 0 120 130" aria-hidden="true">
<path class="scan__ridge" data-apex="72" d="M10,118 A50 46 0 0 1 110,118"></path>
<path class="scan__ridge" data-apex="77" d="M16,118 A44 41 0 0 1 104,118"></path>
<path class="scan__ridge" data-apex="82" d="M22,118 A38 36 0 0 1 98,118"></path>
<path class="scan__ridge" data-apex="87" d="M28,118 A32 31 0 0 1 92,118"></path>
<path class="scan__ridge" data-apex="92" d="M34,118 A26 26 0 0 1 86,118"></path>
<path class="scan__ridge" data-apex="97" d="M40,118 A20 21 0 0 1 80,118"></path>
<path class="scan__ridge" data-apex="102" d="M46,118 A14 16 0 0 1 74,118"></path>
<line class="scan__beam" id="beam" x1="6" x2="114" y1="6" y2="6"></line>
</svg>
<span class="scan__label">指紋スキャン</span>
</button>
<p class="scan__status" id="status">待機中</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 index.html 의 apply() 에 있다 — 능선마다 정점(apex) y 와 스캔 라인 y 의 차를 GLOW_BAND 로
// 나눈 활성 값(act)을 opacity 에 매 프레임 반영한다. 여기는 무대·모양만 맡는다.
@include stage($stage-ink);
.scan-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.scan {
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; }
}
.scan__icon {
width: clamp(120px, 30vw, 160px);
height: auto;
overflow: visible;
}
.scan__ridge {
fill: none;
stroke: $subject-blue-light;
stroke-width: 3;
stroke-linecap: round;
opacity: .26;
}
.scan__beam {
stroke: $subject-blue-light;
stroke-width: 2;
filter: drop-shadow(0 0 6px rgba($subject-blue-light, .9));
}
.scan__label {
color: $subject-cream;
font-weight: 600;
font-size: 13px;
line-height: 1.2;
letter-spacing: .02em;
}
.scan__status {
margin: 0;
color: rgba($subject-cream, .58);
font-weight: 500;
font-size: 12px;
line-height: 1.2;
}
@media (max-width: 340px) {
.stage { padding: $sp-2; }
.scan-wrap { gap: $sp-2; }
.scan { padding: $sp-2 $sp-3; gap: $sp-2; }
.scan__icon { width: 104px; }
.scan__label { font-size: 12px; }
.scan__status { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.scan__ridge, .scan__beam { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.scan-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.scan {
appearance: none;
border: none;
background: transparent;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
cursor: pointer;
padding: 16px;
border-radius: 16px;
}
.scan:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 4px;
}
.scan__icon {
width: clamp(120px, 30vw, 160px);
height: auto;
overflow: visible;
}
.scan__ridge {
fill: none;
stroke: #5b8cf8;
stroke-width: 3;
stroke-linecap: round;
opacity: 0.26;
}
.scan__beam {
stroke: #5b8cf8;
stroke-width: 2;
filter: drop-shadow(0 0 6px rgba(91, 140, 248, 0.9));
}
.scan__label {
color: #fff7e6;
font-weight: 600;
font-size: 13px;
line-height: 1.2;
letter-spacing: 0.02em;
}
.scan__status {
margin: 0;
color: rgba(255, 247, 230, 0.58);
font-weight: 500;
font-size: 12px;
line-height: 1.2;
}
@media (max-width: 340px) {
.stage {
padding: 8px;
}
.scan-wrap {
gap: 8px;
}
.scan {
padding: 8px 12px;
gap: 8px;
}
.scan__icon {
width: 104px;
}
.scan__label {
font-size: 12px;
}
.scan__status {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.scan__ridge, .scan__beam {
transition: none;
}
}(function () {
// 계산 겹: 능선 stagger 순서 계산 — 능선마다 정점(apex) y 값이 다르고, 스캔 라인 y 값이 그 정점을
// 지난 만큼(GLOW_BAND 로 나눈 비율)을 활성 값(act, 0~1)으로 써서 밝기를 매긴다. 정점이 위(작은 y)일수록
// 먼저 활성되므로 "능선이 순서대로 밝아지는" 흐름이 실제 계산으로 나온다.
var SWEEP_SPEED = 1.4; // 변형 01: SWEEP_SPEED 1.4 → 0.6 (스윕 속도)
var BASE_MS = 1000; // 기준 스윕 시간 — 실제 소요는 BASE_MS / SWEEP_SPEED
var GLOW_BAND = 16; // 스캔 라인이 능선 정점을 지난 뒤 완전히 밝아지기까지의 svg 단위 거리
var HOLD_MS = 500; // スキャン完了 뒤 전체 밝기를 유지하는 시간
var GAP_MS = 700; // 다음 자동 스캔까지 대기하는 시간
var TOP = 6, BOTTOM = 124;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var DUR = reduce ? 1 : (BASE_MS / SWEEP_SPEED);
var root = document.getElementById('scan');
var beam = document.getElementById('beam');
var ridges = Array.prototype.slice.call(document.querySelectorAll('.scan__ridge'));
var status = document.getElementById('status');
var STRINGS = { idle: '待機中', scanning: 'スキャン中', done: 'スキャン完了' };
var isDemo = true;
var running = false;
var startTs = 0;
function apply(scanY) {
beam.setAttribute('y1', scanY.toFixed(2));
beam.setAttribute('y2', scanY.toFixed(2));
for (var i = 0; i < ridges.length; i++) {
var apex = parseFloat(ridges[i].getAttribute('data-apex'));
var act = Math.max(0, Math.min(1, (scanY - apex) / GLOW_BAND + 0.001));
ridges[i].style.opacity = (0.26 + 0.74 * act).toFixed(3);
}
}
function runScan(ts) {
if (!startTs) startTs = ts;
var t = ts - startTs;
if (t <= DUR) {
apply(TOP + (BOTTOM - TOP) * (t / DUR));
status.textContent = STRINGS.scanning;
requestAnimationFrame(runScan);
} else if (t <= DUR + HOLD_MS) {
apply(BOTTOM);
status.textContent = STRINGS.done;
requestAnimationFrame(runScan);
} else {
apply(TOP);
status.textContent = STRINGS.idle;
running = false;
root.setAttribute('aria-pressed', 'false');
}
}
function trigger() {
if (running) return;
running = true;
startTs = 0;
root.setAttribute('aria-pressed', 'true');
requestAnimationFrame(runScan);
}
function human() { if (isDemo) { root.classList.remove('is-demo'); isDemo = false; } }
root.addEventListener('click', function () { human(); trigger(); });
root.addEventListener('keydown', function (e) {
if (e.key === ' ' || e.key === 'Enter') { human(); }
});
apply(TOP);
var cycleStart = 0;
var PERIOD = DUR + HOLD_MS + GAP_MS;
function loop(ts) {
if (isDemo && !reduce) {
if (!cycleStart) cycleStart = ts;
var phase = (ts - cycleStart) % PERIOD;
if (phase < 16 && !running) trigger();
}
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// ScanSweepLine — vanilla 판(run/371/demo/01-biometric-scan-sweep-line/index.html) 의 React 포트.
// 계산 겹(능선 stagger 순서 계산)은 useScanSweep 훅 안에 있다: 능선 정점(apex) y 와 스캔 라인 y 의
// 차를 GLOW_BAND 로 나눈 활성 값(act)을 매 프레임 svg style.opacity 에 반영한다.
import { useEffect, useRef, useState } from "react";
import styles from "./ScanSweepLine.module.scss";
type Box<T> = { current: T };
const RIDGES = [
{ apex: 72, d: "M10,118 A50 46 0 0 1 110,118" },
{ apex: 77, d: "M16,118 A44 41 0 0 1 104,118" },
{ apex: 82, d: "M22,118 A38 36 0 0 1 98,118" },
{ apex: 87, d: "M28,118 A32 31 0 0 1 92,118" },
{ apex: 92, d: "M34,118 A26 26 0 0 1 86,118" },
{ apex: 97, d: "M40,118 A20 21 0 0 1 80,118" },
{ apex: 102, d: "M46,118 A14 16 0 0 1 74,118" },
];
const STRINGS: Record<string, string> = { idle: "대기 중", scanning: "스캔 중", done: "스캔 완료" };
function useScanSweep(
rootRef: Box<HTMLButtonElement>,
beamRef: Box<SVGLineElement>,
ridgeRefs: Box<SVGPathElement[]>,
setStatus: (s: string) => void,
setPressed: (b: boolean) => void
) {
useEffect(() => {
const SWEEP_SPEED = 1.4; // 변형 01: SWEEP_SPEED 1.4 → 0.6
const BASE_MS = 1000;
const GLOW_BAND = 16;
const HOLD_MS = 500;
const GAP_MS = 700;
const TOP = 6, BOTTOM = 124;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const DUR = reduce ? 1 : BASE_MS / SWEEP_SPEED;
const root = rootRef.current;
const beam = beamRef.current;
const ridges = ridgeRefs.current;
if (!root || !beam || ridges.length === 0) return;
let isDemo = true;
let running = false;
let startTs = 0;
let rafId = 0;
function apply(scanY: number) {
beam.setAttribute("y1", scanY.toFixed(2));
beam.setAttribute("y2", scanY.toFixed(2));
for (let i = 0; i < ridges.length; i++) {
const apex = RIDGES[i].apex;
const act = Math.max(0, Math.min(1, (scanY - apex) / GLOW_BAND + 0.001));
ridges[i].style.opacity = (0.26 + 0.74 * act).toFixed(3);
}
}
function runScan(ts: number) {
if (!startTs) startTs = ts;
const t = ts - startTs;
if (t <= DUR) {
apply(TOP + (BOTTOM - TOP) * (t / DUR));
setStatus(STRINGS.scanning);
rafId = requestAnimationFrame(runScan);
} else if (t <= DUR + HOLD_MS) {
apply(BOTTOM);
setStatus(STRINGS.done);
rafId = requestAnimationFrame(runScan);
} else {
apply(TOP);
setStatus(STRINGS.idle);
running = false;
setPressed(false);
}
}
function trigger() {
if (running) return;
running = true;
startTs = 0;
setPressed(true);
rafId = requestAnimationFrame(runScan);
}
function human() { if (isDemo) { root.classList.remove(styles.isDemo); isDemo = false; } }
function onClick() { human(); trigger(); }
function onKeyDown(e: KeyboardEvent) { if (e.key === " " || e.key === "Enter") human(); }
root.addEventListener("click", onClick);
root.addEventListener("keydown", onKeyDown);
apply(TOP);
let cycleStart = 0;
const PERIOD = DUR + HOLD_MS + GAP_MS;
function loop(ts: number) {
if (isDemo && !reduce) {
if (!cycleStart) cycleStart = ts;
const phase = (ts - cycleStart) % PERIOD;
if (phase < 16 && !running) trigger();
}
rafId = requestAnimationFrame(loop);
}
rafId = requestAnimationFrame(loop);
return () => {
root.removeEventListener("click", onClick);
root.removeEventListener("keydown", onKeyDown);
cancelAnimationFrame(rafId);
};
}, []);
}
export default function ScanSweepLine() {
const rootRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const beamRef = useRef(null) as unknown as Box<SVGLineElement>;
const ridgeRefs = useRef([]) as unknown as Box<SVGPathElement[]>;
const [status, setStatus] = useState(STRINGS.idle);
const [pressed, setPressed] = useState(false);
useScanSweep(rootRef, beamRef, ridgeRefs, setStatus, setPressed);
return (
<main className={styles.stage}>
<div className={styles.scanWrap}>
<button
type="button"
className={styles.scan + " " + styles.isDemo}
ref={rootRef}
aria-pressed={pressed}
>
<svg className={styles.scanIcon} viewBox="0 0 120 130" aria-hidden="true">
{RIDGES.map((r, i) => (
<path
key={i}
className={styles.scanRidge}
d={r.d}
ref={(el: SVGPathElement | null) => { if (el) ridgeRefs.current[i] = el; }}
/>
))}
<line className={styles.scanBeam} ref={beamRef} x1={6} x2={114} y1={6} y2={6} />
</svg>
<span className={styles.scanLabel}>지문 스캔</span>
</button>
<p className={styles.scanStatus}>{status}</p>
</div>
</main>
);
}01 · スキャンラインスイープ — スキャン進捗の値が上から下へ移動し、通過した位置の指紋稜線の明るさの値が順に上がる方式 — 計測 累積面積 12.571% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function apply(scanY) {
beam.setAttribute('y1', scanY.toFixed(2));
beam.setAttribute('y2', scanY.toFixed(2));
for (var i = 0; i < ridges.length; i++) {
var apex = parseFloat(ridges[i].getAttribute('data-apex'));
var act = Math.max(0, Math.min(1, (scanY - apex) / GLOW_BAND + 0.001));
ridges[i].style.opacity = (0.26 + 0.74 * act).toFixed(3);
}
}
数値が語ること
01は累積面積12.57%・強度平均63.8のまま、23フレーム中12フレームでしか動きが記録されませんでした。9項目の中で累積面積は最大ですが、動いたフレーム数は最少です。スイープは自分のトリガーサイクルのあいだしか走らず、GAP_MSの待機時間はほぼ完全に静止しているためで、動くフレームでは一本のビームと最大7本の稜線が同時に塗り替わります。
02待機パルス — 認証待機状態で円形パルスの半径の値が減衰関数で徐々に大きくなりながら薄くなる方式
LAUNCH_GAPミリ秒ごとに新しいリングスロットが開き、そのスロットの半径の値は直線ではなく指数減衰の曲線に沿ってPULSE_MAX_Rへ近づいていきます——最初は速く、最大値に近づくほど緩やかになります。半径の値が大きくなるのと同時に、同じスロットの不透明度の値は逆方向へ下がっていくため、リングは生まれた瞬間が最も明るく、PULSE_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 に同梱 · パスワードを見る
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);
}
}
数値が語ること
02は累積面積3.40%・強度平均36.1のまま、23フレーム中19フレームで動きが記録されました。一度に存在するリングスロットは4つだけで、成長しながら不透明度が0へ向かって薄くなるため、ループ自体は止まらなくても塗り替わる面積は小さく保たれます。
03失敗振動の減衰 — 認証失敗時にアイコンがスプリング減衰の値に沿って左右に揺れ、値が0に近づくと止まる方式
認証が失敗した瞬間、揺れのオフセットの値はAMPLITUDE * Math.exp(-t / decayMs) * Math.cos(t * FREQ)という式で最大振幅から減衰を始め、最初の一瞬でもっとも大きく揺れてから滑らかに0へ収まります。SHAKE_DAMPはdecayMs(=SHAKE_DAMP * 40)を直接決めるため、この値が小さいほど揺れが0に収まるまでの時間は短くなります。生体認証失敗のフィードバックやロック解除失敗画面に合う方式です。

<main class="stage">
<div class="fail-wrap">
<button type="button" class="fail is-demo" id="fail" aria-pressed="false">
<svg class="fail__icon" id="icon" viewBox="0 0 120 120" aria-hidden="true">
<circle class="fail__ring" cx="60" cy="60" r="34"></circle>
<path class="fail__mark" id="mark" d="M46,46 L74,74 M74,46 L46,74"></path>
</svg>
<span class="fail__label">指紋認証</span>
</button>
<p class="fail__status" id="status">もう一度お試しください</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 index.html 의 apply() 에 있다 — 감쇠 스프링 진동식(지수 감쇠 × 코사인)을 매 프레임
// 다시 계산해 아이콘 translateX 오프셋에 쓴다. 여기는 무대·모양만 맡는다.
@include stage($stage-ink);
.fail-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.fail {
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 $error; outline-offset: 4px; }
}
.fail__icon {
width: clamp(120px, 30vw, 160px);
height: auto;
overflow: visible;
}
.fail__ring {
fill: none;
stroke: $error;
stroke-width: 3;
opacity: .8;
}
.fail__mark {
fill: none;
stroke: $error-soft;
stroke-width: 4;
stroke-linecap: round;
}
.fail__label {
color: $subject-cream;
font-weight: 600;
font-size: 13px;
line-height: 1.2;
letter-spacing: .02em;
}
.fail__status {
margin: 0;
color: rgba($subject-cream, .58);
font-weight: 500;
font-size: 12px;
line-height: 1.2;
}
@media (max-width: 340px) {
.stage { padding: $sp-2; }
.fail-wrap { gap: $sp-2; }
.fail { padding: $sp-2 $sp-3; gap: $sp-2; }
.fail__icon { width: 104px; }
.fail__label { font-size: 12px; }
.fail__status { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.fail__icon { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.fail-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.fail {
appearance: none;
border: none;
background: transparent;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
cursor: pointer;
padding: 16px;
border-radius: 16px;
}
.fail:focus-visible {
outline: 2px solid #c2361a;
outline-offset: 4px;
}
.fail__icon {
width: clamp(120px, 30vw, 160px);
height: auto;
overflow: visible;
}
.fail__ring {
fill: none;
stroke: #c2361a;
stroke-width: 3;
opacity: 0.8;
}
.fail__mark {
fill: none;
stroke: #ffe0d8;
stroke-width: 4;
stroke-linecap: round;
}
.fail__label {
color: #fff7e6;
font-weight: 600;
font-size: 13px;
line-height: 1.2;
letter-spacing: 0.02em;
}
.fail__status {
margin: 0;
color: rgba(255, 247, 230, 0.58);
font-weight: 500;
font-size: 12px;
line-height: 1.2;
}
@media (max-width: 340px) {
.stage {
padding: 8px;
}
.fail-wrap {
gap: 8px;
}
.fail {
padding: 8px 12px;
gap: 8px;
}
.fail__icon {
width: 104px;
}
.fail__label {
font-size: 12px;
}
.fail__status {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.fail__icon {
transition: none;
}
}(function () {
// 계산 겹: 감쇠 스프링 진동 — x(t) = AMPLITUDE * e^(-t/decayMs) * cos(t*FREQ). 트윈 이징이 아니라
// 매 프레임 이 식으로 오프셋을 계산해 아이콘을 흔든다. SHAKE_DAMP 가 클수록 decayMs 가 커져
// 진동이 오래 남고, 작을수록 빨리 잦아든다.
var AMPLITUDE = 14; // 최대 흔들림 폭(px)
var FREQ = 0.028; // 각속도(rad/ms)
var SHAKE_DAMP = 18; // 변형 03: SHAKE_DAMP 18 → 8 (댐핑 — 작을수록 빨리 잦아든다)
var SETTLE_MS = 900; // 이 시간이 지나면 진동을 멈추고 0 으로 고정한다
var GAP_MS = 900; // 다음 자동 실패 시연까지 대기 시간
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var decayMs = reduce ? 1 : SHAKE_DAMP * 40;
var root = document.getElementById('fail');
var icon = document.getElementById('icon');
var status = document.getElementById('status');
var STRINGS = { idle: 'もう一度お試しください', failed: '認証失敗' };
var isDemo = true;
var running = false;
var startTs = 0;
function apply(t) {
var offset = AMPLITUDE * Math.exp(-t / decayMs) * Math.cos(t * FREQ);
icon.style.transform = 'translateX(' + offset.toFixed(2) + 'px)';
}
function runShake(ts) {
if (!startTs) startTs = ts;
var t = ts - startTs;
if (t <= SETTLE_MS) {
apply(t);
requestAnimationFrame(runShake);
} else {
apply(0);
status.textContent = STRINGS.idle;
running = false;
root.setAttribute('aria-pressed', 'false');
}
}
function trigger() {
if (running) return;
running = true;
startTs = 0;
status.textContent = STRINGS.failed;
root.setAttribute('aria-pressed', 'true');
requestAnimationFrame(runShake);
}
function human() { if (isDemo) { root.classList.remove('is-demo'); isDemo = false; } }
root.addEventListener('click', function () { human(); trigger(); });
root.addEventListener('keydown', function (e) {
if (e.key === ' ' || e.key === 'Enter') { human(); }
});
apply(0);
var cycleStart = 0;
var PERIOD = SETTLE_MS + GAP_MS;
function loop(ts) {
if (isDemo && !reduce) {
if (!cycleStart) cycleStart = ts;
var phase = (ts - cycleStart) % PERIOD;
if (phase < 16 && !running) trigger();
}
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// FailShake — vanilla 판(run/371/demo/03-biometric-scan-fail-shake/index.html) 의 React 포트.
// 계산 겹(감쇠 스프링 진동)은 useFailShake 훅 안에 있다: x(t) = AMPLITUDE * e^(-t/decayMs) * cos(t*FREQ)
// 을 매 프레임 다시 계산해 아이콘 translateX 오프셋에 쓴다.
import { useEffect, useRef, useState } from "react";
import styles from "./FailShake.module.scss";
type Box<T> = { current: T };
const STRINGS: Record<string, string> = { idle: "다시 시도해 주세요", failed: "인증 실패" };
function useFailShake(
rootRef: Box<HTMLButtonElement>,
iconRef: Box<SVGSVGElement>,
setStatus: (s: string) => void,
setPressed: (b: boolean) => void
) {
useEffect(() => {
const AMPLITUDE = 14;
const FREQ = 0.028;
const SHAKE_DAMP = 18; // 변형 03: SHAKE_DAMP 18 → 8
const SETTLE_MS = 900;
const GAP_MS = 900;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const decayMs = reduce ? 1 : SHAKE_DAMP * 40;
const root = rootRef.current;
const icon = iconRef.current;
if (!root || !icon) return;
let isDemo = true;
let running = false;
let startTs = 0;
let rafId = 0;
function apply(t: number) {
const offset = AMPLITUDE * Math.exp(-t / decayMs) * Math.cos(t * FREQ);
icon.style.transform = "translateX(" + offset.toFixed(2) + "px)";
}
function runShake(ts: number) {
if (!startTs) startTs = ts;
const t = ts - startTs;
if (t <= SETTLE_MS) {
apply(t);
rafId = requestAnimationFrame(runShake);
} else {
apply(0);
setStatus(STRINGS.idle);
running = false;
setPressed(false);
}
}
function trigger() {
if (running) return;
running = true;
startTs = 0;
setStatus(STRINGS.failed);
setPressed(true);
rafId = requestAnimationFrame(runShake);
}
function human() { if (isDemo) { root.classList.remove(styles.isDemo); isDemo = false; } }
function onClick() { human(); trigger(); }
function onKeyDown(e: KeyboardEvent) { if (e.key === " " || e.key === "Enter") human(); }
root.addEventListener("click", onClick);
root.addEventListener("keydown", onKeyDown);
apply(0);
let cycleStart = 0;
const PERIOD = SETTLE_MS + GAP_MS;
function loop(ts: number) {
if (isDemo && !reduce) {
if (!cycleStart) cycleStart = ts;
const phase = (ts - cycleStart) % PERIOD;
if (phase < 16 && !running) trigger();
}
rafId = requestAnimationFrame(loop);
}
rafId = requestAnimationFrame(loop);
return () => {
root.removeEventListener("click", onClick);
root.removeEventListener("keydown", onKeyDown);
cancelAnimationFrame(rafId);
};
}, []);
}
export default function FailShake() {
const rootRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const iconRef = useRef(null) as unknown as Box<SVGSVGElement>;
const [status, setStatus] = useState(STRINGS.idle);
const [pressed, setPressed] = useState(false);
useFailShake(rootRef, iconRef, setStatus, setPressed);
return (
<main className={styles.stage}>
<div className={styles.failWrap}>
<button
type="button"
className={styles.fail + " " + styles.isDemo}
ref={rootRef}
aria-pressed={pressed}
>
<svg className={styles.failIcon} viewBox="0 0 120 120" aria-hidden="true" ref={iconRef}>
<circle className={styles.failRing} cx={60} cy={60} r={34} />
<path className={styles.failMark} d="M46,46 L74,74 M74,46 L46,74" />
</svg>
<span className={styles.failLabel}>지문 인증</span>
</button>
<p className={styles.failStatus}>{status}</p>
</div>
</main>
);
}03 · 失敗振動の減衰 — 認証失敗時にアイコンがスプリング減衰の値に沿って左右に揺れ、値が0に近づくと止まる方式 — 計測 累積面積 5.362% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function apply(t) {
var offset = AMPLITUDE * Math.exp(-t / decayMs) * Math.cos(t * FREQ);
icon.style.transform = 'translateX(' + offset.toFixed(2) + 'px)';
}
function runShake(ts) {
if (!startTs) startTs = ts;
var t = ts - startTs;
if (t <= SETTLE_MS) {
apply(t);
requestAnimationFrame(runShake);
} else {
apply(0);
status.textContent = STRINGS.idle;
running = false;
root.setAttribute('aria-pressed', 'false');
}
}
数値が語ること
03は累積面積5.36%・強度平均132.2のまま、23フレーム中16フレームで動きが記録されました。アイコンが再描画されるのはt <= SETTLE_MSのあいだだけで、オフセットが十分0に近づくとループ自体が止まるため、塗り替わる面積はサイクル全体に均等に広がらず、振幅が大きい前半に集中します。
04成功モーフィング — 認証に成功すると指紋アイコンのパスの値がチェックマークのパスの値へ滑らかに補間される方式
認証成功の瞬間、補間値はMORPH_MSかけて0から1へ上がり、buildPath()はその同じ値を使って10個の点すべてを指紋の円形座標と手作業で配置したCHECK_PTS座標のあいだで補間し、SVGのd文字列を毎フレーム作り直します——2つの固定した形のあいだをトランジションで渡すのではありません。成功のマイクロインタラクションやアプリのロック解除確認に合う方式です。

<main class="stage">
<div class="morph-wrap">
<button type="button" class="morph is-demo" id="morph" aria-pressed="false">
<svg class="morph__icon" viewBox="0 0 120 120" aria-hidden="true">
<path class="morph__shape" id="shape"></path>
</svg>
<span class="morph__label">認証成功</span>
</button>
<p class="morph__status" id="status">スキャン待機中</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 index.html 의 buildPath() 에 있다 — 원 점 배열과 체크 점 배열을 진행값 p 로 선형
// 보간해 매 프레임 path d 를 새로 만든다. 여기는 무대·모양만 맡는다.
@include stage($stage-yellow);
.morph-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.morph {
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-deep; outline-offset: 4px; }
}
.morph__icon {
width: clamp(120px, 30vw, 160px);
height: auto;
overflow: visible;
}
.morph__shape {
fill: $subject-blue-deep;
stroke: $stage-ink;
stroke-width: 1;
stroke-linejoin: round;
}
.morph__label {
color: $stage-ink;
font-weight: 600;
font-size: 13px;
line-height: 1.2;
letter-spacing: .02em;
}
.morph__status {
margin: 0;
color: rgba($stage-ink, .6);
font-weight: 500;
font-size: 12px;
line-height: 1.2;
}
@media (max-width: 340px) {
.stage { padding: $sp-2; }
.morph-wrap { gap: $sp-2; }
.morph { padding: $sp-2 $sp-3; gap: $sp-2; }
.morph__icon { width: 104px; }
.morph__label { font-size: 12px; }
.morph__status { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.morph__shape { 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);
}
.morph-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.morph {
appearance: none;
border: none;
background: transparent;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
cursor: pointer;
padding: 16px;
border-radius: 16px;
}
.morph:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 4px;
}
.morph__icon {
width: clamp(120px, 30vw, 160px);
height: auto;
overflow: visible;
}
.morph__shape {
fill: #0a4fe8;
stroke: #17141a;
stroke-width: 1;
stroke-linejoin: round;
}
.morph__label {
color: #17141a;
font-weight: 600;
font-size: 13px;
line-height: 1.2;
letter-spacing: 0.02em;
}
.morph__status {
margin: 0;
color: rgba(23, 20, 26, 0.6);
font-weight: 500;
font-size: 12px;
line-height: 1.2;
}
@media (max-width: 340px) {
.stage {
padding: 8px;
}
.morph-wrap {
gap: 8px;
}
.morph {
padding: 8px 12px;
gap: 8px;
}
.morph__icon {
width: 104px;
}
.morph__label {
font-size: 12px;
}
.morph__status {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.morph__shape {
transition: none;
}
}(function () {
// 계산 겹: SVG path 모핑 보간 — 원(지문 자리) 점 배열과 체크 점 배열을 같은 점 개수(N)로 두고
// 진행값 p(0~1) 로 각 점을 선형 보간한 뒤 매 프레임 path d 를 새로 만든다. CSS 트랜지션이 아니라
// 점 좌표 자체를 계산한다.
var CX = 60, CY = 60, R = 30;
var N = 10; // 점 개수 — 두 도형이 같은 개수를 공유해야 보간이 성립한다
var MORPH_MS = 650; // 원 → 체크 보간에 걸리는 시간
var HOLD_MS = 550; // 체크 상태 유지 시간
var GAP_MS = 700; // 다음 자동 성공 시연까지 대기 시간
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var DUR = reduce ? 1 : MORPH_MS;
var root = document.getElementById('morph');
var shape = document.getElementById('shape');
var status = document.getElementById('status');
var STRINGS = { idle: 'スキャン待機中', done: '認証成功' };
var isDemo = true;
var running = false;
var startTs = 0;
function circlePoint(i) {
var a = (i / N) * Math.PI * 2 - Math.PI / 2;
return [CX + R * Math.cos(a), CY + R * Math.sin(a)];
}
// 체크마크 둘레를 따라 N 개 점을 손으로 배치한다 — 얇은 두 막대가 겹친 다각형
var CHECK_PTS = [
[40, 62], [52, 74], [50, 82], [36, 68], [30, 62], [36, 56],
[50, 68], [82, 36], [88, 42], [58, 78]
];
function lerp(a, b, t) { return a + (b - a) * t; }
function buildPath(p) {
var d = '';
for (var i = 0; i < N; i++) {
var c = circlePoint(i);
var k = CHECK_PTS[i];
var x = lerp(c[0], k[0], p);
var y = lerp(c[1], k[1], p);
d += (i === 0 ? 'M' : 'L') + x.toFixed(2) + ',' + y.toFixed(2) + ' ';
}
return d + 'Z';
}
function apply(p) { shape.setAttribute('d', buildPath(p)); }
function ease(t) { return 1 - Math.pow(1 - t, 3); }
function runMorph(ts) {
if (!startTs) startTs = ts;
var t = ts - startTs;
if (t <= DUR) {
apply(ease(t / DUR));
status.textContent = STRINGS.idle;
requestAnimationFrame(runMorph);
} else if (t <= DUR + HOLD_MS) {
apply(1);
status.textContent = STRINGS.done;
requestAnimationFrame(runMorph);
} else if (t <= DUR + HOLD_MS + DUR) {
apply(1 - ease((t - DUR - HOLD_MS) / DUR));
status.textContent = STRINGS.idle;
requestAnimationFrame(runMorph);
} else {
apply(0);
running = false;
root.setAttribute('aria-pressed', 'false');
}
}
function trigger() {
if (running) return;
running = true;
startTs = 0;
root.setAttribute('aria-pressed', 'true');
requestAnimationFrame(runMorph);
}
function human() { if (isDemo) { root.classList.remove('is-demo'); isDemo = false; } }
root.addEventListener('click', function () { human(); trigger(); });
root.addEventListener('keydown', function (e) {
if (e.key === ' ' || e.key === 'Enter') { human(); }
});
apply(0);
var cycleStart = 0;
var PERIOD = DUR * 2 + HOLD_MS + GAP_MS;
function loop(ts) {
if (isDemo && !reduce) {
if (!cycleStart) cycleStart = ts;
var phase = (ts - cycleStart) % PERIOD;
if (phase < 16 && !running) trigger();
}
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// SuccessMorph — vanilla 판(run/371/demo/04-biometric-scan-success-morph/index.html) 의 React 포트.
// 계산 겹(SVG path 모핑 보간)은 useEffect 안에 있다: 원 점 배열과 체크 점 배열을 같은 점 개수(N)로
// 두고 진행값 p(0~1) 로 선형 보간해 매 프레임 path d 를 새로 만든다.
import { useEffect, useRef, useState } from "react";
import styles from "./SuccessMorph.module.scss";
type Box<T> = { current: T };
const STRINGS: Record<string, string> = { idle: "스캔 대기 중", done: "인증 성공" };
const CHECK_PTS: [number, number][] = [
[40, 62], [52, 74], [50, 82], [36, 68], [30, 62], [36, 56],
[50, 68], [82, 36], [88, 42], [58, 78],
];
export default function SuccessMorph() {
const rootRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const shapeRef = useRef(null) as unknown as Box<SVGPathElement>;
const [status, setStatus] = useState(STRINGS.idle);
const [pressed, setPressed] = useState(false);
useEffect(() => {
const CX = 60, CY = 60, R = 30;
const N = 10;
const MORPH_MS = 650;
const HOLD_MS = 550;
const GAP_MS = 700;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const DUR = reduce ? 1 : MORPH_MS;
const root = rootRef.current;
const shape = shapeRef.current;
if (!root || !shape) return;
let isDemo = true;
let running = false;
let startTs = 0;
let rafId = 0;
function circlePoint(i: number): [number, number] {
const a = (i / N) * Math.PI * 2 - Math.PI / 2;
return [CX + R * Math.cos(a), CY + R * Math.sin(a)];
}
function lerp(a: number, b: number, t: number) { return a + (b - a) * t; }
function buildPath(p: number) {
let d = "";
for (let i = 0; i < N; i++) {
const c = circlePoint(i);
const k = CHECK_PTS[i];
const x = lerp(c[0], k[0], p);
const y = lerp(c[1], k[1], p);
d += (i === 0 ? "M" : "L") + x.toFixed(2) + "," + y.toFixed(2) + " ";
}
return d + "Z";
}
function apply(p: number) { shape.setAttribute("d", buildPath(p)); }
function ease(t: number) { return 1 - Math.pow(1 - t, 3); }
function runMorph(ts: number) {
if (!startTs) startTs = ts;
const t = ts - startTs;
if (t <= DUR) {
apply(ease(t / DUR));
setStatus(STRINGS.idle);
rafId = requestAnimationFrame(runMorph);
} else if (t <= DUR + HOLD_MS) {
apply(1);
setStatus(STRINGS.done);
rafId = requestAnimationFrame(runMorph);
} else if (t <= DUR + HOLD_MS + DUR) {
apply(1 - ease((t - DUR - HOLD_MS) / DUR));
setStatus(STRINGS.idle);
rafId = requestAnimationFrame(runMorph);
} else {
apply(0);
running = false;
setPressed(false);
}
}
function trigger() {
if (running) return;
running = true;
startTs = 0;
setPressed(true);
rafId = requestAnimationFrame(runMorph);
}
function human() { if (isDemo) { root.classList.remove(styles.isDemo); isDemo = false; } }
function onClick() { human(); trigger(); }
function onKeyDown(e: KeyboardEvent) { if (e.key === " " || e.key === "Enter") human(); }
root.addEventListener("click", onClick);
root.addEventListener("keydown", onKeyDown);
apply(0);
let cycleStart = 0;
const PERIOD = DUR * 2 + HOLD_MS + GAP_MS;
function loop(ts: number) {
if (isDemo && !reduce) {
if (!cycleStart) cycleStart = ts;
const phase = (ts - cycleStart) % PERIOD;
if (phase < 16 && !running) trigger();
}
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.morphWrap}>
<button
type="button"
className={styles.morph + " " + styles.isDemo}
ref={rootRef}
aria-pressed={pressed}
>
<svg className={styles.morphIcon} viewBox="0 0 120 120" aria-hidden="true">
<path className={styles.morphShape} ref={shapeRef} />
</svg>
<span className={styles.morphLabel}>인증 성공</span>
</button>
<p className={styles.morphStatus}>{status}</p>
</div>
</main>
);
}04 · 成功モーフィング — 認証に成功すると指紋アイコンのパスの値がチェックマークのパスの値へ滑らかに補間される方式 — 計測 累積面積 3.191% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function buildPath(p) {
var d = '';
for (var i = 0; i < N; i++) {
var c = circlePoint(i);
var k = CHECK_PTS[i];
var x = lerp(c[0], k[0], p);
var y = lerp(c[1], k[1], p);
d += (i === 0 ? 'M' : 'L') + x.toFixed(2) + ',' + y.toFixed(2) + ' ';
}
return d + 'Z';
}
数値が語ること
04は9項目の中で強度平均が最も高い158.6を記録しながら、累積面積は3.19%、動いたフレームは23フレーム中15フレームにとどまりました。毎フレーム10個の点すべてを作り直すことでパスの鋭い辺が1ピクセルあたり大きく変化する一方、モーフィング自体は1回のトリガーにつき指紋→チェック、チェック→指紋の2回しか再生されないためです。
05圧力リップル — スキャン面を押す圧力の値が増すほど同心円リップルの半径と振幅の値が共に大きくなる方式
指やポインターが押されているあいだ、PointerEvent.pressureからそのまま読み取った圧力の値が32個の点からなるリングが基準半径からどれだけ揺れるかを決めます——BASE_R + AMPLITUDE * pressure * Math.sin(WAVE_K * theta - t * WAVE_SPEED)という式です。軽く触れたときはほとんど揺れず、強く押すほど波が遠くまで届きます。実際のポインターが押されていないときは、同じ圧力の入力が自動のサイン波サイクルから来ます。指紋センサーのインタラクションデモやタッチ圧力のフィードバック背景に合う方式です。

<main class="stage">
<div class="ripple-wrap">
<button type="button" class="ripple is-demo" id="ripple" aria-pressed="false">
<svg class="ripple__icon" viewBox="0 0 120 120" aria-hidden="true">
<path class="ripple__wave" id="wave"></path>
<circle class="ripple__core" cx="60" cy="60" r="10"></circle>
</svg>
<span class="ripple__label">押して認証</span>
</button>
<p class="ripple__status" id="status">圧力 0%</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 index.html 의 buildPath() 에 있다 — 각도마다 반지름을 사인 함수로 계산해(물결 진폭)
// 매 프레임 점을 다시 찍는다. 진폭은 포인터 압력(pointerPressure) 에 비례한다.
@include stage($stage-paper);
.ripple-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.ripple {
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;
touch-action: none;
&:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 4px; }
}
.ripple__icon {
width: clamp(120px, 30vw, 160px);
height: auto;
overflow: visible;
}
.ripple__wave {
fill: none;
stroke: $subject-blue-light;
stroke-width: 2.5;
}
.ripple__core {
fill: $subject-blue-deep;
}
.ripple__label {
color: $stage-ink;
font-weight: 600;
font-size: 13px;
line-height: 1.2;
letter-spacing: .02em;
}
.ripple__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; }
.ripple-wrap { gap: $sp-2; }
.ripple { padding: $sp-2 $sp-3; gap: $sp-2; }
.ripple__icon { width: 104px; }
.ripple__label { font-size: 12px; }
.ripple__status { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.ripple__wave { 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);
}
.ripple-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.ripple {
appearance: none;
border: none;
background: transparent;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
cursor: pointer;
padding: 16px;
border-radius: 16px;
touch-action: none;
}
.ripple:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 4px;
}
.ripple__icon {
width: clamp(120px, 30vw, 160px);
height: auto;
overflow: visible;
}
.ripple__wave {
fill: none;
stroke: #5b8cf8;
stroke-width: 2.5;
}
.ripple__core {
fill: #0a4fe8;
}
.ripple__label {
color: #17141a;
font-weight: 600;
font-size: 13px;
line-height: 1.2;
letter-spacing: 0.02em;
}
.ripple__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;
}
.ripple-wrap {
gap: 8px;
}
.ripple {
padding: 8px 12px;
gap: 8px;
}
.ripple__icon {
width: 104px;
}
.ripple__label {
font-size: 12px;
}
.ripple__status {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.ripple__wave {
transition: none;
}
}(function () {
// 계산 겹: 물결 진폭 — 각도(θ)마다 반지름을 BASE_R + AMPLITUDE*pressure*sin(WAVE_K*θ - t*WAVE_SPEED)
// 로 계산해 M 개 점을 매 프레임 다시 찍는다. pressure 는 실 포인터 압력(e.pressure) 또는
// 자동 루프의 누르기-떼기 사인 곡선에서 온다 — 눌린 힘이 셀수록 물결의 진폭이 커진다.
var BASE_R = 30;
var AMPLITUDE = 11; // 최대 압력일 때 물결이 흔들리는 폭
var WAVE_K = 5; // 물결 마루 개수
var WAVE_SPEED = 0.0032; // 물결이 도는 각속도(rad/ms)
var M = 32; // 원둘레를 따라 찍는 점 개수
var PRESS_CYCLE_MS = 1400; // 자동 루프에서 누르기→떼기 한 번의 시간
var GAP_MS = 500;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var root = document.getElementById('ripple');
var wave = document.getElementById('wave');
var status = document.getElementById('status');
var isDemo = true;
var pointerPressure = 0;
var pointerDown = false;
var autoStart = 0;
function buildPath(t, pressure) {
var d = '';
for (var i = 0; i <= M; i++) {
var theta = (i / M) * Math.PI * 2;
var r = BASE_R + AMPLITUDE * pressure * Math.sin(WAVE_K * theta - t * WAVE_SPEED);
var x = 60 + r * Math.cos(theta);
var y = 60 + r * Math.sin(theta);
d += (i === 0 ? 'M' : 'L') + x.toFixed(2) + ',' + y.toFixed(2) + ' ';
}
return d + 'Z';
}
function autoPressure(now) {
if (!autoStart) autoStart = now;
var phase = (now - autoStart) % (PRESS_CYCLE_MS + GAP_MS);
if (phase > PRESS_CYCLE_MS) return 0;
return Math.max(0, Math.sin((phase / PRESS_CYCLE_MS) * Math.PI));
}
function human() { if (isDemo) { root.classList.remove('is-demo'); isDemo = false; } }
root.addEventListener('pointerdown', function (e) {
human();
pointerDown = true;
pointerPressure = e.pressure > 0 ? e.pressure : 0.6;
root.setAttribute('aria-pressed', 'true');
});
root.addEventListener('pointermove', function (e) {
if (!pointerDown) return;
pointerPressure = e.pressure > 0 ? e.pressure : pointerPressure;
});
window.addEventListener('pointerup', function () {
if (!pointerDown) return;
pointerDown = false;
pointerPressure = 0;
root.setAttribute('aria-pressed', 'false');
});
root.addEventListener('keydown', function (e) {
if (e.key === ' ' || e.key === 'Enter') { human(); }
});
function loop(ts) {
var reduceNow = reduce && isDemo;
var pressure = pointerDown ? pointerPressure : (isDemo && !reduce ? autoPressure(ts) : 0);
if (reduceNow) { pressure = 0; }
wave.setAttribute('d', buildPath(ts, pressure));
status.textContent = '圧力 ' + Math.round(pressure * 100) + '%';
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// PressureRipple — vanilla 판(run/371/demo/05-biometric-scan-pressure-ripple/index.html) 의 React 포트.
// 계산 겹(물결 진폭)은 usePressureRipple 훅 안에 있다: 각도(θ)마다 반지름을
// BASE_R + AMPLITUDE*pressure*sin(WAVE_K*θ - t*WAVE_SPEED) 로 계산해 매 프레임 점을 다시 찍는다.
// pressure 는 실 포인터 압력(e.pressure) 또는 자동 루프의 누르기-떼기 사인 곡선에서 온다.
import { useEffect, useRef, useState } from "react";
import styles from "./PressureRipple.module.scss";
type Box<T> = { current: T };
function usePressureRipple(
rootRef: Box<HTMLButtonElement>,
waveRef: Box<SVGPathElement>,
setPressure: (p: number) => void,
setPressed: (b: boolean) => void
) {
useEffect(() => {
const BASE_R = 30;
const AMPLITUDE = 11;
const WAVE_K = 5;
const WAVE_SPEED = 0.0032;
const M = 32;
const PRESS_CYCLE_MS = 1400;
const GAP_MS = 500;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const root = rootRef.current;
const wave = waveRef.current;
if (!root || !wave) return;
let isDemo = true;
let pointerPressure = 0;
let pointerDown = false;
let autoStart = 0;
let rafId = 0;
function buildPath(t: number, pressure: number) {
let d = "";
for (let i = 0; i <= M; i++) {
const theta = (i / M) * Math.PI * 2;
const r = BASE_R + AMPLITUDE * pressure * Math.sin(WAVE_K * theta - t * WAVE_SPEED);
const x = 60 + r * Math.cos(theta);
const y = 60 + r * Math.sin(theta);
d += (i === 0 ? "M" : "L") + x.toFixed(2) + "," + y.toFixed(2) + " ";
}
return d + "Z";
}
function autoPressure(now: number) {
if (!autoStart) autoStart = now;
const phase = (now - autoStart) % (PRESS_CYCLE_MS + GAP_MS);
if (phase > PRESS_CYCLE_MS) return 0;
return Math.max(0, Math.sin((phase / PRESS_CYCLE_MS) * Math.PI));
}
function human() { if (isDemo) { root.classList.remove(styles.isDemo); isDemo = false; } }
function onPointerDown(e: PointerEvent) {
human();
pointerDown = true;
pointerPressure = e.pressure > 0 ? e.pressure : 0.6;
setPressed(true);
}
function onPointerMove(e: PointerEvent) {
if (!pointerDown) return;
pointerPressure = e.pressure > 0 ? e.pressure : pointerPressure;
}
function onPointerUp() {
if (!pointerDown) return;
pointerDown = false;
pointerPressure = 0;
setPressed(false);
}
function onKeyDown(e: KeyboardEvent) { if (e.key === " " || e.key === "Enter") human(); }
root.addEventListener("pointerdown", onPointerDown);
root.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
root.addEventListener("keydown", onKeyDown);
function loop(ts: number) {
const reduceNow = reduce && isDemo;
let pressure = pointerDown ? pointerPressure : (isDemo && !reduce ? autoPressure(ts) : 0);
if (reduceNow) pressure = 0;
wave.setAttribute("d", buildPath(ts, pressure));
setPressure(pressure);
rafId = requestAnimationFrame(loop);
}
rafId = requestAnimationFrame(loop);
return () => {
root.removeEventListener("pointerdown", onPointerDown);
root.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onPointerUp);
root.removeEventListener("keydown", onKeyDown);
cancelAnimationFrame(rafId);
};
}, []);
}
export default function PressureRipple() {
const rootRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const waveRef = useRef(null) as unknown as Box<SVGPathElement>;
const [pressure, setPressure] = useState(0);
const [pressed, setPressed] = useState(false);
usePressureRipple(rootRef, waveRef, setPressure, setPressed);
return (
<main className={styles.stage}>
<div className={styles.rippleWrap}>
<button
type="button"
className={styles.ripple + " " + styles.isDemo}
ref={rootRef}
aria-pressed={pressed}
>
<svg className={styles.rippleIcon} viewBox="0 0 120 120" aria-hidden="true">
<path className={styles.rippleWave} ref={waveRef} />
<circle className={styles.rippleCore} cx={60} cy={60} r={10} />
</svg>
<span className={styles.rippleLabel}>눌러서 인증</span>
</button>
<p className={styles.rippleStatus}>{"누르는 힘 " + Math.round(pressure * 100) + "%"}</p>
</div>
</main>
);
}05 · 圧力リップル — スキャン面を押す圧力の値が増すほど同心円リップルの半径と振幅の値が共に大きくなる方式 — 計測 累積面積 3.583% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function buildPath(t, pressure) {
var d = '';
for (var i = 0; i <= M; i++) {
var theta = (i / M) * Math.PI * 2;
var r = BASE_R + AMPLITUDE * pressure * Math.sin(WAVE_K * theta - t * WAVE_SPEED);
var x = 60 + r * Math.cos(theta);
var y = 60 + r * Math.sin(theta);
d += (i === 0 ? 'M' : 'L') + x.toFixed(2) + ',' + y.toFixed(2) + ' ';
}
return d + 'Z';
}
数値が語ること
05は累積面積3.58%・強度平均107.7のまま、23フレーム中19フレームで動きが記録されました。32個の点からなる波は圧力の値にかかわらず毎フレーム描き直されますが、波そのものの振幅——つまり実際に塗り替わる面積——は現在の圧力の値がどれだけ大きいかにかかっています。
06ホログラムフリッカー — 2Dノイズの値を基にスキャンアイコンの明滅強度の値が微細に揺れる方式
周波数の異なる3つのサイン波(FREQS)を重み付けして合計し、およそ-1から1のあいだの値を作り、その値を各要素の不透明度にマッピングします——グリフには1つの位相オフセットを、4本のスキャンラインにはそれぞれLINE_PHASE_STEPだけずらした位相を与えるため、2本のラインが同時に同じリズムで明滅することはありません。この合計値は決定的な計算であり、値ノイズのフィールドをサンプリングしているわけではなく、Math.randomも一度も実行されません。生体認証待機アイコンやSF風の認証画面背景に合う方式です。

<main class="stage">
<div class="holo-wrap">
<button type="button" class="holo is-demo" id="holo" aria-pressed="true">
<svg class="holo__icon" viewBox="0 0 120 120" aria-hidden="true">
<g id="glyph">
<path class="holo__ridge" d="M20,90 A40 36 0 0 1 100,90"></path>
<path class="holo__ridge" d="M30,90 A30 27 0 0 1 90,90"></path>
<path class="holo__ridge" d="M40,90 A20 19 0 0 1 80,90"></path>
</g>
<rect class="holo__line" data-i="0" x="14" y="30" width="92" height="2"></rect>
<rect class="holo__line" data-i="1" x="14" y="50" width="92" height="2"></rect>
<rect class="holo__line" data-i="2" x="14" y="70" width="92" height="2"></rect>
<rect class="holo__line" data-i="3" x="14" y="96" width="92" height="2"></rect>
</svg>
<span class="holo__label">ホログラム認証カード</span>
</button>
<p class="holo__status" id="status">投影中</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 index.html 의 noise() 에 있다 — 서로 다른 주기의 사인 3개를 가중합한 결정적 노이즈로
// 매 프레임 불투명도를 다시 계산한다(Math.random 미사용). 여기는 무대·모양만 맡는다.
@include stage($stage-ink);
.holo-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.holo {
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-sky; outline-offset: 4px; }
}
.holo__icon {
width: clamp(120px, 30vw, 160px);
height: auto;
overflow: visible;
}
.holo__ridge {
fill: none;
stroke: $subject-sky;
stroke-width: 2.5;
stroke-linecap: round;
filter: drop-shadow(0 0 5px rgba($subject-sky, .8));
}
.holo__line {
fill: $subject-sky;
opacity: .6;
}
.holo__label {
color: $subject-cream;
font-weight: 600;
font-size: 13px;
line-height: 1.2;
letter-spacing: .02em;
}
.holo__status {
margin: 0;
color: rgba($subject-cream, .58);
font-weight: 500;
font-size: 12px;
line-height: 1.2;
}
@media (max-width: 340px) {
.stage { padding: $sp-2; }
.holo-wrap { gap: $sp-2; }
.holo { padding: $sp-2 $sp-3; gap: $sp-2; }
.holo__icon { width: 104px; }
.holo__label { font-size: 12px; }
.holo__status { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.holo__ridge, .holo__line { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.holo-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.holo {
appearance: none;
border: none;
background: transparent;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
cursor: pointer;
padding: 16px;
border-radius: 16px;
}
.holo:focus-visible {
outline: 2px solid #6ec8ff;
outline-offset: 4px;
}
.holo__icon {
width: clamp(120px, 30vw, 160px);
height: auto;
overflow: visible;
}
.holo__ridge {
fill: none;
stroke: #6ec8ff;
stroke-width: 2.5;
stroke-linecap: round;
filter: drop-shadow(0 0 5px rgba(110, 200, 255, 0.8));
}
.holo__line {
fill: #6ec8ff;
opacity: 0.6;
}
.holo__label {
color: #fff7e6;
font-weight: 600;
font-size: 13px;
line-height: 1.2;
letter-spacing: 0.02em;
}
.holo__status {
margin: 0;
color: rgba(255, 247, 230, 0.58);
font-weight: 500;
font-size: 12px;
line-height: 1.2;
}
@media (max-width: 340px) {
.stage {
padding: 8px;
}
.holo-wrap {
gap: 8px;
}
.holo {
padding: 8px 12px;
gap: 8px;
}
.holo__icon {
width: 104px;
}
.holo__label {
font-size: 12px;
}
.holo__status {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.holo__ridge, .holo__line {
transition: none;
}
}(function () {
// 계산 겹: 결정적 노이즈 플리커 — Math.random 없이 서로 다른 주기의 사인 3개를 가중합해
// -1~1 노이즈 값을 만들고(노이즈 합성), 매 프레임 그 값으로 불투명도를 계산한다. 스캔 라인마다
// 위상(phase)을 다르게 줘서 같은 순간에도 줄마다 다른 밝기로 반짝인다.
var FREQS = [0.013, 0.031, 0.007]; // 세 사인의 각속도(rad/ms) — 서로소에 가깝게 골라 주기성이 안 보이게
var WEIGHTS = [0.5, 0.32, 0.18];
var PHASES = [0, 1.7, 3.4];
var LINE_PHASE_STEP = 0.6; // 스캔 라인 인덱스마다 더하는 위상 차
var BASE = 0.55, RANGE = 0.42;
var WSUM = WEIGHTS[0] + WEIGHTS[1] + WEIGHTS[2];
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var root = document.getElementById('holo');
var glyph = document.getElementById('glyph');
var lines = Array.prototype.slice.call(document.querySelectorAll('.holo__line'));
var status = document.getElementById('status');
var STRINGS = { on: '投影中', off: '電源オフ' };
var isDemo = true;
var active = true;
function noise(t, phaseOffset) {
var v = 0;
for (var i = 0; i < 3; i++) {
v += WEIGHTS[i] * Math.sin(t * FREQS[i] + PHASES[i] + phaseOffset);
}
return v / WSUM;
}
function human() { if (isDemo) { root.classList.remove('is-demo'); isDemo = false; } }
function toggle() {
active = !active;
root.setAttribute('aria-pressed', String(active));
status.textContent = active ? STRINGS.on : STRINGS.off;
}
root.addEventListener('click', function () { human(); toggle(); });
root.addEventListener('keydown', function (e) {
if (e.key === ' ' || e.key === 'Enter') { human(); }
});
function loop(ts) {
var run = active && (isDemo ? !reduce : true);
if (run) {
glyph.style.opacity = (BASE + RANGE * noise(ts, 0)).toFixed(3);
for (var i = 0; i < lines.length; i++) {
var v = BASE + RANGE * noise(ts, i * LINE_PHASE_STEP + 1);
lines[i].style.opacity = Math.max(0, Math.min(1, v)).toFixed(3);
}
} else {
glyph.style.opacity = active ? '1' : '0.08';
for (var j = 0; j < lines.length; j++) { lines[j].style.opacity = active ? '0.7' : '0'; }
}
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// HologramFlicker — vanilla 판(run/371/demo/06-biometric-scan-hologram-flicker/index.html) 의 React 포트.
// 계산 겹(결정적 노이즈 플리커)은 useEffect 안에 있다: Math.random 없이 서로 다른 주기의 사인 3개를
// 가중합해 노이즈 값을 만들고, 매 프레임 그 값으로 불투명도를 계산한다.
import { useEffect, useRef, useState } from "react";
import styles from "./HologramFlicker.module.scss";
type Box<T> = { current: T };
const STRINGS: Record<string, string> = { on: "투사 중", off: "전원 꺼짐" };
export default function HologramFlicker() {
const rootRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const glyphRef = useRef(null) as unknown as Box<SVGGElement>;
const lineRefs = useRef([]) as unknown as Box<SVGRectElement[]>;
const [status, setStatus] = useState(STRINGS.on);
const [pressed, setPressed] = useState(true);
useEffect(() => {
const FREQS = [0.013, 0.031, 0.007];
const WEIGHTS = [0.5, 0.32, 0.18];
const PHASES = [0, 1.7, 3.4];
const LINE_PHASE_STEP = 0.6;
const BASE = 0.55, RANGE = 0.42;
const WSUM = WEIGHTS[0] + WEIGHTS[1] + WEIGHTS[2];
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const root = rootRef.current;
const glyph = glyphRef.current;
const lines = lineRefs.current;
if (!root || !glyph || lines.length === 0) return;
let isDemo = true;
let active = true;
let rafId = 0;
function noise(t: number, phaseOffset: number) {
let v = 0;
for (let i = 0; i < 3; i++) {
v += WEIGHTS[i] * Math.sin(t * FREQS[i] + PHASES[i] + phaseOffset);
}
return v / WSUM;
}
function human() { if (isDemo) { root.classList.remove(styles.isDemo); isDemo = false; } }
function toggle() {
active = !active;
setPressed(active);
setStatus(active ? STRINGS.on : STRINGS.off);
}
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 run = active && (isDemo ? !reduce : true);
if (run) {
glyph.style.opacity = (BASE + RANGE * noise(ts, 0)).toFixed(3);
for (let i = 0; i < lines.length; i++) {
const v = BASE + RANGE * noise(ts, i * LINE_PHASE_STEP + 1);
lines[i].style.opacity = Math.max(0, Math.min(1, v)).toFixed(3);
}
} else {
glyph.style.opacity = active ? "1" : "0.08";
for (let j = 0; j < lines.length; j++) lines[j].style.opacity = active ? "0.7" : "0";
}
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.holoWrap}>
<button
type="button"
className={styles.holo + " " + styles.isDemo}
ref={rootRef}
aria-pressed={pressed}
>
<svg className={styles.holoIcon} viewBox="0 0 120 120" aria-hidden="true">
<g ref={glyphRef}>
<path className={styles.holoRidge} d="M20,90 A40 36 0 0 1 100,90" />
<path className={styles.holoRidge} d="M30,90 A30 27 0 0 1 90,90" />
<path className={styles.holoRidge} d="M40,90 A20 19 0 0 1 80,90" />
</g>
{[30, 50, 70, 96].map((y, i) => (
<rect
key={i}
className={styles.holoLine}
x={14}
y={y}
width={92}
height={2}
ref={(el: SVGRectElement | null) => { if (el) lineRefs.current[i] = el; }}
/>
))}
</svg>
<span className={styles.holoLabel}>홀로그램 인증표</span>
</button>
<p className={styles.holoStatus}>{status}</p>
</div>
</main>
);
}06 · ホログラムフリッカー — 2Dノイズの値を基にスキャンアイコンの明滅強度の値が微細に揺れる方式 — 計測 累積面積 3.044% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function noise(t, phaseOffset) {
var v = 0;
for (var i = 0; i < 3; i++) {
v += WEIGHTS[i] * Math.sin(t * FREQS[i] + PHASES[i] + phaseOffset);
}
return v / WSUM;
}
数値が語ること
06は累積面積3.04%のまま、強度平均はわずか38.8にとどまりましたが、23フレーム中22フレームで動きが記録され——ほぼ全てのサンプルフレームで動いています。3つのサイン波の合計は完全には落ち着かないためですが、1回ごとの不透明度の変化は小さく、1ピクセルあたりの強度は9項目の中で下から2番目に低い値になっています。
07リングオーバーシュート — 円形プログレスリングが角度の値で満ちていき、完了の瞬間に慣性の値の分だけ少し行き過ぎてから戻る方式
stroke-dashoffsetの値はイージングのトゥイーンではなく、force = (target - pos) * STIFFNESS - vel * DAMPINGというスプリング物理シミュレーションを毎フレーム追いかけるため、目標値が切り替わるとリングの塗りは本当に目標を通り過ぎてから慣性で引き戻されます。自動デモはtargetをスプリングの整定時間より短いFLIP_MSごとに2つの一致率のあいだで切り替え続けるため、リングは常にどちらかへ向かう途中で、完全に静止することがありません。

<main class="stage">
<div class="ring-wrap">
<button type="button" class="ring is-demo" id="ring" aria-pressed="false">
<svg class="ring__icon" viewBox="0 0 120 120" aria-hidden="true">
<circle class="ring__track" cx="60" cy="60" r="46"></circle>
<circle class="ring__fill" id="fill" cx="60" cy="60" r="46"></circle>
<text class="ring__pct" id="pct" x="60" y="66" text-anchor="middle">0%</text>
</svg>
<span class="ring__label">一致率確認</span>
</button>
<p class="ring__status" id="status">待機中</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 index.html 의 step() 에 있다 — 목표 일치율을 스프링 물리(힘·속도·위치)로 매 프레임
// 직접 적분해 관성으로 목표를 넘었다가(오버슛) 되돌아와 멈춘다. 여기는 무대·모양만 맡는다.
@include stage($stage-orange);
.ring-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.ring {
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 $stage-ink; outline-offset: 4px; }
}
.ring__icon {
width: clamp(120px, 30vw, 160px);
height: auto;
overflow: visible;
transform: rotate(-90deg);
transform-origin: 50% 50%;
}
.ring__track {
fill: none;
stroke: rgba($stage-ink, .18);
stroke-width: 8;
}
.ring__fill {
fill: none;
stroke: $subject-blue-deep;
stroke-width: 8;
stroke-linecap: round;
}
.ring__pct {
transform: rotate(90deg);
transform-origin: 60px 60px;
fill: $stage-ink;
font-weight: 700;
font-size: 20px;
}
.ring__label {
color: $stage-ink;
font-weight: 600;
font-size: 13px;
line-height: 1.2;
letter-spacing: .02em;
}
.ring__status {
margin: 0;
color: rgba($stage-ink, .6);
font-weight: 500;
font-size: 12px;
line-height: 1.2;
}
@media (max-width: 340px) {
.stage { padding: $sp-2; }
.ring-wrap { gap: $sp-2; }
.ring { padding: $sp-2 $sp-3; gap: $sp-2; }
.ring__icon { width: 104px; }
.ring__label { font-size: 12px; }
.ring__status { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.ring__fill { 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);
}
.ring-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.ring {
appearance: none;
border: none;
background: transparent;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
cursor: pointer;
padding: 16px;
border-radius: 16px;
}
.ring:focus-visible {
outline: 2px solid #17141a;
outline-offset: 4px;
}
.ring__icon {
width: clamp(120px, 30vw, 160px);
height: auto;
overflow: visible;
transform: rotate(-90deg);
transform-origin: 50% 50%;
}
.ring__track {
fill: none;
stroke: rgba(23, 20, 26, 0.18);
stroke-width: 8;
}
.ring__fill {
fill: none;
stroke: #0a4fe8;
stroke-width: 8;
stroke-linecap: round;
}
.ring__pct {
transform: rotate(90deg);
transform-origin: 60px 60px;
fill: #17141a;
font-weight: 700;
font-size: 20px;
}
.ring__label {
color: #17141a;
font-weight: 600;
font-size: 13px;
line-height: 1.2;
letter-spacing: 0.02em;
}
.ring__status {
margin: 0;
color: rgba(23, 20, 26, 0.6);
font-weight: 500;
font-size: 12px;
line-height: 1.2;
}
@media (max-width: 340px) {
.stage {
padding: 8px;
}
.ring-wrap {
gap: 8px;
}
.ring {
padding: 8px 12px;
gap: 8px;
}
.ring__icon {
width: 104px;
}
.ring__label {
font-size: 12px;
}
.ring__status {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.ring__fill {
transition: none;
}
}(function () {
// 계산 겹: 관성 오버슛 — 트윈 이징이 아니라 스프링 물리를 매 프레임 직접 적분한다.
// force = (target-pos)*STIFFNESS - vel*DAMPING, vel += force*dt, pos += vel*dt.
// 관성 때문에 목표를 넘어섰다가(오버슛) 다시 돌아와 멈춘다.
// 자동 루프는 목표값을 정착 시간보다 짧은 간격으로 두 값 사이에서 계속 바꾼다(재측정을
// 반복하는 계기판처럼) — 스프링이 한 번도 완전히 멈추지 않아 어느 구간을 잘라도 항상
// 진행 중인 움직임이 있다. 실제 조작(클릭·키보드)은 별도 경로로 한 번만 목표에 도달한다.
var STIFFNESS = 0.0006;
var DAMPING = 0.02;
var TARGET = 0.82; // 실제 조작이 향하는 목표 일치율(82%)
var AUTO_HIGH = 0.82, AUTO_LOW = 0.28; // 자동 루프가 번갈아 재는 두 목표값
var FLIP_MS = 480; // 정착 시간(약 400ms)보다 짧게 잡아 늘 진행 중이게 한다
var SETTLE_EPS = 0.0006; // 속도·오차가 이 밑이면 정지로 본다(실제 조작 경로에서만 쓴다)
var R = 46, CIRC = 2 * Math.PI * R;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var root = document.getElementById('ring');
var fill = document.getElementById('fill');
var pctText = document.getElementById('pct');
var status = document.getElementById('status');
var STRINGS = { idle: '待機中', running: '一致率計算中', done: '一致確認' };
var isDemo = true;
var manualRunning = false;
var manualPos = 0, manualVel = 0, manualLastTs = 0;
var autoPos = 0, autoVel = 0, autoLastTs = 0, autoTargetHigh = true, autoFlipAt = 0;
var rafId = 0;
fill.style.strokeDasharray = CIRC.toFixed(2);
function paint(pos) {
var shown = Math.max(0, pos);
var offset = CIRC * (1 - Math.min(shown, 1.3));
fill.style.strokeDashoffset = offset.toFixed(2);
pctText.textContent = Math.round(shown * 100) + '%';
}
function runManual(ts) {
if (!manualLastTs) manualLastTs = ts;
var dt = Math.min(ts - manualLastTs, 32);
manualLastTs = ts;
var force = (TARGET - manualPos) * STIFFNESS - manualVel * DAMPING;
manualVel += force * dt;
manualPos += manualVel * dt;
paint(manualPos);
if (Math.abs(manualVel) > SETTLE_EPS || Math.abs(TARGET - manualPos) > SETTLE_EPS) {
status.textContent = STRINGS.running;
requestAnimationFrame(runManual);
} else {
manualPos = TARGET;
manualVel = 0;
paint(manualPos);
status.textContent = STRINGS.done;
manualRunning = false;
root.setAttribute('aria-pressed', 'false');
}
}
function trigger() {
if (manualRunning || isDemo) return;
manualRunning = true;
manualPos = 0; manualVel = 0; manualLastTs = 0;
root.setAttribute('aria-pressed', 'true');
if (reduce) {
manualPos = TARGET; manualVel = 0;
paint(manualPos);
status.textContent = STRINGS.done;
manualRunning = false;
root.setAttribute('aria-pressed', 'false');
return;
}
requestAnimationFrame(runManual);
}
function human() {
if (isDemo) {
isDemo = false;
root.classList.remove('is-demo');
cancelAnimationFrame(rafId);
status.textContent = STRINGS.idle;
paint(0);
}
}
root.addEventListener('click', function () { human(); trigger(); });
root.addEventListener('keydown', function (e) {
if (e.key === ' ' || e.key === 'Enter') { human(); }
});
paint(0);
function autoLoop(ts) {
if (!isDemo || reduce) return;
if (!autoLastTs) autoLastTs = ts;
var dt = Math.min(ts - autoLastTs, 32);
autoLastTs = ts;
if (!autoFlipAt) autoFlipAt = ts + FLIP_MS;
if (ts >= autoFlipAt) { autoTargetHigh = !autoTargetHigh; autoFlipAt = ts + FLIP_MS; }
var target = autoTargetHigh ? AUTO_HIGH : AUTO_LOW;
var force = (target - autoPos) * STIFFNESS - autoVel * DAMPING;
autoVel += force * dt;
autoPos += autoVel * dt;
paint(autoPos);
status.textContent = STRINGS.running;
rafId = requestAnimationFrame(autoLoop);
}
if (isDemo && !reduce) {
rafId = requestAnimationFrame(autoLoop);
} else if (reduce) {
paint(TARGET);
status.textContent = STRINGS.done;
}
})();// RingOvershoot — vanilla 판(run/371/demo/07-biometric-scan-ring-overshoot/index.html) 의 React 포트.
// 계산 겹(관성 오버슛)은 useEffect 안에 있다: 목표 일치율을 스프링 물리(힘·속도·위치)로 매 프레임
// 직접 적분한다 — 트윈 이징이 아니라 관성 때문에 목표를 넘었다가 되돌아와 멈춘다.
// 자동 루프는 목표값을 정착 시간보다 짧은 간격으로 두 값 사이에서 계속 바꿔 스프링이 한 번도
// 완전히 멈추지 않게 한다(어느 순간을 잘라도 항상 진행 중인 움직임이 있다).
import { useEffect, useRef, useState } from "react";
import styles from "./RingOvershoot.module.scss";
type Box<T> = { current: T };
const STRINGS: Record<string, string> = { idle: "대기 중", running: "일치율 계산 중", done: "일치 확인" };
const R = 46;
const CIRC = 2 * Math.PI * R;
export default function RingOvershoot() {
const rootRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const fillRef = useRef(null) as unknown as Box<SVGCircleElement>;
const [pct, setPct] = useState(0);
const [status, setStatus] = useState(STRINGS.idle);
const [pressed, setPressed] = useState(false);
useEffect(() => {
const STIFFNESS = 0.0006;
const DAMPING = 0.02;
const TARGET = 0.82;
const AUTO_HIGH = 0.82;
const AUTO_LOW = 0.28;
const FLIP_MS = 480;
const SETTLE_EPS = 0.0006;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const root = rootRef.current;
const fill = fillRef.current;
if (!root || !fill) return;
fill.style.strokeDasharray = CIRC.toFixed(2);
let isDemo = true;
let manualRunning = false;
let manualPos = 0, manualVel = 0, manualLastTs = 0;
let autoPos = 0, autoVel = 0, autoLastTs = 0, autoTargetHigh = true, autoFlipAt = 0;
let rafId = 0;
function paint(pos: number) {
const shown = Math.max(0, pos);
const offset = CIRC * (1 - Math.min(shown, 1.3));
fill!.style.strokeDashoffset = offset.toFixed(2);
setPct(Math.round(shown * 100));
}
function runManual(ts: number) {
if (!manualLastTs) manualLastTs = ts;
const dt = Math.min(ts - manualLastTs, 32);
manualLastTs = ts;
const force = (TARGET - manualPos) * STIFFNESS - manualVel * DAMPING;
manualVel += force * dt;
manualPos += manualVel * dt;
paint(manualPos);
if (Math.abs(manualVel) > SETTLE_EPS || Math.abs(TARGET - manualPos) > SETTLE_EPS) {
setStatus(STRINGS.running);
rafId = requestAnimationFrame(runManual);
} else {
manualPos = TARGET;
manualVel = 0;
paint(manualPos);
setStatus(STRINGS.done);
manualRunning = false;
setPressed(false);
}
}
function trigger() {
if (manualRunning || isDemo) return;
manualRunning = true;
manualPos = 0; manualVel = 0; manualLastTs = 0;
setPressed(true);
if (reduce) {
manualPos = TARGET; manualVel = 0;
paint(manualPos);
setStatus(STRINGS.done);
manualRunning = false;
setPressed(false);
return;
}
rafId = requestAnimationFrame(runManual);
}
function human() {
if (isDemo) {
isDemo = false;
root!.classList.remove(styles.isDemo);
cancelAnimationFrame(rafId);
setStatus(STRINGS.idle);
paint(0);
}
}
function onClick() { human(); trigger(); }
function onKeyDown(e: KeyboardEvent) { if (e.key === " " || e.key === "Enter") human(); }
root.addEventListener("click", onClick);
root.addEventListener("keydown", onKeyDown);
paint(0);
function autoLoop(ts: number) {
if (!isDemo || reduce) return;
if (!autoLastTs) autoLastTs = ts;
const dt = Math.min(ts - autoLastTs, 32);
autoLastTs = ts;
if (!autoFlipAt) autoFlipAt = ts + FLIP_MS;
if (ts >= autoFlipAt) { autoTargetHigh = !autoTargetHigh; autoFlipAt = ts + FLIP_MS; }
const target = autoTargetHigh ? AUTO_HIGH : AUTO_LOW;
const force = (target - autoPos) * STIFFNESS - autoVel * DAMPING;
autoVel += force * dt;
autoPos += autoVel * dt;
paint(autoPos);
setStatus(STRINGS.running);
rafId = requestAnimationFrame(autoLoop);
}
if (isDemo && !reduce) {
rafId = requestAnimationFrame(autoLoop);
} else if (reduce) {
paint(TARGET);
setStatus(STRINGS.done);
}
return () => {
root.removeEventListener("click", onClick);
root.removeEventListener("keydown", onKeyDown);
cancelAnimationFrame(rafId);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.ringWrap}>
<button
type="button"
className={styles.ring + " " + styles.isDemo}
ref={rootRef}
aria-pressed={pressed}
>
<svg className={styles.ringIcon} viewBox="0 0 120 120" aria-hidden="true">
<circle className={styles.ringTrack} cx={60} cy={60} r={R} />
<circle className={styles.ringFill} ref={fillRef} cx={60} cy={60} r={R} />
<text className={styles.ringPct} x={60} y={66} textAnchor="middle">{pct}%</text>
</svg>
<span className={styles.ringLabel}>일치율 확인</span>
</button>
<p className={styles.ringStatus}>{status}</p>
</div>
</main>
);
}07 · リングオーバーシュート — 円形プログレスリングが角度の値で満ちていき、完了の瞬間に慣性の値の分だけ少し行き過ぎてから戻る方式 — 計測 累積面積 1.799% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function autoLoop(ts) {
if (!isDemo || reduce) return;
if (!autoLastTs) autoLastTs = ts;
var dt = Math.min(ts - autoLastTs, 32);
autoLastTs = ts;
if (!autoFlipAt) autoFlipAt = ts + FLIP_MS;
if (ts >= autoFlipAt) { autoTargetHigh = !autoTargetHigh; autoFlipAt = ts + FLIP_MS; }
var target = autoTargetHigh ? AUTO_HIGH : AUTO_LOW;
var force = (target - autoPos) * STIFFNESS - autoVel * DAMPING;
autoVel += force * dt;
autoPos += autoVel * dt;
paint(autoPos);
status.textContent = STRINGS.running;
rafId = requestAnimationFrame(autoLoop);
}
数値が語ること
07は9項目の中で累積面積が最少の1.80%でありながら、動いたフレーム数は23フレーム中23フレームと最多で、強度平均は136.8でした。自動ループの短いフリップ間隔のもとではスプリングが一度も本当に落ち着くことがなく、まれに大きく跳ねるのではなく、ほぼ全てのサンプルフレームで細いストロークとパーセント表示をわずかずつ動かし続けるためです。
08グリッドスキャングロー — スキャン領域をグリッドに分け、セルごとに異なる遅延の値で順に光る方式
36個のグリッドセルはそれぞれ行と列を足したdiagの値を持ち、render()はそれをdiag * DIAG_STEPというセルごとの遅延に変換したうえで、遅延を差し引いたローカル時間をMath.sinのパルスに渡してセルを光らせ、ピーク時にはわずかに拡大させます。遅延が行と列の合計だけで決まるため、同じ対角線上のセルは常に同じタイミングで光り、それが角から角へ光が流れるように見える理由です。顔・指紋のグリッドスキャン演出やセキュリティ認証のローディング画面に合う方式です。

<main class="stage">
<div class="grid-wrap">
<button type="button" class="grid is-demo" id="grid" aria-pressed="true">
<div class="grid__cells" id="cells" aria-hidden="true"></div>
<span class="grid__label">指紋センサースキャン</span>
</button>
<p class="grid__status" id="status">スキャン中</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 index.html 의 render() 에 있다 — 셀마다 (행+열) 대각 지연을 두고 삼각 펄스를
// 매 프레임 새로 계산해 opacity·transform 만 바꾼다. 여기는 무대·격자 모양만 맡는다.
@include stage($stage-ink);
.grid-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.grid {
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-mint; outline-offset: 4px; }
}
.grid__cells {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 4px;
padding: $sp-3;
border-radius: $r-sm;
background: rgba($subject-mint, .08);
}
.grid__cell {
width: 18px;
height: 18px;
border-radius: $r-xs;
background: $subject-mint;
opacity: .16;
will-change: opacity, transform;
}
.grid__label {
color: $subject-cream;
font-weight: 600;
font-size: 13px;
line-height: 1.2;
letter-spacing: .02em;
}
.grid__status {
margin: 0;
color: rgba($subject-cream, .68);
font-weight: 500;
font-size: 12px;
line-height: 1.2;
}
@media (max-width: 340px) {
.stage { padding: $sp-2; }
.grid-wrap { gap: $sp-2; }
.grid { padding: $sp-2 $sp-3; gap: $sp-2; }
.grid__cells { gap: 0; padding: $sp-1; }
.grid__cell { width: 11px; height: 11px; }
.grid__label { font-size: 12px; }
.grid__status { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.grid__cell { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.grid-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.grid {
appearance: none;
border: none;
background: transparent;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
cursor: pointer;
padding: 16px;
border-radius: 16px;
}
.grid:focus-visible {
outline: 2px solid #4cd4a6;
outline-offset: 4px;
}
.grid__cells {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 4px;
padding: 12px;
border-radius: 8px;
background: rgba(76, 212, 166, 0.08);
}
.grid__cell {
width: 18px;
height: 18px;
border-radius: 4px;
background: #4cd4a6;
opacity: 0.16;
will-change: opacity, transform;
}
.grid__label {
color: #fff7e6;
font-weight: 600;
font-size: 13px;
line-height: 1.2;
letter-spacing: 0.02em;
}
.grid__status {
margin: 0;
color: rgba(255, 247, 230, 0.68);
font-weight: 500;
font-size: 12px;
line-height: 1.2;
}
@media (max-width: 340px) {
.stage {
padding: 8px;
}
.grid-wrap {
gap: 8px;
}
.grid {
padding: 8px 12px;
gap: 8px;
}
.grid__cells {
gap: 0;
padding: 4px;
}
.grid__cell {
width: 11px;
height: 11px;
}
.grid__label {
font-size: 12px;
}
.grid__status {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.grid__cell {
transition: none;
}
}(function () {
// 계산 겹: 격자 대각 스태거 — 셀마다 (행+열) 로 지연을 매겨 빛이 대각선으로 번져 나간다.
// delay(row,col) = (row+col)*DIAG_STEP. 지연 목록을 미리 박아두지 않고 매 프레임 셀 좌표에서
// 직접 계산해 삼각 펄스를 얹는다 — 한 번도 멈추지 않는 진행파라 캡처 구간 어디를 잘라도
// 항상 움직임이 있다.
var COLS = 6, ROWS = 6;
var DIAG_STEP = 90; // ms, 대각선 한 칸당 지연
var CYCLE_MS = 1600; // ms, 파동이 한 바퀴 도는 주기
var PULSE_MS = 420; // ms, 셀 하나가 빛나는 지속 시간
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var root = document.getElementById('grid');
var cellsBox = document.getElementById('cells');
var status = document.getElementById('status');
var STRINGS = { on: 'スキャン中', off: 'スキャン停止' };
var isDemo = true;
var active = true;
var rafId = 0;
var startTs = 0;
var cells = [];
for (var r = 0; r < ROWS; r++) {
for (var c = 0; c < COLS; c++) {
var cell = document.createElement('span');
cell.className = 'grid__cell';
cellsBox.appendChild(cell);
cells.push({ el: cell, diag: r + c });
}
}
function pulse(local) {
if (local < 0 || local >= PULSE_MS) return 0;
return Math.sin((local / PULSE_MS) * Math.PI);
}
function render(t) {
for (var i = 0; i < cells.length; i++) {
var delay = cells[i].diag * DIAG_STEP;
var local = ((t - delay) % CYCLE_MS + CYCLE_MS) % CYCLE_MS;
var v = pulse(local);
cells[i].el.style.opacity = (0.16 + v * 0.84).toFixed(3);
cells[i].el.style.transform = 'scale(' + (1 + v * 0.22).toFixed(3) + ')';
}
}
function resetCells() {
for (var i = 0; i < cells.length; i++) {
cells[i].el.style.opacity = '0.16';
cells[i].el.style.transform = 'scale(1)';
}
}
function loop(ts) {
if (!startTs) startTs = ts;
render(ts - startTs);
rafId = requestAnimationFrame(loop);
}
function setActive(v) {
active = v;
root.setAttribute('aria-pressed', String(v));
status.textContent = v ? STRINGS.on : STRINGS.off;
cancelAnimationFrame(rafId);
if (!v) { resetCells(); return; }
if (reduce) { render(CYCLE_MS * 0.42); return; }
startTs = 0;
rafId = requestAnimationFrame(loop);
}
function human() { if (isDemo) { root.classList.remove('is-demo'); isDemo = false; } }
root.addEventListener('click', function () { human(); setActive(!active); });
root.addEventListener('keydown', function (e) {
if (e.key === ' ' || e.key === 'Enter') { human(); }
});
setActive(true);
})();// GridGlow — vanilla 판(run/371/demo/08-biometric-scan-grid-glow/index.html) 의 React 포트.
// 계산 겹(격자 대각 스태거)은 useEffect 안에 있다: 셀마다 (행+열) 로 지연을 매겨 빛이
// 대각선으로 번져 나가는 삼각 펄스를 매 프레임 다시 계산한다 — 지연을 미리 박아두지 않는다.
import { useEffect, useRef, useState } from "react";
import styles from "./GridGlow.module.scss";
type Box<T> = { current: T };
const COLS = 6, ROWS = 6;
const CELL_COUNT = COLS * ROWS;
export default function GridGlow() {
const rootRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const cellsRef = useRef([]) as unknown as Box<(HTMLSpanElement | null)[]>;
const [status, setStatus] = useState("스캔 중");
const [pressed, setPressed] = useState(true);
useEffect(() => {
const DIAG_STEP = 90;
const CYCLE_MS = 1600;
const PULSE_MS = 420;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const root = rootRef.current;
if (!root) return;
const diagOf: number[] = [];
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) diagOf.push(r + c);
}
let isDemo = true;
let active = true;
let rafId = 0;
let startTs = 0;
function pulse(local: number) {
if (local < 0 || local >= PULSE_MS) return 0;
return Math.sin((local / PULSE_MS) * Math.PI);
}
function render(t: number) {
const els = cellsRef.current;
for (let i = 0; i < els.length; i++) {
const el = els[i];
if (!el) continue;
const delay = diagOf[i] * DIAG_STEP;
const local = ((t - delay) % CYCLE_MS + CYCLE_MS) % CYCLE_MS;
const v = pulse(local);
el.style.opacity = (0.16 + v * 0.84).toFixed(3);
el.style.transform = "scale(" + (1 + v * 0.22).toFixed(3) + ")";
}
}
function resetCells() {
const els = cellsRef.current;
for (let i = 0; i < els.length; i++) {
const el = els[i];
if (!el) continue;
el.style.opacity = "0.16";
el.style.transform = "scale(1)";
}
}
function loop(ts: number) {
if (!startTs) startTs = ts;
render(ts - startTs);
rafId = requestAnimationFrame(loop);
}
function setActive(v: boolean) {
active = v;
setPressed(v);
setStatus(v ? "스캔 중" : "스캔 정지됨");
cancelAnimationFrame(rafId);
if (!v) { resetCells(); return; }
if (reduce) { render(CYCLE_MS * 0.42); return; }
startTs = 0;
rafId = requestAnimationFrame(loop);
}
function human() { if (isDemo) { root!.classList.remove(styles.isDemo); isDemo = false; } }
function onClick() { human(); setActive(!active); }
function onKeyDown(e: KeyboardEvent) { if (e.key === " " || e.key === "Enter") human(); }
root.addEventListener("click", onClick);
root.addEventListener("keydown", onKeyDown);
setActive(true);
return () => {
root.removeEventListener("click", onClick);
root.removeEventListener("keydown", onKeyDown);
cancelAnimationFrame(rafId);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.gridWrap}>
<button
type="button"
className={styles.grid + " " + styles.isDemo}
ref={rootRef}
aria-pressed={pressed}
>
<div className={styles.cells} aria-hidden="true">
{Array.from({ length: CELL_COUNT }).map((_, i) => (
<span
key={i}
className={styles.cell}
ref={(el: HTMLSpanElement | null) => { cellsRef.current[i] = el; }}
/>
))}
</div>
<span className={styles.label}>지문 센서 스캔</span>
</button>
<p className={styles.status}>{status}</p>
</div>
</main>
);
}08 · グリッドスキャングロー — スキャン領域をグリッドに分け、セルごとに異なる遅延の値で順に光る方式 — 計測 累積面積 12.0% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function render(t) {
for (var i = 0; i < cells.length; i++) {
var delay = cells[i].diag * DIAG_STEP;
var local = ((t - delay) % CYCLE_MS + CYCLE_MS) % CYCLE_MS;
var v = pulse(local);
cells[i].el.style.opacity = (0.16 + v * 0.84).toFixed(3);
cells[i].el.style.transform = 'scale(' + (1 + v * 0.22).toFixed(3) + ')';
}
}
数値が語ること
08は累積面積12.00%・強度平均76.7のまま、23フレーム中20フレームで動きが記録され——01(12.57%)に次いで9項目の中で2番目に大きい累積面積でした。36個のセルが短いトリガー窓だけでなく、繰り返しのサイクルにそって常に脈打ち続けるため、グリッドの広い対角帯がほとんど常にどこか光っている状態になるからです。
09待機呼吸 — 入力がない間アイコンの大きさの値がサインイージング曲線に沿って大きくなったり小さくなったりを繰り返す方式
スキャンや押下が行われていないあいだ、ひとつの拡大率の値が1 + AMPLITUDE * Math.sin(t * OMEGA)という式でおよそ0.91から1.10のあいだを行き来し、アイコン全体に一度に適用されます。同時に7本の稜線それぞれも、隣どうしRIDGE_PHASE_STEPだけ位相をずらした自分自身のサイン波で明るさが上下します。稜線の位相が内側ほど大きくなっていくため、すべての稜線が全く同じ式で動いているにもかかわらず、明るさそのものが中心から外側へ伝わっているように見えます。生体認証ボタンの待機状態やロック画面アイコンに合う方式です。

<main class="stage">
<div class="breathe-wrap">
<button type="button" class="breathe is-demo" id="breathe" aria-pressed="true">
<svg class="breathe__icon" id="icon" viewBox="0 0 120 130" aria-hidden="true">
<path class="breathe__ridge" d="M10,118 A50 46 0 0 1 110,118"></path>
<path class="breathe__ridge" d="M16,118 A44 41 0 0 1 104,118"></path>
<path class="breathe__ridge" d="M22,118 A38 36 0 0 1 98,118"></path>
<path class="breathe__ridge" d="M28,118 A32 31 0 0 1 92,118"></path>
<path class="breathe__ridge" d="M34,118 A26 26 0 0 1 86,118"></path>
<path class="breathe__ridge" d="M40,118 A20 21 0 0 1 80,118"></path>
<path class="breathe__ridge" d="M46,118 A14 16 0 0 1 74,118"></path>
</svg>
<span class="breathe__label">認証待機</span>
</button>
<p class="breathe__status" id="status">待機中</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 index.html 의 render() 에 있다 — scale·opacity 를 사인 함수로 매 프레임 다시
// 계산한다. 여기는 무대·모양만 맡는다.
@include stage($stage-paper);
.breathe-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.breathe {
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-lilac; outline-offset: 4px; }
}
.breathe__icon {
width: clamp(120px, 30vw, 160px);
height: auto;
overflow: visible;
transform-origin: 50% 50%; // 자기 중심에서 커진다(스스로 완결한 아이콘)
}
.breathe__ridge {
fill: none;
stroke: $subject-lilac;
stroke-width: 3;
stroke-linecap: round;
opacity: .72;
}
.breathe__label {
color: $stage-ink;
font-weight: 600;
font-size: 13px;
line-height: 1.2;
letter-spacing: .02em;
}
.breathe__status {
margin: 0;
color: rgba($stage-ink, .6);
font-weight: 500;
font-size: 12px;
line-height: 1.2;
}
@media (max-width: 340px) {
.stage { padding: $sp-2; }
.breathe-wrap { gap: $sp-2; }
.breathe { padding: $sp-2 $sp-3; gap: $sp-2; }
.breathe__icon { width: 104px; }
.breathe__label { font-size: 12px; }
.breathe__status { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.breathe__ridge { 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);
}
.breathe-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.breathe {
appearance: none;
border: none;
background: transparent;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
cursor: pointer;
padding: 16px;
border-radius: 16px;
}
.breathe:focus-visible {
outline: 2px solid #b9a0ff;
outline-offset: 4px;
}
.breathe__icon {
width: clamp(120px, 30vw, 160px);
height: auto;
overflow: visible;
transform-origin: 50% 50%;
}
.breathe__ridge {
fill: none;
stroke: #b9a0ff;
stroke-width: 3;
stroke-linecap: round;
opacity: 0.72;
}
.breathe__label {
color: #17141a;
font-weight: 600;
font-size: 13px;
line-height: 1.2;
letter-spacing: 0.02em;
}
.breathe__status {
margin: 0;
color: rgba(23, 20, 26, 0.6);
font-weight: 500;
font-size: 12px;
line-height: 1.2;
}
@media (max-width: 340px) {
.stage {
padding: 8px;
}
.breathe-wrap {
gap: 8px;
}
.breathe {
padding: 8px 12px;
gap: 8px;
}
.breathe__icon {
width: 104px;
}
.breathe__label {
font-size: 12px;
}
.breathe__status {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.breathe__ridge {
transition: none;
}
}(function () {
// 계산 겹: 사인 이징 호흡 — scale = 1 + AMPLITUDE*sin(t*OMEGA) 를 매 프레임 직접 계산해
// 아이콘 전체에 적용한다(중심 기준 확대). 능선마다 위상차(RIDGE_PHASE_STEP)를 살짝 둬
// 밝기가 안에서 바깥으로 번지는 파도처럼 보이게 한다 — 미리 만든 트윈이 아니라 매 프레임
// 삼각함수로 다시 계산한다.
var AMPLITUDE = 0.095; // 스케일 진폭(호흡 정도)
var OMEGA = 0.0026; // rad/ms — 주기 약 2400ms
var RIDGE_PHASE_STEP = 0.16;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var root = document.getElementById('breathe');
var icon = document.getElementById('icon');
var ridges = Array.prototype.slice.call(document.querySelectorAll('.breathe__ridge'));
var status = document.getElementById('status');
var STRINGS = { idle: '待機中', paused: '一時停止中' };
var isDemo = true;
var active = true;
var rafId = 0;
var startTs = 0;
function render(t) {
var s = 1 + AMPLITUDE * Math.sin(t * OMEGA);
icon.style.transform = 'scale(' + s.toFixed(4) + ')';
for (var i = 0; i < ridges.length; i++) {
var phase = t * OMEGA + i * RIDGE_PHASE_STEP;
var b = 0.5 + 0.5 * Math.sin(phase);
ridges[i].style.opacity = b.toFixed(3);
}
}
function resetIcon() {
icon.style.transform = 'scale(1)';
for (var i = 0; i < ridges.length; i++) ridges[i].style.opacity = '0.72';
}
function loop(ts) {
if (!startTs) startTs = ts;
render(ts - startTs);
rafId = requestAnimationFrame(loop);
}
function setActive(v) {
active = v;
root.setAttribute('aria-pressed', String(v));
status.textContent = v ? STRINGS.idle : STRINGS.paused;
cancelAnimationFrame(rafId);
if (!v) { resetIcon(); return; }
if (reduce) { render(0); return; }
startTs = 0;
rafId = requestAnimationFrame(loop);
}
function human() { if (isDemo) { root.classList.remove('is-demo'); isDemo = false; } }
root.addEventListener('click', function () { human(); setActive(!active); });
root.addEventListener('keydown', function (e) {
if (e.key === ' ' || e.key === 'Enter') { human(); }
});
setActive(true);
})();// IdleBreathing — vanilla 판(run/371/demo/09-biometric-scan-idle-breathing/index.html) 의 React 포트.
// 계산 겹(사인 이징 호흡)은 useEffect 안에 있다: scale = 1 + AMPLITUDE*sin(t*OMEGA) 를 매 프레임
// 직접 계산해 아이콘에 적용한다 — 능선마다 위상차를 둬 밝기가 안에서 바깥으로 번진다.
import { useEffect, useRef, useState } from "react";
import styles from "./IdleBreathing.module.scss";
type Box<T> = { current: T };
const RIDGE_D = [
"M10,118 A50 46 0 0 1 110,118",
"M16,118 A44 41 0 0 1 104,118",
"M22,118 A38 36 0 0 1 98,118",
"M28,118 A32 31 0 0 1 92,118",
"M34,118 A26 26 0 0 1 86,118",
"M40,118 A20 21 0 0 1 80,118",
"M46,118 A14 16 0 0 1 74,118",
];
export default function IdleBreathing() {
const rootRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const iconRef = useRef(null) as unknown as Box<SVGSVGElement>;
const ridgesRef = useRef([]) as unknown as Box<(SVGPathElement | null)[]>;
const [status, setStatus] = useState("대기 중");
const [pressed, setPressed] = useState(true);
useEffect(() => {
const AMPLITUDE = 0.095;
const OMEGA = 0.0026;
const RIDGE_PHASE_STEP = 0.16;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const root = rootRef.current;
const icon = iconRef.current;
if (!root || !icon) return;
let isDemo = true;
let active = true;
let rafId = 0;
let startTs = 0;
function render(t: number) {
const s = 1 + AMPLITUDE * Math.sin(t * OMEGA);
icon!.style.transform = "scale(" + s.toFixed(4) + ")";
const ridges = ridgesRef.current;
for (let i = 0; i < ridges.length; i++) {
const el = ridges[i];
if (!el) continue;
const phase = t * OMEGA + i * RIDGE_PHASE_STEP;
const b = 0.5 + 0.5 * Math.sin(phase);
el.style.opacity = b.toFixed(3);
}
}
function resetIcon() {
icon!.style.transform = "scale(1)";
const ridges = ridgesRef.current;
for (let i = 0; i < ridges.length; i++) {
const el = ridges[i];
if (el) el.style.opacity = "0.72";
}
}
function loop(ts: number) {
if (!startTs) startTs = ts;
render(ts - startTs);
rafId = requestAnimationFrame(loop);
}
function setActive(v: boolean) {
active = v;
setPressed(v);
setStatus(v ? "대기 중" : "일시 정지됨");
cancelAnimationFrame(rafId);
if (!v) { resetIcon(); return; }
if (reduce) { render(0); return; }
startTs = 0;
rafId = requestAnimationFrame(loop);
}
function human() { if (isDemo) { root!.classList.remove(styles.isDemo); isDemo = false; } }
function onClick() { human(); setActive(!active); }
function onKeyDown(e: KeyboardEvent) { if (e.key === " " || e.key === "Enter") human(); }
root.addEventListener("click", onClick);
root.addEventListener("keydown", onKeyDown);
setActive(true);
return () => {
root.removeEventListener("click", onClick);
root.removeEventListener("keydown", onKeyDown);
cancelAnimationFrame(rafId);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.breatheWrap}>
<button
type="button"
className={styles.breathe + " " + styles.isDemo}
ref={rootRef}
aria-pressed={pressed}
>
<svg className={styles.icon} ref={iconRef} viewBox="0 0 120 130" aria-hidden="true">
{RIDGE_D.map((d, i) => (
<path
key={i}
className={styles.ridge}
d={d}
ref={(el: SVGPathElement | null) => { ridgesRef.current[i] = el; }}
/>
))}
</svg>
<span className={styles.label}>인증 대기</span>
</button>
<p className={styles.status}>{status}</p>
</div>
</main>
);
}09 · 待機呼吸 — 入力がない間アイコンの大きさの値がサインイージング曲線に沿って大きくなったり小さくなったりを繰り返す方式 — 計測 累積面積 3.988% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function render(t) {
var s = 1 + AMPLITUDE * Math.sin(t * OMEGA);
icon.style.transform = 'scale(' + s.toFixed(4) + ')';
for (var i = 0; i < ridges.length; i++) {
var phase = t * OMEGA + i * RIDGE_PHASE_STEP;
var b = 0.5 + 0.5 * Math.sin(phase);
ridges[i].style.opacity = b.toFixed(3);
}
}
数値が語ること
09は9項目の中で強度平均が最も低い23.9にとどまりながら、累積面積3.99%・23フレーム中21フレームで動きが記録されました。呼吸のような拡大率は止まることがありませんが、揺れ幅そのものは小さく——大きな塗り替えではなく、ゆるやかなサイズ変化と稜線の淡いフェードにとどまるため、ほとんどのフレームがわずかに動きながらも1フレームあたりの変化量は小さいままです。
値が揺れと明るさを変える基準 — 進捗値・圧力値・減衰値
zipにはこの9つの場面がまとめて入っており、開くパスワードはxysfva3xのひとつだけで、項目ごとに別のパスワードを探す必要はありません。
生体認証 アイコンのUIというと、ひとつのスピナーが「お待ちください」を表すだけになりがちですが、この9つのうち3つの項目はそれぞれ全く異なる基準に反応しています。01の稜線の明るさはスキャン進捗の値そのものに反応します——apply()が読む唯一の入力はビームのscanY位置で、スイープ全体に何ミリ秒かかろうと、稜線はビームが通過した順番どおりに明るくなります。05のリップルの振幅は代わりに押す圧力の値に反応します——PointerEvent.pressureが毎フレームリングの揺れ幅を決めるため、軽く触れたときと強く押したときでは同じ32点の波でも揺れる量が大きく変わります。03の揺れのオフセットはそれとは対照的に、減衰の値だけに反応します——SHAKE_DAMPが決めるのは揺れが0に収まるまでの時間だけで、最初に振れる振幅の大きさには関わりません。デモを標準よりも大きく揺らしたいときと早く止めたいときで、変えるべき数値がはっきり分かれているということです。
進捗・圧力・減衰という3つの基準の違いは、この指紋認証 ボタンのアニメーションを実際のフォームに組み込むときにこそ意味を持ちます。ログイン 画面 デザイン9選 — 会員登録からセッション切れまでは会員登録・ログイン・セッション切れという画面の流れ全体を扱うもので、各画面はこの9つの場面のように毎フレームの物理値ではなく、ステップの段階を反映しています。本人確認 画面UI 9選 — そのまま使える認証カードは本人確認画面という静的なレイアウト構造を並べたもので、ボタンひとつの連続したフィードバック値とは軸が異なります。ログイン 失敗 ロック 9選 — 値が計算で絞られていく9つの方式は残り試行回数という段階的な整数値をゼロへ向けて数えるもので、この9つの生体認証の場面が毎フレーム読み続ける連続的な減衰値やスプリング値とは性質が違います。
どこで壊れるか — 素早い再試行、低スペック端末、reduced-motion
生体認証スキャンボタンのシミュレーションは、実際のタイミングと実際のハードウェアが絡むと振る舞いが変わる場面が3つあります。
揺れが収まる前の再試行
03のtrigger()は冒頭でif (running) return;を実行しており、最初の失敗の揺れがまだ終わっていないあいだに来た2回目の失敗は、アニメーションを再スタートさせずに黙って無視されます。失敗した直後にもう一度タップした訪問者には、最初の揺れがそのまま最後まで減衰していく様子だけが見え、2回目の失敗はアプリケーションの状態には存在しても、独自の見た目としては現れません。
フレーム落ちの中のスプリング物理
07のautoLoop()は自身の経過時間をMath.min(ts - autoLastTs, 32)で32ミリ秒に丸めているため、実際には100ミリ秒かかった重いフレームでも、スプリングはあたかも32ミリ秒しか経っていないかのように進みます。FLIP_MSはスプリングの状態にかかわらず480ミリ秒ごとに目標を切り替え続けるため、フレーム落ちがひどい端末ではリングが次の切り替えまでにどちらの目標にも追いつけません。
reduced-motionはスイープを省略せず圧縮する
01はDUR = reduce ? 1 : (BASE_MS / SWEEP_SPEED)という式を使っており、prefers-reduced-motion: reduceはクリックで始まるスイープそのものを取り除くのではなく、上から下までの全経路をわずか1ミリ秒に圧縮します。動きを減らす設定を選んだ訪問者がボタンをクリックしても、アニメーションが完全に省略されるのではなく、すべての稜線がほぼ一瞬で光る様子が見えることになります。
3つのバリエーションとアクセシビリティ
スイープ速度を1.4から0.6へ下げると進捗の値がゆっくり上がり、稜線が順に明るくなる流れがはっきり見えます。SHAKE_DAMPを18から8へ下げるとdecayMs(=SHAKE_DAMP×40)が720msから320msへ縮まり、揺れの値が0に収まるまでの時間が短くなって、失敗の振動は一、二回跳ねてすぐ止まります。最大半径の値を40から70へ上げるとパルスがより広く広がってから薄くなり、待機状態がより余裕をもって見えます。
| バリエーション | 変わるもの | 変わらないもの |
|---|---|---|
| スキャンラインを遅くする | SWEEP_SPEED 1.4 → 0.6 |
GLOW_BAND、BASE_MS、稜線の頂点配置そのもの |
| 失敗の揺れを早く収める | SHAKE_DAMP 18 → 8 |
AMPLITUDE、FREQ、SETTLE_MS |
| 待機パルスを広げる | PULSE_MAX_R 40 → 70 |
TAU、LAUNCH_GAP、リングスロットの割り当て順 |
9項目とも視覚的なアイコン部分(01〜07・09は自身のSVGアイコン、08はグリッドセルのdiv)にaria-hidden="true"を付けており、アイコンやセルはあくまで装飾的なフィードバックで情報そのものではないためです。代わりに目に見えるステータス文(「待機中」「スキャン中」「認証失敗」「認証成功」など)を用意し、値が変わるたびにそのテキスト自体を書き換えます。切り替え可能なボタンにはすべてaria-pressedが付き、9項目全てがSpaceキーやEnterキーによるkeydownもクリックと並んで受け付けるため(05はpointerdown・pointermove・pointerupも加わります)、マウスで見る失敗の揺れや成功のモーフィング、スイープと同じ体験をキーボードからも呼び出せます。
FAQ
スマホ 指紋認証 ボタンのようなUIに、本物のセンサーは必要ですか
必要ありません。9つとも実際の指紋・顔認証APIを呼び出さないシミュレーションで、スキャン進捗・押す圧力・一致率はすべてクリックやキー操作、PointerEvent.pressureから来る単純な数値です。展開するzipにも同じ数値ベースのコードがそのまま入っています。
この指紋認証 アニメーションの9パターンは、それぞれ別ファイルですか、それとも設定違いの1コンポーネントですか
9つとも独立したファイルです。項目ごとに自分自身の定数と自分自身のステップ関数・描画関数を持っており、上のコード欄はテンプレートに数値を差し込んで生成した出力ではなく、そのファイルから直接抜き出した抜粋です。
06のホログラムフリッカーは、なぜ実際のノイズライブラリを使っていないのですか
使う必要がないためです。周波数の異なる3つのサイン波を重み付けして合計するnoise()だけで、人の目には繰り返しに気づきにくい値が十分に作れます。外部のノイズライブラリを省くことで、このセットの他の項目と同じくファイル単体で完結させています。
Sources: MDN — CSS アニメーション · MDN — Web Animations API · MDN — stroke-dashoffset · MDN — Window.requestAnimationFrame()







