09待機呼吸 — 入力がない間アイコンの大きさの値がサインイージング曲線に沿って大きくなったり小さくなったりを繰り返す方式
大きさの値がサイン曲線に沿って1.0と1.06の間を行き来し、操作がない間も画面が完全に静止して見えないようにします。
使いどころ生体認証ボタンの待機状態、ロック画面アイコン
RIDGE_PHASE_STEP$subject-lilacOMEGA

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

