09最後のステップでリングが広がって消える完了演出
最後のステップが終わると、円の縁が外側へ波のように広がりながら薄くなって消える。
使いどころ決済完了ステップの演出
STEP_BURST_RING_COUNTSTEP_BURST_SPEED_PX_SSTEP_BURST_STAGGER_S

<main class="stage">
<div class="cb is-demo" id="cb">
<ol class="cb__row" id="cbRow" role="list" aria-label="決済完了ステップ">
<li class="cb__node" id="n0"><span class="cb__circle is-done">1</span><span class="cb__label">注文</span></li>
<li class="cb__connector is-done"></li>
<li class="cb__node" id="n1"><span class="cb__circle is-done">2</span><span class="cb__label">決済</span></li>
<li class="cb__connector is-done"></li>
<li class="cb__node" id="n2">
<span class="cb__cardGlow" id="cardGlow"></span>
<span class="cb__burstLayer" id="burstLayer"></span>
<span class="cb__circle" id="finalCircle">3</span>
<span class="cb__label">完了</span>
</li>
</ol>
<p class="cb__status" id="cbStatus" aria-live="polite">決済を完了してください</p>
<div class="cb__controls">
<button type="button" class="cb__btn cb__btn--primary" id="cbFinish">決済を完了</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 링마다 반경 = BASE_RADIUS_PX + STEP_BURST_SPEED_PX_S × 경과시간, 알파 = 1 - 경과/DURATION 을
// 매 프레임 계산해 그린다. STEP_BURST_STAGGER_S 만큼 어긋난 시작 시각으로 세 겹이 순서대로 번진다.
$duration: $dur-enter;
$easing: $ease-out;
$color: $subject-cream;
@include stage($stage-ink);
.cb {
display: grid;
gap: $sp-4;
justify-items: center;
width: min(90vw, 280px);
}
.cb__row {
display: flex;
align-items: flex-start;
list-style: none;
padding: 0;
margin: 0;
width: 100%;
}
.cb__node {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-1;
flex: 0 0 auto;
}
.cb__burstLayer {
position: absolute;
top: 17px;
left: 50%;
width: 0;
height: 0;
pointer-events: none;
}
.cb__cardGlow {
position: absolute;
top: 17px;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: radial-gradient(circle, rgba($subject-mint, .5), rgba($subject-mint, 0) 72%);
transform: translate(-50%, -50%);
pointer-events: none;
opacity: 0;
}
.cb__ring {
position: absolute;
top: 0;
left: 0;
border-radius: 50%;
border: 5px solid $subject-mint;
opacity: 0;
}
.cb__circle {
position: relative;
z-index: 1;
width: 34px;
height: 34px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 13px;
font-weight: 700;
color: $subject-cream;
background: rgba(255, 255, 255, .14);
border: 2px solid rgba(255, 255, 255, .28);
}
.cb__circle.is-done { background: $subject-mint; border-color: $subject-mint; color: $stage-ink; }
.cb__label { font-size: 10px; color: rgba(255, 255, 255, .68); font-weight: 600; }
.cb__connector {
flex: 1 1 auto;
height: 3px;
min-width: 22px;
margin: 16px 4px 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .18);
}
.cb__connector.is-done { background: $subject-mint; }
.cb__status {
margin: 0;
font-size: 11px;
color: rgba(255, 255, 255, .78);
font-weight: 600;
}
.cb__controls { display: flex; gap: $sp-2; }
.cb__btn {
border: 1px solid rgba(255, 255, 255, .3);
background: transparent;
color: $subject-cream;
border-radius: $r-control;
padding: $sp-2 $sp-4;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.cb__btn--primary { background: $subject-mint; border-color: $subject-mint; color: $stage-ink; }
.cb__btn:focus-visible {
outline: 2px solid $subject-mint;
outline-offset: 2px;
}
@media (max-width: 340px) {
.cb__circle { width: 28px; height: 28px; font-size: 11px; }
.cb__burstLayer { top: 14px; }
.cb__connector { margin-top: 16px; }
}
@media (prefers-reduced-motion: reduce) {
.cb__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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cb {
display: grid;
gap: 16px;
justify-items: center;
width: min(90vw, 280px);
}
.cb__row {
display: flex;
align-items: flex-start;
list-style: none;
padding: 0;
margin: 0;
width: 100%;
}
.cb__node {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
flex: 0 0 auto;
}
.cb__burstLayer {
position: absolute;
top: 17px;
left: 50%;
width: 0;
height: 0;
pointer-events: none;
}
.cb__cardGlow {
position: absolute;
top: 17px;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: radial-gradient(circle, rgba(76, 212, 166, 0.5), rgba(76, 212, 166, 0) 72%);
transform: translate(-50%, -50%);
pointer-events: none;
opacity: 0;
}
.cb__ring {
position: absolute;
top: 0;
left: 0;
border-radius: 50%;
border: 5px solid #4cd4a6;
opacity: 0;
}
.cb__circle {
position: relative;
z-index: 1;
width: 34px;
height: 34px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 13px;
font-weight: 700;
color: #fff7e6;
background: rgba(255, 255, 255, 0.14);
border: 2px solid rgba(255, 255, 255, 0.28);
}
.cb__circle.is-done {
background: #4cd4a6;
border-color: #4cd4a6;
color: #17141a;
}
.cb__label {
font-size: 10px;
color: rgba(255, 255, 255, 0.68);
font-weight: 600;
}
.cb__connector {
flex: 1 1 auto;
height: 3px;
min-width: 22px;
margin: 16px 4px 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.18);
}
.cb__connector.is-done {
background: #4cd4a6;
}
.cb__status {
margin: 0;
font-size: 11px;
color: rgba(255, 255, 255, 0.78);
font-weight: 600;
}
.cb__controls {
display: flex;
gap: 8px;
}
.cb__btn {
border: 1px solid rgba(255, 255, 255, 0.3);
background: transparent;
color: #fff7e6;
border-radius: 12px;
padding: 8px 16px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.cb__btn--primary {
background: #4cd4a6;
border-color: #4cd4a6;
color: #17141a;
}
.cb__btn:focus-visible {
outline: 2px solid #4cd4a6;
outline-offset: 2px;
}
@media (max-width: 340px) {
.cb__circle {
width: 28px;
height: 28px;
font-size: 11px;
}
.cb__burstLayer {
top: 14px;
}
.cb__connector {
margin-top: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.cb__ring {
transition: none;
}
}(function () {
var STEP_BURST_RING_COUNT = 3;
var STEP_BURST_SPEED_PX_S = 85;
var STEP_BURST_DURATION_S = 0.78;
var STEP_BURST_STAGGER_S = 0.14;
var BASE_RADIUS_PX = 17;
var root = document.getElementById('cb');
var status = document.getElementById('cbStatus');
var finishBtn = document.getElementById('cbFinish');
var burstLayer = document.getElementById('burstLayer');
var finalCircle = document.getElementById('finalCircle');
var cardGlow = document.getElementById('cardGlow');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var burstAt = null;
var completed = false;
var ringEls = [];
for (var i = 0; i < STEP_BURST_RING_COUNT; i++) {
var r = document.createElement('span');
r.className = 'cb__ring';
burstLayer.appendChild(r);
ringEls.push(r);
}
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function finish() {
completed = true;
burstAt = performance.now();
finalCircle.classList.add('is-done');
status.textContent = '決済が完了しました';
if (reduce) { ringEls.forEach(function (el) { el.style.opacity = '0'; }); cardGlow.style.opacity = '0'; }
}
function reset() {
completed = false;
burstAt = null;
finalCircle.classList.remove('is-done');
finalCircle.style.boxShadow = 'none';
status.textContent = '決済を完了してください';
ringEls.forEach(function (el) { el.style.opacity = '0'; });
cardGlow.style.width = '0px';
cardGlow.style.height = '0px';
cardGlow.style.opacity = '0';
}
finishBtn.addEventListener('click', function () { root.classList.remove('is-demo'); if (!completed) finish(); });
var startTime = null;
var phase = 0;
function tick(now) {
if (startTime === null) startTime = now;
var t = (now - startTime) / 1000 % 2.0;
if (root.classList.contains('is-demo')) {
if (t < 0.05 && phase !== 0) { phase = 0; reset(); }
if (phase === 0 && t > 0.22) { finish(); phase = 1; }
if (phase === 1 && t > 1.05) { reset(); phase = 2; }
if (phase === 2 && t > 1.15) { finish(); phase = 3; }
}
if (!reduce && burstAt !== null) {
var small = window.innerWidth < 340;
var scale = small ? 0.42 : 1;
var elapsed = (now - burstAt) / 1000;
var anyAlive = false;
var primaryRatio = 0;
ringEls.forEach(function (el, idx) {
var tt = elapsed - idx * STEP_BURST_STAGGER_S;
if (tt < 0) { el.style.opacity = '0'; anyAlive = true; return; }
if (tt > STEP_BURST_DURATION_S) { el.style.opacity = '0'; return; }
anyAlive = true;
var radius = (BASE_RADIUS_PX + STEP_BURST_SPEED_PX_S * tt) * scale;
var alpha = 1 - tt / STEP_BURST_DURATION_S;
var d = radius * 2;
el.style.width = d.toFixed(1) + 'px';
el.style.height = d.toFixed(1) + 'px';
el.style.marginLeft = (-radius).toFixed(1) + 'px';
el.style.marginTop = (-radius).toFixed(1) + 'px';
el.style.opacity = alpha.toFixed(3);
// 계산된 시각 레이어: 첫 링(지연 없음)의 반경 진행률·알파를 카드 배경 톤·노드 글로우로 같은 함수로 전파
if (idx === 0) primaryRatio = alpha * clamp(tt / STEP_BURST_DURATION_S, 0, 1);
});
if (primaryRatio > 0) {
var glowMax = (small ? 90 : 220) * primaryRatio;
cardGlow.style.width = glowMax.toFixed(1) + 'px';
cardGlow.style.height = glowMax.toFixed(1) + 'px';
cardGlow.style.opacity = (0.4 * primaryRatio).toFixed(3);
finalCircle.style.boxShadow = '0 0 ' + (8 + primaryRatio * 26).toFixed(1) + 'px rgba(120, 224, 173, ' + (0.55 * primaryRatio).toFixed(3) + ')';
}
if (!anyAlive) burstAt = null;
}
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
})();// StepCompletionRingBurst — vanilla 판(09-step-precision-completion-ring-burst) 의 React 포트.
// 값 하나: 링마다 반경 = BASE_RADIUS_PX + STEP_BURST_SPEED_PX_S × 경과시간, 알파 = 1 - 경과/DURATION 을
// 매 프레임 계산해 그린다. STEP_BURST_STAGGER_S 만큼 어긋난 시작 시각으로 세 겹이 순서대로 번진다.
import { useEffect, useRef, useState } from "react";
import styles from "./StepCompletionRingBurst.module.scss";
type Box<T> = { current: T };
const STEP_BURST_RING_COUNT = 3;
const STEP_BURST_SPEED_PX_S = 85;
const STEP_BURST_DURATION_S = 0.78;
const STEP_BURST_STAGGER_S = 0.14;
const BASE_RADIUS_PX = 17;
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
export default function StepCompletionRingBurst() {
const [completed, setCompleted] = useState(false);
const [status, setStatus] = useState("결제를 완료해 주세요");
const [reduce, setReduce] = useState(false);
const completedRef = useRef<boolean>(false) as Box<boolean>;
const burstAtRef = useRef<number | null>(null) as Box<number | null>;
const demoRef = useRef<boolean>(true) as Box<boolean>;
const ringRefs = useRef<Array<HTMLSpanElement | null>>([null, null, null]) as Box<Array<HTMLSpanElement | null>>;
const cardGlowRef = useRef<HTMLSpanElement | null>(null) as Box<HTMLSpanElement | null>;
const finalCircleRef = useRef<HTMLSpanElement | null>(null) as Box<HTMLSpanElement | null>;
const frameRef = useRef<number>(0) as Box<number>;
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
function finish(): void {
completedRef.current = true;
burstAtRef.current = performance.now();
setCompleted(true);
setStatus("결제가 완료되었습니다");
if (reduce) {
ringRefs.current.forEach((el) => { if (el) el.style.opacity = "0"; });
if (cardGlowRef.current) cardGlowRef.current.style.opacity = "0";
}
}
function reset(): void {
completedRef.current = false;
burstAtRef.current = null;
setCompleted(false);
setStatus("결제를 완료해 주세요");
ringRefs.current.forEach((el) => { if (el) el.style.opacity = "0"; });
if (cardGlowRef.current) { cardGlowRef.current.style.width = "0px"; cardGlowRef.current.style.height = "0px"; cardGlowRef.current.style.opacity = "0"; }
if (finalCircleRef.current) finalCircleRef.current.style.boxShadow = "none";
}
function stopDemo(): void { demoRef.current = false; }
useEffect(() => {
let startTime = 0;
let phase = 0;
function tick(now: number): void {
if (startTime === 0) startTime = now;
const t = ((now - startTime) / 1000) % 2.0;
if (demoRef.current) {
if (t < 0.05 && phase !== 0) { phase = 0; reset(); }
if (phase === 0 && t > 0.22) { finish(); phase = 1; }
if (phase === 1 && t > 1.05) { reset(); phase = 2; }
if (phase === 2 && t > 1.15) { finish(); phase = 3; }
}
if (!reduce && burstAtRef.current !== null) {
const small = window.innerWidth < 340;
const scale = small ? 0.42 : 1;
const elapsed = (now - burstAtRef.current) / 1000;
let anyAlive = false;
let primaryRatio = 0;
ringRefs.current.forEach((el, idx) => {
if (!el) return;
const tt = elapsed - idx * STEP_BURST_STAGGER_S;
if (tt < 0) { el.style.opacity = "0"; anyAlive = true; return; }
if (tt > STEP_BURST_DURATION_S) { el.style.opacity = "0"; return; }
anyAlive = true;
const radius = (BASE_RADIUS_PX + STEP_BURST_SPEED_PX_S * tt) * scale;
const alpha = 1 - tt / STEP_BURST_DURATION_S;
const d = radius * 2;
el.style.width = d.toFixed(1) + "px";
el.style.height = d.toFixed(1) + "px";
el.style.marginLeft = (-radius).toFixed(1) + "px";
el.style.marginTop = (-radius).toFixed(1) + "px";
el.style.opacity = alpha.toFixed(3);
// 계산된 시각 레이어: 첫 링(지연 없음)의 반경 진행률·알파를 카드 배경 톤·노드 글로우로 같은 함수로 전파
if (idx === 0) primaryRatio = alpha * clamp(tt / STEP_BURST_DURATION_S, 0, 1);
});
if (primaryRatio > 0) {
const glowMax = (small ? 90 : 220) * primaryRatio;
if (cardGlowRef.current) {
cardGlowRef.current.style.width = glowMax.toFixed(1) + "px";
cardGlowRef.current.style.height = glowMax.toFixed(1) + "px";
cardGlowRef.current.style.opacity = (0.4 * primaryRatio).toFixed(3);
}
if (finalCircleRef.current) {
finalCircleRef.current.style.boxShadow = "0 0 " + (8 + primaryRatio * 26).toFixed(1) + "px rgba(120, 224, 173, " + (0.55 * primaryRatio).toFixed(3) + ")";
}
}
if (!anyAlive) burstAtRef.current = null;
}
frameRef.current = requestAnimationFrame(tick);
}
frameRef.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frameRef.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [reduce]);
return (
<main className={styles.stage}>
<div className={styles.cb}>
<ol className={styles.row} role="list" aria-label="결제 완료 단계">
<li className={styles.node}>
<span className={[styles.circle, styles.circleDone].join(" ")}>1</span>
<span className={styles.label}>주문</span>
</li>
<li className={[styles.connector, styles.connectorDone].join(" ")} />
<li className={styles.node}>
<span className={[styles.circle, styles.circleDone].join(" ")}>2</span>
<span className={styles.label}>결제</span>
</li>
<li className={[styles.connector, styles.connectorDone].join(" ")} />
<li className={styles.node}>
<span className={styles.cardGlow} ref={cardGlowRef} />
<span className={styles.burstLayer}>
{[0, 1, 2].map((i) => (
<span className={styles.ring} key={i} ref={(el: HTMLSpanElement | null) => { ringRefs.current[i] = el; }} />
))}
</span>
<span
className={[styles.circle, completed ? styles.circleDone : ""].join(" ")}
ref={finalCircleRef}
>
3
</span>
<span className={styles.label}>완료</span>
</li>
</ol>
<p className={styles.status} aria-live="polite">{status}</p>
<div className={styles.controls}>
<button
type="button"
className={[styles.btn, styles.btnPrimary].join(" ")}
onClick={() => { stopDemo(); if (!completedRef.current) finish(); }}
>
결제 완료
</button>
</div>
</div>
</main>
);
}09 最後のステップでリングが広がって消える完了演出 — 計測 累積面積 14.96% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · ステップインジケーター デザイン 9パターン — 物理計算で仕上げる進行段階
このデモが属する記事
ステップインジケーター デザイン 9パターン — 物理計算で仕上げる進行段階このステップインジケーター デザインは9通りある。デザイナーは03番のチェックマークが描かれる瞬間を気に入り、開発者は05番の摩擦値ひとつのせいでスクラバーが毎回違う位置で止まることに手を焼いた。09番では二人とも頷いた — リングの広がり この記事のデモをすべて見る


