07Skipped Step That Fades and Sinks Into a Ghost
A skipped step loses its color and settles slightly lower, so it reads apart from the path you're actually taking.
Best forSetup wizard with optional steps
STEP_SKIP_SINK_PXSTEP_SKIP_ALPHA_MINpaintSkip

<main class="stage">
<div class="gh is-demo" id="gh">
<ol class="gh__row" id="ghRow" role="list" aria-label="Setup wizard steps">
<li class="gh__node" data-i="0"><span class="gh__circle is-active" id="n0">1</span><span class="gh__label">Terms</span></li>
<li class="gh__connector" id="k0"></li>
<li class="gh__node" data-i="1"><span class="gh__circle" id="n1">2</span><span class="gh__label">Profile</span></li>
<li class="gh__connector" id="k1"></li>
<li class="gh__node" data-i="2"><span class="gh__aura" id="ghAura"></span><span class="gh__circle" id="n2">3</span><span class="gh__label">Notifications</span><span class="gh__optional">Optional</span></li>
<li class="gh__connector" id="k2"></li>
<li class="gh__node" data-i="3"><span class="gh__circle" id="n3">4</span><span class="gh__label">Done</span></li>
</ol>
<p class="gh__status" id="ghStatus" aria-live="polite">Step 1: Terms in progress</p>
<div class="gh__controls">
<button type="button" class="gh__btn" id="ghSkip">Skip Notifications</button>
<button type="button" class="gh__btn gh__btn--primary" id="ghNext">Next</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 건너뛴 노드의 하강 거리·투명도는 easeOutCubic(t) 로 보간한 ratio 를
// STEP_SKIP_SINK_PX·STEP_SKIP_ALPHA_MIN 에 곱한 계산값이다. 같은 ratio 를
// 좌우 연결선 투명도에도 옮겨 값 하나가 넓은 면으로 퍼진다.
$duration: $dur-quick;
$easing: $ease-out;
$color: $stage-paper;
@include stage($stage-orange);
.gh {
display: grid;
gap: $sp-4;
justify-items: center;
width: min(90vw, 300px);
}
.gh__row {
display: flex;
align-items: flex-start;
list-style: none;
padding: 0;
margin: 0;
width: 100%;
}
.gh__node {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
flex: 0 0 auto;
}
.gh__circle {
position: relative;
z-index: 1;
width: 38px;
height: 38px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 15px;
font-weight: 700;
color: $stage-paper;
background: rgba(255, 255, 255, .2);
border: 2px solid rgba(255, 255, 255, .4);
}
.gh__circle.is-active { background: $stage-paper; color: $subject-orange-deep; border-color: $stage-paper; }
.gh__circle.is-done { background: $subject-ink; border-color: $subject-ink; }
.gh__circle.is-skipped { background: rgba(255, 255, 255, .12); border-color: rgba(255, 255, 255, .2); }
// 고스트 아우라 — paintSkip() 이 같은 ratio 로 크기·불투명도를 계산해 얹는 레이어
.gh__aura {
position: absolute;
top: 50%;
left: 50%;
z-index: 0;
width: 0;
height: 0;
border-radius: 50%;
background: radial-gradient(circle, rgba(255, 255, 255, .55), rgba(255, 255, 255, 0) 70%);
transform: translate(-50%, -50%);
pointer-events: none;
opacity: 0;
}
.gh__label { font-size: 10px; color: rgba(255, 255, 255, .82); font-weight: 600; }
.gh__optional { font-size: 8px; color: rgba(255, 255, 255, .55); }
.gh__connector {
flex: 1 1 auto;
height: 5px;
min-width: 16px;
margin: 20px 4px 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .28);
}
.gh__status {
margin: 0;
font-size: 11px;
color: $stage-paper;
font-weight: 700;
}
.gh__controls { display: flex; gap: $sp-2; flex-wrap: wrap; justify-content: center; }
.gh__btn {
border: 1px solid rgba(255, 255, 255, .4);
background: transparent;
color: $stage-paper;
border-radius: $r-control;
padding: $sp-2 $sp-3;
font-size: 11px;
font-weight: 600;
cursor: pointer;
}
.gh__btn--primary { background: $subject-ink; border-color: $subject-ink; }
.gh__btn:disabled { opacity: .4; cursor: default; }
.gh__btn:focus-visible {
outline: 2px solid $stage-paper;
outline-offset: 2px;
}
@media (max-width: 340px) {
.gh__circle { width: 30px; height: 30px; font-size: 12px; }
.gh__connector { height: 4px; margin-top: 16px; }
}
@media (prefers-reduced-motion: reduce) {
.gh__circle { 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);
}
.gh {
display: grid;
gap: 16px;
justify-items: center;
width: min(90vw, 300px);
}
.gh__row {
display: flex;
align-items: flex-start;
list-style: none;
padding: 0;
margin: 0;
width: 100%;
}
.gh__node {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
flex: 0 0 auto;
}
.gh__circle {
position: relative;
z-index: 1;
width: 38px;
height: 38px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 15px;
font-weight: 700;
color: #ffffff;
background: rgba(255, 255, 255, 0.2);
border: 2px solid rgba(255, 255, 255, 0.4);
}
.gh__circle.is-active {
background: #ffffff;
color: #c93000;
border-color: #ffffff;
}
.gh__circle.is-done {
background: #17141a;
border-color: #17141a;
}
.gh__circle.is-skipped {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.2);
}
.gh__aura {
position: absolute;
top: 50%;
left: 50%;
z-index: 0;
width: 0;
height: 0;
border-radius: 50%;
background: radial-gradient(circle, rgba(255, 255, 255, 0.55), rgba(255, 255, 255, 0) 70%);
transform: translate(-50%, -50%);
pointer-events: none;
opacity: 0;
}
.gh__label {
font-size: 10px;
color: rgba(255, 255, 255, 0.82);
font-weight: 600;
}
.gh__optional {
font-size: 8px;
color: rgba(255, 255, 255, 0.55);
}
.gh__connector {
flex: 1 1 auto;
height: 5px;
min-width: 16px;
margin: 20px 4px 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.28);
}
.gh__status {
margin: 0;
font-size: 11px;
color: #ffffff;
font-weight: 700;
}
.gh__controls {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
}
.gh__btn {
border: 1px solid rgba(255, 255, 255, 0.4);
background: transparent;
color: #ffffff;
border-radius: 12px;
padding: 8px 12px;
font-size: 11px;
font-weight: 600;
cursor: pointer;
}
.gh__btn--primary {
background: #17141a;
border-color: #17141a;
}
.gh__btn:disabled {
opacity: 0.4;
cursor: default;
}
.gh__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
@media (max-width: 340px) {
.gh__circle {
width: 30px;
height: 30px;
font-size: 12px;
}
.gh__connector {
height: 4px;
margin-top: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.gh__circle {
transition: none;
}
}(function () {
var STEP_SKIP_SINK_PX = 16;
var STEP_SKIP_ALPHA_MIN = 0.4;
var STEP_SKIP_TWEEN_MS = 320;
var STEP_COUNT = 4;
var LABELS = ['Terms', 'Profile', 'Notifications', 'Done'];
var STATUS_TEXTS = ['Step 1: Terms in progress', 'Step 2: Profile in progress', 'Step 3: Notifications in progress', 'Step 4: Done in progress'];
var OPTIONAL_INDEX = 2;
var AURA_MAX_PX = 170;
var root = document.getElementById('gh');
var status = document.getElementById('ghStatus');
var nextBtn = document.getElementById('ghNext');
var skipBtn = document.getElementById('ghSkip');
var ghAura = document.getElementById('ghAura');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var circles = [0, 1, 2, 3].map(function (i) { return document.getElementById('n' + i); });
var connectors = [0, 1, 2].map(function (i) { return document.getElementById('k' + i); });
var current = 0;
var skipped = false;
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
function applySkinkTween(fromRatio, toRatio) {
if (reduce) { paintSkip(toRatio); return; }
var start = null;
function step(now) {
if (start === null) start = now;
var t = clamp((now - start) / STEP_SKIP_TWEEN_MS, 0, 1);
var r = fromRatio + (toRatio - fromRatio) * easeOutCubic(t);
paintSkip(r);
if (t < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
function paintSkip(ratio) {
var node = circles[OPTIONAL_INDEX];
var small = window.innerWidth < 340;
var sink = STEP_SKIP_SINK_PX * ratio;
var alpha = 1 - (1 - STEP_SKIP_ALPHA_MIN) * ratio;
node.style.transform = 'translateY(' + sink.toFixed(2) + 'px)';
node.style.opacity = alpha.toFixed(3);
// 계산된 시각 레이어 1: 가라앉음 값 ratio 를 노드 그림자(블러·퍼짐)에 그대로 전파
var shadowBlur = (small ? 3 : 6) + ratio * (small ? 8 : 22);
var shadowSpread = ratio * (small ? 1 : 4);
node.style.boxShadow = ratio > 0.01
? ('0 ' + (2 + sink * 0.4).toFixed(1) + 'px ' + shadowBlur.toFixed(1) + 'px ' + shadowSpread.toFixed(1) + 'px rgba(0,0,0,' + (0.3 * ratio).toFixed(3) + ')')
: 'none';
// 계산된 시각 레이어 2: 같은 ratio 로 커지고 옅어지는 고스트 아우라(320px 이하에서는 축소해 BD9 유지)
var auraMax = small ? AURA_MAX_PX * 0.3 : AURA_MAX_PX;
var auraSize = auraMax * ratio;
ghAura.style.width = auraSize.toFixed(1) + 'px';
ghAura.style.height = auraSize.toFixed(1) + 'px';
ghAura.style.opacity = (0.5 * ratio).toFixed(3);
connectors[OPTIONAL_INDEX - 1].style.opacity = (1 - 0.35 * ratio).toFixed(3);
connectors[OPTIONAL_INDEX].style.opacity = (1 - 0.35 * ratio).toFixed(3);
}
function render() {
circles.forEach(function (el, i) {
el.classList.toggle('is-active', i === current);
el.classList.toggle('is-done', i < current);
el.classList.toggle('is-skipped', i === OPTIONAL_INDEX && skipped);
});
status.textContent = current >= STEP_COUNT - 1 ? 'All steps complete' : STATUS_TEXTS[current];
skipBtn.disabled = current !== OPTIONAL_INDEX || skipped;
nextBtn.disabled = current === STEP_COUNT - 1;
}
function toggleSkip(next) {
if (next === skipped) return;
skipped = next;
applySkinkTween(next ? 0 : 1, next ? 1 : 0);
render();
}
function advance() {
current = clamp(current + 1, 0, STEP_COUNT - 1);
render();
}
skipBtn.addEventListener('click', function () { root.classList.remove('is-demo'); toggleSkip(true); advance(); });
nextBtn.addEventListener('click', function () { root.classList.remove('is-demo'); advance(); });
root.addEventListener('keydown', function (e) {
if (e.key === 'ArrowRight') { root.classList.remove('is-demo'); advance(); e.preventDefault(); }
});
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; current = 0; skipped = false; paintSkip(0); render(); }
if (phase === 0 && t > 0.2) { current = OPTIONAL_INDEX; render(); toggleSkip(true); phase = 1; }
if (phase === 1 && t > 0.58) { toggleSkip(false); phase = 2; }
if (phase === 2 && t > 0.96) { toggleSkip(true); phase = 3; }
if (phase === 3 && t > 1.34) { toggleSkip(false); phase = 4; }
if (phase === 4 && t > 1.7) { current = 0; render(); phase = 5; }
}
requestAnimationFrame(tick);
}
render();
requestAnimationFrame(tick);
})();// StepSkipGhostSink — vanilla 판(07-step-precision-skip-ghost-sink) 의 React 포트.
// 값 하나: 건너뛴 노드의 하강 거리·투명도는 easeOutCubic(t) 로 보간한 ratio 를
// STEP_SKIP_SINK_PX·STEP_SKIP_ALPHA_MIN 에 곱한 계산값이다 — 같은 ratio 가 좌우 연결선
// 투명도까지 옮겨져 값 하나가 넓은 면으로 퍼진다.
import { useEffect, useRef, useState } from "react";
import styles from "./StepSkipGhostSink.module.scss";
type Box<T> = { current: T };
const STEP_SKIP_SINK_PX = 16;
const STEP_SKIP_ALPHA_MIN = 0.4;
const STEP_SKIP_TWEEN_MS = 320;
const STEP_COUNT = 4;
const LABELS = ["약관", "프로필", "알림설정", "완료"];
const STATUS_TEXTS = ["1단계: 약관 진행 중", "2단계: 프로필 진행 중", "3단계: 알림설정 진행 중", "4단계: 완료 진행 중"];
const STATUS_ALL_DONE = "모든 단계 완료";
const OPTIONAL_INDEX = 2;
const AURA_MAX_PX = 170;
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
function easeOutCubic(t: number): number {
return 1 - Math.pow(1 - t, 3);
}
export default function StepSkipGhostSink() {
const [current, setCurrent] = useState(0);
const [skipped, setSkipped] = useState(false);
const [reduce, setReduce] = useState(false);
const currentRef = useRef<number>(0) as Box<number>;
const skippedRef = useRef<boolean>(false) as Box<boolean>;
const demoRef = useRef<boolean>(true) as Box<boolean>;
const circleRefs = useRef<Array<HTMLSpanElement | null>>([null, null, null, null]) as Box<Array<HTMLSpanElement | null>>;
const connectorRefs = useRef<Array<HTMLDivElement | null>>([null, null, null]) as Box<Array<HTMLDivElement | null>>;
const auraRef = 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 paintSkip(ratio: number): void {
const node = circleRefs.current[OPTIONAL_INDEX];
const small = window.innerWidth < 340;
const sink = STEP_SKIP_SINK_PX * ratio;
const alpha = 1 - (1 - STEP_SKIP_ALPHA_MIN) * ratio;
if (node) {
node.style.transform = "translateY(" + sink.toFixed(2) + "px)";
node.style.opacity = alpha.toFixed(3);
// 계산된 시각 레이어 1: 가라앉음 값 ratio 를 노드 그림자(블러·퍼짐)에 그대로 전파
const shadowBlur = (small ? 3 : 6) + ratio * (small ? 8 : 22);
const shadowSpread = ratio * (small ? 1 : 4);
node.style.boxShadow = ratio > 0.01
? "0 " + (2 + sink * 0.4).toFixed(1) + "px " + shadowBlur.toFixed(1) + "px " + shadowSpread.toFixed(1) + "px rgba(0, 0, 0, " + (0.3 * ratio).toFixed(3) + ")"
: "none";
}
// 계산된 시각 레이어 2: 같은 ratio 로 커지고 옅어지는 고스트 아우라(320px 이하에서는 축소해 BD9 유지)
const aura = auraRef.current;
if (aura) {
const auraMax = small ? AURA_MAX_PX * 0.3 : AURA_MAX_PX;
const auraSize = auraMax * ratio;
aura.style.width = auraSize.toFixed(1) + "px";
aura.style.height = auraSize.toFixed(1) + "px";
aura.style.opacity = (0.5 * ratio).toFixed(3);
}
const left = connectorRefs.current[OPTIONAL_INDEX - 1];
const right = connectorRefs.current[OPTIONAL_INDEX];
const connOpacity = (1 - 0.35 * ratio).toFixed(3);
if (left) left.style.opacity = connOpacity;
if (right) right.style.opacity = connOpacity;
}
function applySinkTween(fromRatio: number, toRatio: number): void {
if (reduce) { paintSkip(toRatio); return; }
let start = 0;
function step(now: number): void {
if (start === 0) start = now;
const t = clamp((now - start) / STEP_SKIP_TWEEN_MS, 0, 1);
const r = fromRatio + (toRatio - fromRatio) * easeOutCubic(t);
paintSkip(r);
if (t < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
function toggleSkip(next: boolean): void {
if (next === skippedRef.current) return;
skippedRef.current = next;
setSkipped(next);
applySinkTween(next ? 0 : 1, next ? 1 : 0);
}
function advance(): void {
const next = clamp(currentRef.current + 1, 0, STEP_COUNT - 1);
currentRef.current = next;
setCurrent(next);
}
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; currentRef.current = 0; setCurrent(0); skippedRef.current = false; setSkipped(false); paintSkip(0); }
if (phase === 0 && t > 0.2) { currentRef.current = OPTIONAL_INDEX; setCurrent(OPTIONAL_INDEX); toggleSkip(true); phase = 1; }
if (phase === 1 && t > 0.58) { toggleSkip(false); phase = 2; }
if (phase === 2 && t > 0.96) { toggleSkip(true); phase = 3; }
if (phase === 3 && t > 1.34) { toggleSkip(false); phase = 4; }
if (phase === 4 && t > 1.7) { currentRef.current = 0; setCurrent(0); phase = 5; }
}
frameRef.current = requestAnimationFrame(tick);
}
frameRef.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frameRef.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [reduce]);
function onKeyDown(e: { key: string; preventDefault: () => void }): void {
if (e.key === "ArrowRight") { stopDemo(); advance(); e.preventDefault(); }
}
const statusText = current >= STEP_COUNT - 1 ? STATUS_ALL_DONE : STATUS_TEXTS[current];
return (
<main className={styles.stage}>
<div className={styles.gh} onKeyDown={onKeyDown}>
<ol className={styles.row} role="list" aria-label="설정 마법사 단계">
{LABELS.map((label, i) => (
<li key={label} style={{ display: "contents" }}>
<div className={styles.node}>
{i === OPTIONAL_INDEX && <span className={styles.aura} ref={auraRef} />}
<span
className={[styles.circle, i === current ? styles.circleActive : "", i < current ? styles.circleDone : "", i === OPTIONAL_INDEX && skipped ? styles.circleSkipped : ""].join(" ")}
ref={(el: HTMLSpanElement | null) => { circleRefs.current[i] = el; }}
>
{i + 1}
</span>
<span className={styles.label}>{label}</span>
{i === OPTIONAL_INDEX && <span className={styles.optional}>선택</span>}
</div>
{i < STEP_COUNT - 1 && <div className={styles.connector} ref={(el: HTMLDivElement | null) => { connectorRefs.current[i] = el; }} />}
</li>
))}
</ol>
<p className={styles.status} aria-live="polite">{statusText}</p>
<div className={styles.controls}>
<button
type="button"
className={styles.btn}
disabled={current !== OPTIONAL_INDEX || skipped}
onClick={() => { stopDemo(); toggleSkip(true); advance(); }}
>
알림설정 건너뛰기
</button>
<button
type="button"
className={[styles.btn, styles.btnPrimary].join(" ")}
disabled={current === STEP_COUNT - 1}
onClick={() => { stopDemo(); advance(); }}
>
다음
</button>
</div>
</div>
</main>
);
}07 Skipped Step That Fades and Sinks Into a Ghost — Measured area 12.444% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · Step Indicator UI: 9 Physics-Based Patterns
The post this demo lives in
Step Indicator UI: 9 Physics-Based PatternsNine step indicator ui builds: The designer loved item 03's self-drawing checkmark. The developer fought item 05 — one f See all demos in this post
If you like this effect - similar demos
Inputs02 Form height that measures each step and flows to it9 Multi Step Form UI Patterns — Branch and Resume
Inputs03 Windows spectrum — a color picker that splits a 2D position into saturation and lightness9 Color Picker Design Patterns (Copy-Paste)
Inputs01 Quantity change that tints the line total and the grand total togetherCheckout Page Design — 9 Cart Patterns, No Library