02현재 단계 원이 숨 쉬듯 커졌다 작아지는 표시
지금 진행 중인 단계의 원은 크기가 점점 잦아드는 진폭으로 반복해 커졌다 작아진다.
쓰임체크아웃 진행 단계
STEP_BREATH_DECAYSTEP_BREATH_FREQSTEP_BREATH_AMPLITUDE

<main class="stage">
<div class="bp is-demo" id="bp">
<ol class="bp__row" id="bpRow" role="list" aria-label="체크아웃 진행 단계">
<li class="bp__node" data-i="0"><span class="bp__ring"></span><span class="bp__circle" id="n0">1</span><span class="bp__label">배송지</span></li>
<li class="bp__connector"></li>
<li class="bp__node" data-i="1"><span class="bp__ring"></span><span class="bp__circle" id="n1">2</span><span class="bp__label">결제</span></li>
<li class="bp__connector"></li>
<li class="bp__node" data-i="2"><span class="bp__ring"></span><span class="bp__circle" id="n2">3</span><span class="bp__label">확인</span></li>
</ol>
<p class="bp__status" id="bpStatus" aria-live="polite">진행 중: 배송지 (1/3)</p>
<div class="bp__controls">
<button type="button" class="bp__btn" id="bpPrev">이전</button>
<button type="button" class="bp__btn bp__btn--primary" id="bpNext">다음</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 활성 노드의 확대 비율이 감쇠 사인(진폭 STEP_BREATH_AMPLITUDE·감쇠 STEP_BREATH_DECAY·
// 각진동수 STEP_BREATH_FREQ)을 매 프레임 계산한 값이다 — 진폭이 exp(-k·t) 로 점점 잦아든다.
$duration: $dur-loop;
$easing: $ease-out;
$color: $stage-ink;
@include stage($stage-yellow);
.bp {
--breathGlow: 0;
display: grid;
gap: $sp-4;
justify-items: center;
width: min(92vw, 380px);
background-image: radial-gradient(circle at 50% 28%, rgba(10, 79, 232, calc(var(--breathGlow, 0) * .16)), transparent 70%);
border-radius: $r-card;
}
.bp__row {
display: flex;
align-items: flex-start;
list-style: none;
padding: 0;
margin: 0;
width: 100%;
}
.bp__node {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-1;
flex: 0 0 auto;
}
.bp__ring {
position: absolute;
top: -8px;
left: 50%;
width: 58px;
height: 58px;
margin-left: -29px;
border-radius: 50%;
border: 2px solid $subject-blue-deep;
opacity: 0;
pointer-events: none;
}
.bp__circle {
--breathGlow: 0;
position: relative;
z-index: 1;
width: 44px;
height: 44px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 15px;
font-weight: 700;
color: $stage-ink;
background: $stage-paper;
border: 2px solid rgba(23, 20, 26, .2);
box-shadow: 0 0 calc(var(--breathGlow, 0) * 18px) rgba(10, 79, 232, calc(var(--breathGlow, 0) * .7));
cursor: pointer;
transform-origin: center;
}
.bp__circle.is-active {
background: $subject-blue-deep;
border-color: $subject-blue-deep;
color: $stage-paper;
}
.bp__circle.is-done {
background: $stage-ink;
border-color: $stage-ink;
color: $stage-yellow;
}
.bp__label {
font-size: 10px;
color: rgba(23, 20, 26, .7);
font-weight: 600;
}
.bp__connector {
flex: 1 1 auto;
height: 6px;
min-width: 20px;
margin: 20px 4px 0;
border-radius: $r-pill;
background: rgba(23, 20, 26, .18);
}
.bp__status {
margin: 0;
font-size: 11px;
color: rgba(23, 20, 26, .78);
font-weight: 700;
}
.bp__controls { display: flex; gap: $sp-2; }
.bp__btn {
border: 1px solid rgba(23, 20, 26, .3);
background: transparent;
color: $stage-ink;
border-radius: $r-control;
padding: $sp-2 $sp-4;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.bp__btn--primary {
background: $subject-blue-deep;
border-color: $subject-blue-deep;
color: $stage-paper;
}
.bp__btn:disabled { opacity: .4; cursor: default; }
.bp__btn:focus-visible,
.bp__circle:focus-visible {
outline: 2px solid $subject-blue-deep;
outline-offset: 2px;
}
@media (max-width: 340px) {
.bp__circle { width: 34px; height: 34px; font-size: 12px; }
.bp__ring { width: 48px; height: 48px; margin-left: -24px; top: -6px; }
.bp__label { font-size: 9px; }
.bp__connector { height: 4px; margin: 16px 4px 0; }
}
@media (prefers-reduced-motion: reduce) {
.bp__circle { transition: none; }
.bp__ring { display: 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);
}
.bp {
--breathGlow: 0;
display: grid;
gap: 16px;
justify-items: center;
width: min(92vw, 380px);
background-image: radial-gradient(circle at 50% 28%, rgba(10, 79, 232, calc(var(--breathGlow, 0) * 0.16)), transparent 70%);
border-radius: 16px;
}
.bp__row {
display: flex;
align-items: flex-start;
list-style: none;
padding: 0;
margin: 0;
width: 100%;
}
.bp__node {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
flex: 0 0 auto;
}
.bp__ring {
position: absolute;
top: -8px;
left: 50%;
width: 58px;
height: 58px;
margin-left: -29px;
border-radius: 50%;
border: 2px solid #0a4fe8;
opacity: 0;
pointer-events: none;
}
.bp__circle {
--breathGlow: 0;
position: relative;
z-index: 1;
width: 44px;
height: 44px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 15px;
font-weight: 700;
color: #17141a;
background: #ffffff;
border: 2px solid rgba(23, 20, 26, 0.2);
box-shadow: 0 0 calc(var(--breathGlow, 0) * 18px) rgba(10, 79, 232, calc(var(--breathGlow, 0) * 0.7));
cursor: pointer;
transform-origin: center;
}
.bp__circle.is-active {
background: #0a4fe8;
border-color: #0a4fe8;
color: #ffffff;
}
.bp__circle.is-done {
background: #17141a;
border-color: #17141a;
color: #ffd23f;
}
.bp__label {
font-size: 10px;
color: rgba(23, 20, 26, 0.7);
font-weight: 600;
}
.bp__connector {
flex: 1 1 auto;
height: 6px;
min-width: 20px;
margin: 20px 4px 0;
border-radius: 999px;
background: rgba(23, 20, 26, 0.18);
}
.bp__status {
margin: 0;
font-size: 11px;
color: rgba(23, 20, 26, 0.78);
font-weight: 700;
}
.bp__controls {
display: flex;
gap: 8px;
}
.bp__btn {
border: 1px solid rgba(23, 20, 26, 0.3);
background: transparent;
color: #17141a;
border-radius: 12px;
padding: 8px 16px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.bp__btn--primary {
background: #0a4fe8;
border-color: #0a4fe8;
color: #ffffff;
}
.bp__btn:disabled {
opacity: 0.4;
cursor: default;
}
.bp__btn:focus-visible,
.bp__circle:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
@media (max-width: 340px) {
.bp__circle {
width: 34px;
height: 34px;
font-size: 12px;
}
.bp__ring {
width: 48px;
height: 48px;
margin-left: -24px;
top: -6px;
}
.bp__label {
font-size: 9px;
}
.bp__connector {
height: 4px;
margin: 16px 4px 0;
}
}
@media (prefers-reduced-motion: reduce) {
.bp__circle {
transition: none;
}
.bp__ring {
display: none;
}
}(function () {
var STEP_BREATH_DECAY = 2.4;
var STEP_BREATH_FREQ = 9.5;
var STEP_BREATH_AMPLITUDE = 0.16;
var STEP_BREATH_IMPULSE_S = 1.5;
var STEP_COUNT = 3;
var LABELS = ['배송지', '결제', '확인'];
var STATUS_TEXTS = ['진행 중: 배송지 (1/3)', '진행 중: 결제 (2/3)', '진행 중: 확인 (3/3)'];
var root = document.getElementById('bp');
var status = document.getElementById('bpStatus');
var prevBtn = document.getElementById('bpPrev');
var nextBtn = document.getElementById('bpNext');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var circles = [0, 1, 2].map(function (i) { return document.getElementById('n' + i); });
var nodes = Array.prototype.slice.call(root.querySelectorAll('.bp__node'));
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
var current = 0;
var impulseAt = 0;
function render() {
nodes.forEach(function (el, i) {
var circle = circles[i];
circle.classList.toggle('is-done', i < current);
circle.classList.toggle('is-active', i === current);
el.querySelector('.bp__ring').classList.toggle('is-active', i === current);
});
status.textContent = STATUS_TEXTS[current];
prevBtn.disabled = current === 0;
nextBtn.disabled = current === STEP_COUNT - 1;
}
function goTo(next) {
next = clamp(next, 0, STEP_COUNT - 1);
if (next === current) return;
current = next;
impulseAt = performance.now();
render();
}
prevBtn.addEventListener('click', function () { root.classList.remove('is-demo'); goTo(current - 1); });
nextBtn.addEventListener('click', function () { root.classList.remove('is-demo'); goTo(current + 1); });
root.addEventListener('keydown', function (e) {
if (e.key === 'ArrowRight') { root.classList.remove('is-demo'); goTo(current + 1); e.preventDefault(); }
else if (e.key === 'ArrowLeft') { root.classList.remove('is-demo'); goTo(current - 1); e.preventDefault(); }
});
circles.forEach(function (el, i) { el.addEventListener('click', function () { root.classList.remove('is-demo'); goTo(i); }); });
function breathScale(nowMs) {
var tt = (nowMs - impulseAt) / 1000;
if (tt < 0) return 1;
var envelope = Math.exp(-STEP_BREATH_DECAY * tt);
return 1 + STEP_BREATH_AMPLITUDE * envelope * Math.sin(STEP_BREATH_FREQ * tt);
}
var startTime = null;
var phase = 0;
function tick(now) {
if (startTime === null) { startTime = now; impulseAt = now; }
if (root.classList.contains('is-demo')) {
var t = (now - startTime) / 1000 % 2;
if (t < 0.05 && phase !== 0) { phase = 0; }
if (phase === 0 && t > 0.2) { goTo((current + 1) % STEP_COUNT); phase = 1; }
if (phase === 1 && t > 0.85) { goTo((current + 1) % STEP_COUNT); phase = 2; }
if (phase === 2 && t > 1.5) { goTo((current + 1) % STEP_COUNT); phase = 3; }
}
// 감쇠 사인(envelope)값을 원 확대·후광 링뿐 아니라 카드 배경 톤에도 같은 함수로 퍼뜨린다
var tt0 = (now - impulseAt) / 1000;
var envelope0 = tt0 < 0 ? 0 : Math.max(0, Math.exp(-STEP_BREATH_DECAY * tt0));
root.style.setProperty('--breathGlow', envelope0.toFixed(3));
if (!reduce) {
var activeCircle = circles[current];
var s = breathScale(now);
activeCircle.style.transform = 'scale(' + s.toFixed(4) + ')';
activeCircle.style.setProperty('--breathGlow', envelope0.toFixed(3));
var ringEl = nodes[current].querySelector('.bp__ring');
var ringEnvelope = envelope0;
ringEl.style.opacity = (ringEnvelope * 0.5).toFixed(3);
ringEl.style.transform = 'scale(' + (1 + (1 - ringEnvelope) * 0.6).toFixed(3) + ')';
} else {
circles[current].style.transform = 'scale(1)';
}
requestAnimationFrame(tick);
}
render();
requestAnimationFrame(tick);
})();// StepBreathingPulseNode — vanilla 판(02-step-precision-node-breathing-pulse) 의 React 포트.
// 값 하나: 활성 원의 확대 비율 = 1 + AMPLITUDE·exp(-DECAY·t)·sin(FREQ·t) — 감쇠 사인을 매 프레임 계산.
// 같은 감쇠값(envelope)을 링의 opacity·scale 에도 옮겨 값 하나가 두 시각 요소로 퍼진다.
import { useEffect, useRef, useState } from "react";
import styles from "./StepBreathingPulseNode.module.scss";
type Box<T> = { current: T };
const STEP_BREATH_DECAY = 2.4;
const STEP_BREATH_FREQ = 9.5;
const STEP_BREATH_AMPLITUDE = 0.16;
const STEP_COUNT = 3;
const LABELS = ["배송지", "결제", "확인"];
const STATUS_TEXTS = ["진행 중: 배송지 (1/3)", "진행 중: 결제 (2/3)", "진행 중: 확인 (3/3)"];
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
export default function StepBreathingPulseNode() {
const [current, setCurrent] = useState(0);
const [reduce, setReduce] = useState(false);
const currentRef = useRef<number>(0) as Box<number>;
const demoRef = useRef<boolean>(true) as Box<boolean>;
const impulseAtRef = useRef<number>(0) as Box<number>;
const circleRefs = useRef<Array<HTMLButtonElement | null>>([null, null, null]) as Box<Array<HTMLButtonElement | null>>;
const ringRefs = useRef<Array<HTMLSpanElement | null>>([null, null, null]) as Box<Array<HTMLSpanElement | null>>;
const rootRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const frameRef = useRef<number>(0) as Box<number>;
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
function goTo(next: number): void {
const clamped = clamp(next, 0, STEP_COUNT - 1);
if (clamped === currentRef.current) return;
currentRef.current = clamped;
impulseAtRef.current = performance.now();
setCurrent(clamped);
}
function stopDemo(): void { demoRef.current = false; }
function breathScale(nowMs: number): number {
const tt = (nowMs - impulseAtRef.current) / 1000;
if (tt < 0) return 1;
const envelope = Math.exp(-STEP_BREATH_DECAY * tt);
return 1 + STEP_BREATH_AMPLITUDE * envelope * Math.sin(STEP_BREATH_FREQ * tt);
}
useEffect(() => {
let startTime = 0;
let phase = 0;
function tick(now: number): void {
if (startTime === 0) { startTime = now; impulseAtRef.current = now; }
if (demoRef.current) {
const t = ((now - startTime) / 1000) % 2;
if (t < 0.05 && phase !== 0) phase = 0;
if (phase === 0 && t > 0.2) { goTo((currentRef.current + 1) % STEP_COUNT); phase = 1; }
if (phase === 1 && t > 0.85) { goTo((currentRef.current + 1) % STEP_COUNT); phase = 2; }
if (phase === 2 && t > 1.5) { goTo((currentRef.current + 1) % STEP_COUNT); phase = 3; }
}
const idx = currentRef.current;
const circle = circleRefs.current[idx];
const ring = ringRefs.current[idx];
const tt = (now - impulseAtRef.current) / 1000;
const envelope = tt < 0 ? 0 : Math.max(0, Math.exp(-STEP_BREATH_DECAY * tt));
if (rootRef.current) rootRef.current.style.setProperty("--breathGlow", envelope.toFixed(3));
if (!reduce) {
if (circle) {
circle.style.transform = "scale(" + breathScale(now).toFixed(4) + ")";
circle.style.setProperty("--breathGlow", envelope.toFixed(3));
}
if (ring) {
ring.style.opacity = (envelope * 0.5).toFixed(3);
ring.style.transform = "scale(" + (1 + (1 - envelope) * 0.6).toFixed(3) + ")";
}
} else if (circle) {
circle.style.transform = "scale(1)";
}
frameRef.current = requestAnimationFrame(tick);
}
frameRef.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce]);
function onKeyDown(e: { key: string; preventDefault: () => void }): void {
if (e.key === "ArrowRight") { stopDemo(); goTo(current + 1); e.preventDefault(); }
else if (e.key === "ArrowLeft") { stopDemo(); goTo(current - 1); e.preventDefault(); }
}
return (
<main className={styles.stage}>
<div className={styles.bp} onKeyDown={onKeyDown} ref={(el: HTMLDivElement | null) => { rootRef.current = el; }}>
<ol className={styles.row} role="list" aria-label="체크아웃 진행 단계">
{LABELS.map((label, i) => (
<li key={label} style={{ display: "contents" }}>
<div className={styles.node}>
<span className={[styles.ring, i === current ? styles.ringActive : ""].join(" ")} ref={(el: HTMLSpanElement | null) => { ringRefs.current[i] = el; }} />
<button
type="button"
className={[styles.circle, i === current ? styles.circleActive : "", i < current ? styles.circleDone : ""].join(" ")}
ref={(el: HTMLButtonElement | null) => { circleRefs.current[i] = el; }}
onClick={() => { stopDemo(); goTo(i); }}
>
{i + 1}
</button>
<span className={styles.label}>{label}</span>
</div>
{i < STEP_COUNT - 1 && <div className={styles.connector} />}
</li>
))}
</ol>
<p className={styles.status} aria-live="polite">{STATUS_TEXTS[current]}</p>
<div className={styles.controls}>
<button type="button" className={styles.btn} disabled={current === 0} onClick={() => { stopDemo(); goTo(current - 1); }}>이전</button>
<button type="button" className={[styles.btn, styles.btnPrimary].join(" ")} disabled={current === STEP_COUNT - 1} onClick={() => { stopDemo(); goTo(current + 1); }}>다음</button>
</div>
</div>
</main>
);
}02 현재 단계 원이 숨 쉬듯 커졌다 작아지는 표시 — 측정 누적면적 23.564% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 진행 단계 ui 9가지 — 스텝 인디케이터를 물리 계산으로 다듬기
이 데모가 속한 글
진행 단계 ui 9가지 — 스텝 인디케이터를 물리 계산으로 다듬기진행 단계 ui 아홉 가지를 한 페이지에 모았다. 디자이너는 03번 체크 표시가 그려지는 순간을 마음에 들어 했고, 개발자는 05번의 마찰 값 하나 때문에 스크러버가 매번 다른 자리에서 멈춰 애를 먹었다. 09번에서 이 글의 데모 전부 보기


