06Status badge timeline that lights up stage by stage
Submit, review, and approve stack vertically, the connector fills up to the current stage with scaleY, each stage change snaps in a steps(1, end) cut, and the active stage carries aria-current.
Best forthe progress marker on a document approval screen, processing stages on an application
scaleYsteps(1, end)aria-current

<main class="stage">
<section class="st is-demo" id="st" aria-label="Approval progress">
<header class="st__h">
<span class="st__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><path d="M160 216h-.4a8.1 8.1 0 0 1-7.1-5.2l-57.2-150l-32 70.5A8 8 0 0 1 56 136H24a8 8 0 0 1 0-16h26.8l37.9-83.3a8.2 8.2 0 0 1 7.6-4.7a8 8 0 0 1 7.2 5.2L161 188.1l31.8-63.7a8.2 8.2 0 0 1 7.2-4.4h32a8 8 0 0 1 0 16h-27.1l-37.7 75.6a8.2 8.2 0 0 1-7.2 4.4Z"/></svg></span>
<h2 class="st__t">Autumn campaign proposal</h2>
<span class="st__phase" id="st-phase" data-at="0">
<i class="st__p st__p--1" aria-hidden="true">Submit stage</i>
<i class="st__p st__p--2" aria-hidden="true">Review stage</i>
<i class="st__p st__p--3" aria-hidden="true">Approve stage</i>
</span>
<span class="st__sr" id="st-live" aria-live="polite">Submit stage</span>
</header>
<ol class="st__tl" id="st-tl">
<li class="st__row" data-row="1">
<span class="st__rail" aria-hidden="true"><i class="st__fill"></i></span>
<span class="st__dot" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="m223.69 42.18l-58.22 192a8 8 0 0 1-14.92 1.25L108 148l-87.42-42.55a8 8 0 0 1 1.25-14.92l192-58.22a8 8 0 0 1 9.86 9.87" opacity=".2"/><path d="M227.32 28.68a16 16 0 0 0-15.66-4.08h-.15L19.57 82.84a16 16 0 0 0-2.49 29.8L102 154l41.3 84.87a15.86 15.86 0 0 0 14.44 9.13q.69 0 1.38-.06a15.88 15.88 0 0 0 14-11.51l58.2-191.94v-.15a16 16 0 0 0-4-15.66m-69.49 203.17l-.05.14v-.07l-40.06-82.3l48-48a8 8 0 0 0-11.31-11.31l-48 48l-82.33-40.06h-.07h.14L216 40Z"/></g></svg></span>
<span class="st__lb">Submit<time class="st__tm">09:12</time></span>
</li>
<li class="st__row" data-row="2">
<span class="st__rail" aria-hidden="true"><i class="st__fill"></i></span>
<span class="st__dot" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></g></svg></span>
<span class="st__lb">Review<time class="st__tm">10:05</time></span>
</li>
<li class="st__row" data-row="3">
<span class="st__rail st__rail--end" aria-hidden="true"><i class="st__fill"></i></span>
<span class="st__dot" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M174 47.75a254.19 254.19 0 0 0-41.45-38.3a8 8 0 0 0-9.18 0A254.36 254.36 0 0 0 82 47.75C54.51 79.32 40 112.6 40 144a88 88 0 0 0 176 0c0-31.4-14.51-64.68-42-96.25M128 216a72.08 72.08 0 0 1-72-72c0-57.23 55.47-99.58 72-110.93c16.53 11.35 72 53.7 72 110.93a72.08 72.08 0 0 1-72 72"/></g></svg></span>
<span class="st__lb">Approve<time class="st__tm">waiting</time></span>
</li>
</ol>
<button class="st__next" type="button" id="st-next">Next stage</button>
</section>
</main>@use "sass:color";
@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
$line: color.mix($subject-cream, $stage-ink, 16%);
$soft: color.mix($subject-cream, $stage-ink, 8%);
.st {
box-sizing: border-box;
width: min(304px, 92vw);
padding: $sp-3;
border: 1px solid $line;
border-radius: $r-card;
background: $stage-ink;
color: $subject-cream;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.3;
}
.st__h {
display: flex;
align-items: center;
gap: $sp-1;
margin: 0 0 $sp-2;
}
.st__ic { display: inline-flex; width: 14px; height: 14px; color: $subject-mint; }
.st__t {
margin: 0;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.st__phase {
position: relative;
margin-left: auto;
min-width: 52px;
padding: 0 $sp-2;
border-radius: $r-pill;
background: $color;
color: #ffffff;
font-size: 10px;
font-weight: 700;
font-style: normal;
line-height: 1.7;
text-align: center;
}
.st__p { position: absolute; inset: 0; visibility: hidden; font-style: normal; }
.st__phase[data-at="0"] .st__p--1,
.st__phase[data-at="1"] .st__p--2,
.st__phase[data-at="2"] .st__p--3 { visibility: visible; }
.st__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// ── 세로 타임라인 ─────────────────────────────────
.st__tl {
display: grid;
margin: 0;
padding: 0;
list-style: none;
}
.st__row {
display: grid;
grid-template-columns: 24px 1fr;
align-items: center;
column-gap: $sp-2;
}
.st__rail {
position: relative;
justify-self: center;
width: 2px;
height: 34px;
margin: $sp-1 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .16);
}
.st__rail--end { background: transparent; }
.st__fill {
position: absolute;
inset: 0;
border-radius: $r-pill;
background: $subject-mint;
transform-origin: top; // 규칙8: 세로 레일 위에서 채움
transform: scaleY(0);
}
.st__dot {
position: absolute;
left: 50%;
top: 50%;
z-index: 1;
display: inline-grid;
place-items: center;
width: 22px;
height: 22px;
border: 1px solid $line;
border-radius: 50%;
background: $stage-ink;
color: rgba(255, 255, 255, .4);
transform: translate(-50%, -50%);
}
.st__dot svg { width: 12px; height: 12px; }
.st__lb {
display: inline-flex;
align-items: baseline;
gap: $sp-2;
color: rgba(255, 255, 255, .45);
font-weight: 700;
}
.st__tm { color: rgba(255, 255, 255, .72); font-size: 10px; font-weight: 400; }
// 정적(사람이 조작하는) 경로 — 지금 단계와 끝난 단계
.st__row.is-now .st__dot { border-color: $color; background: $color; color: #ffffff; }
.st__row.is-now .st__dot svg { width: 12px; height: 12px; }
.st__row.is-now .st__lb { color: $subject-cream; }
.st__row.is-now .st__rail .st__fill { transform: scaleY(1); }
.st__row.is-done .st__dot { border-color: $subject-mint; color: $subject-mint; }
.st__row.is-done .st__lb { color: rgba(255, 255, 255, .75); }
.st__row.is-done .st__rail .st__fill { transform: scaleY(1); }
.st__next {
display: inline-flex;
align-items: center;
margin: $sp-2 0 0;
padding: $sp-1 $sp-3;
border: 1px solid $line;
border-radius: $r-pill;
background: transparent;
color: rgba(255, 255, 255, .72);
font-family: inherit;
font-size: 12px;
font-weight: 700;
line-height: 1.6;
cursor: pointer;
}
.st__next:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.st__next:active { transform: scale(.94); }
// ── 격자 미리보기 전용 2초 루프 ─────────────────────
// 단계 넘어감은 steps 컷으로 한 프레임에 자르고, 이음선 채움과 점 맥박이 프레임을 채운다
@keyframes st-fill-1 {
0% { transform: scaleY(0); }
28%, 87% { transform: scaleY(1); }
88%, 100% { transform: scaleY(0); }
}
@keyframes st-fill-2 {
0%, 27% { transform: scaleY(0); }
60%, 87% { transform: scaleY(1); }
88%, 100% { transform: scaleY(0); }
}
@keyframes st-dot-1 {
0% { border-color: $color; background: $color; color: #ffffff; }
28%, 100% { border-color: $subject-mint; background: $stage-ink; color: $subject-mint; }
}
@keyframes st-dot-2 {
0%, 27% { border-color: $line; background: $stage-ink; color: rgba(255, 255, 255, .4); }
28%, 59% { border-color: $color; background: $color; color: #ffffff; }
60%, 100% { border-color: $subject-mint; background: $stage-ink; color: $subject-mint; }
}
@keyframes st-dot-3 {
0%, 59% { border-color: $line; background: $stage-ink; color: rgba(255, 255, 255, .4); }
60%, 87% { border-color: $color; background: $color; color: #ffffff; }
88%, 100% { border-color: $line; background: $stage-ink; color: rgba(255, 255, 255, .4); }
}
@keyframes st-lb-1 {
0% { color: $subject-cream; }
28%, 100% { color: rgba(255, 255, 255, .75); }
}
@keyframes st-lb-2 {
0%, 27% { color: rgba(255, 255, 255, .45); }
28%, 59% { color: $subject-cream; }
60%, 100% { color: rgba(255, 255, 255, .75); }
}
@keyframes st-lb-3 {
0%, 59% { color: rgba(255, 255, 255, .45); }
60%, 87% { color: $subject-cream; }
88%, 100% { color: rgba(255, 255, 255, .45); }
}
@keyframes st-now-pulse {
0%, 100% { transform: translate(-50%, -50%) scale(1); }
50% { transform: translate(-50%, -50%) scale(1.12); }
}
@keyframes st-p-1 { 0%, 27% { visibility: visible; } 28%, 100% { visibility: hidden; } }
@keyframes st-p-2 { 0%, 27% { visibility: hidden; } 28%, 59% { visibility: visible; } 60%, 100% { visibility: hidden; } }
@keyframes st-p-3 { 0%, 59% { visibility: hidden; } 60%, 87% { visibility: visible; } 88%, 100% { visibility: hidden; } }
@keyframes st-next-breath {
0%, 26% { transform: translateY(0); }
30% { transform: translateY(2px); }
34%, 58% { transform: translateY(0); }
62% { transform: translateY(2px); }
66%, 86% { transform: translateY(0); }
87%, 100% { transform: translateY(0); }
}
.st.is-demo {
.st__row[data-row="1"] .st__fill { animation-name: st-fill-1; }
.st__row[data-row="2"] .st__fill { animation-name: st-fill-2; }
.st__fill {
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.st__row[data-row="1"] .st__dot { animation-name: st-dot-1; }
.st__row[data-row="2"] .st__dot { animation-name: st-dot-2; }
.st__row[data-row="3"] .st__dot { animation-name: st-dot-3; }
.st__dot {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.st__row[data-row="1"] .st__lb { animation-name: st-lb-1; }
.st__row[data-row="2"] .st__lb { animation-name: st-lb-2; }
.st__row[data-row="3"] .st__lb { animation-name: st-lb-3; }
.st__lb {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.st__p--1 { animation-name: st-p-1; }
.st__p--2 { animation-name: st-p-2; }
.st__p--3 { animation-name: st-p-3; }
.st__p {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.st__next {
animation-name: st-next-breath;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
}
@media (max-width: 340px) {
.st { padding: $sp-2; font-size: 11px; }
.st__h { margin-bottom: $sp-1; }
.st__t { font-size: 11px; }
.st__rail { height: 28px; }
.st__dot { width: 20px; height: 20px; }
.st__next { margin-top: $sp-1; padding: 0 $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.st__next:active { transform: none; }
.st.is-demo .st__fill,
.st.is-demo .st__lb,
.st.is-demo .st__p,
.st.is-demo .st__next { animation: none !important; transform: none !important; }
.st.is-demo .st__dot { animation: none !important; } // 점의 translate(-50%,-50%) 는 배치라 두고 색만 되돌린다
// 움직임만 끄고 결과 상태는 남긴다 — 검토 단계까지 진행
.st.is-demo .st__row[data-row="1"] .st__fill,
.st.is-demo .st__row[data-row="2"] .st__fill { transform: scaleY(1) !important; }
.st.is-demo .st__row[data-row="1"] .st__dot,
.st.is-demo .st__row[data-row="2"] .st__dot { border-color: $subject-mint !important; background: $stage-ink !important; color: $subject-mint !important; }
.st.is-demo .st__row[data-row="3"] .st__dot { border-color: $color !important; background: $color !important; color: #ffffff !important; }
.st.is-demo .st__row[data-row="1"] .st__lb,
.st.is-demo .st__row[data-row="2"] .st__lb { color: rgba(255, 255, 255, .75) !important; }
.st.is-demo .st__row[data-row="3"] .st__lb { color: $subject-cream !important; }
.st.is-demo .st__p--1, .st.is-demo .st__p--2 { visibility: hidden !important; }
.st.is-demo .st__p--3 { visibility: visible !important; }
}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);
}
.st {
box-sizing: border-box;
width: min(304px, 92vw);
padding: 12px;
border: 1px solid rgb(23.5764705882%, 22.0862745098%, 22.9960784314%);
border-radius: 16px;
background: #17141a;
color: #fff7e6;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.3;
}
.st__h {
display: flex;
align-items: center;
gap: 4px;
margin: 0 0 8px;
}
.st__ic {
display: inline-flex;
width: 14px;
height: 14px;
color: #4cd4a6;
}
.st__t {
margin: 0;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.st__phase {
position: relative;
margin-left: auto;
min-width: 52px;
padding: 0 8px;
border-radius: 999px;
background: #2f6df6;
color: #ffffff;
font-size: 10px;
font-weight: 700;
font-style: normal;
line-height: 1.7;
text-align: center;
}
.st__p {
position: absolute;
inset: 0;
visibility: hidden;
font-style: normal;
}
.st__phase[data-at="0"] .st__p--1,
.st__phase[data-at="1"] .st__p--2,
.st__phase[data-at="2"] .st__p--3 {
visibility: visible;
}
.st__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.st__tl {
display: grid;
margin: 0;
padding: 0;
list-style: none;
}
.st__row {
display: grid;
grid-template-columns: 24px 1fr;
align-items: center;
column-gap: 8px;
}
.st__rail {
position: relative;
justify-self: center;
width: 2px;
height: 34px;
margin: 4px 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.16);
}
.st__rail--end {
background: transparent;
}
.st__fill {
position: absolute;
inset: 0;
border-radius: 999px;
background: #4cd4a6;
transform-origin: top;
transform: scaleY(0);
}
.st__dot {
position: absolute;
left: 50%;
top: 50%;
z-index: 1;
display: inline-grid;
place-items: center;
width: 22px;
height: 22px;
border: 1px solid rgb(23.5764705882%, 22.0862745098%, 22.9960784314%);
border-radius: 50%;
background: #17141a;
color: rgba(255, 255, 255, 0.4);
transform: translate(-50%, -50%);
}
.st__dot svg {
width: 12px;
height: 12px;
}
.st__lb {
display: inline-flex;
align-items: baseline;
gap: 8px;
color: rgba(255, 255, 255, 0.45);
font-weight: 700;
}
.st__tm {
color: rgba(255, 255, 255, 0.72);
font-size: 10px;
font-weight: 400;
}
.st__row.is-now .st__dot {
border-color: #2f6df6;
background: #2f6df6;
color: #ffffff;
}
.st__row.is-now .st__dot svg {
width: 12px;
height: 12px;
}
.st__row.is-now .st__lb {
color: #fff7e6;
}
.st__row.is-now .st__rail .st__fill {
transform: scaleY(1);
}
.st__row.is-done .st__dot {
border-color: #4cd4a6;
color: #4cd4a6;
}
.st__row.is-done .st__lb {
color: rgba(255, 255, 255, 0.75);
}
.st__row.is-done .st__rail .st__fill {
transform: scaleY(1);
}
.st__next {
display: inline-flex;
align-items: center;
margin: 8px 0 0;
padding: 4px 12px;
border: 1px solid rgb(23.5764705882%, 22.0862745098%, 22.9960784314%);
border-radius: 999px;
background: transparent;
color: rgba(255, 255, 255, 0.72);
font-family: inherit;
font-size: 12px;
font-weight: 700;
line-height: 1.6;
cursor: pointer;
}
.st__next:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.st__next:active {
transform: scale(0.94);
}
@keyframes st-fill-1 {
0% {
transform: scaleY(0);
}
28%, 87% {
transform: scaleY(1);
}
88%, 100% {
transform: scaleY(0);
}
}
@keyframes st-fill-2 {
0%, 27% {
transform: scaleY(0);
}
60%, 87% {
transform: scaleY(1);
}
88%, 100% {
transform: scaleY(0);
}
}
@keyframes st-dot-1 {
0% {
border-color: #2f6df6;
background: #2f6df6;
color: #ffffff;
}
28%, 100% {
border-color: #4cd4a6;
background: #17141a;
color: #4cd4a6;
}
}
@keyframes st-dot-2 {
0%, 27% {
border-color: rgb(23.5764705882%, 22.0862745098%, 22.9960784314%);
background: #17141a;
color: rgba(255, 255, 255, 0.4);
}
28%, 59% {
border-color: #2f6df6;
background: #2f6df6;
color: #ffffff;
}
60%, 100% {
border-color: #4cd4a6;
background: #17141a;
color: #4cd4a6;
}
}
@keyframes st-dot-3 {
0%, 59% {
border-color: rgb(23.5764705882%, 22.0862745098%, 22.9960784314%);
background: #17141a;
color: rgba(255, 255, 255, 0.4);
}
60%, 87% {
border-color: #2f6df6;
background: #2f6df6;
color: #ffffff;
}
88%, 100% {
border-color: rgb(23.5764705882%, 22.0862745098%, 22.9960784314%);
background: #17141a;
color: rgba(255, 255, 255, 0.4);
}
}
@keyframes st-lb-1 {
0% {
color: #fff7e6;
}
28%, 100% {
color: rgba(255, 255, 255, 0.75);
}
}
@keyframes st-lb-2 {
0%, 27% {
color: rgba(255, 255, 255, 0.45);
}
28%, 59% {
color: #fff7e6;
}
60%, 100% {
color: rgba(255, 255, 255, 0.75);
}
}
@keyframes st-lb-3 {
0%, 59% {
color: rgba(255, 255, 255, 0.45);
}
60%, 87% {
color: #fff7e6;
}
88%, 100% {
color: rgba(255, 255, 255, 0.45);
}
}
@keyframes st-now-pulse {
0%, 100% {
transform: translate(-50%, -50%) scale(1);
}
50% {
transform: translate(-50%, -50%) scale(1.12);
}
}
@keyframes st-p-1 {
0%, 27% {
visibility: visible;
}
28%, 100% {
visibility: hidden;
}
}
@keyframes st-p-2 {
0%, 27% {
visibility: hidden;
}
28%, 59% {
visibility: visible;
}
60%, 100% {
visibility: hidden;
}
}
@keyframes st-p-3 {
0%, 59% {
visibility: hidden;
}
60%, 87% {
visibility: visible;
}
88%, 100% {
visibility: hidden;
}
}
@keyframes st-next-breath {
0%, 26% {
transform: translateY(0);
}
30% {
transform: translateY(2px);
}
34%, 58% {
transform: translateY(0);
}
62% {
transform: translateY(2px);
}
66%, 86% {
transform: translateY(0);
}
87%, 100% {
transform: translateY(0);
}
}
.st.is-demo .st__row[data-row="1"] .st__fill {
animation-name: st-fill-1;
}
.st.is-demo .st__row[data-row="2"] .st__fill {
animation-name: st-fill-2;
}
.st.is-demo .st__fill {
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.st.is-demo .st__row[data-row="1"] .st__dot {
animation-name: st-dot-1;
}
.st.is-demo .st__row[data-row="2"] .st__dot {
animation-name: st-dot-2;
}
.st.is-demo .st__row[data-row="3"] .st__dot {
animation-name: st-dot-3;
}
.st.is-demo .st__dot {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.st.is-demo .st__row[data-row="1"] .st__lb {
animation-name: st-lb-1;
}
.st.is-demo .st__row[data-row="2"] .st__lb {
animation-name: st-lb-2;
}
.st.is-demo .st__row[data-row="3"] .st__lb {
animation-name: st-lb-3;
}
.st.is-demo .st__lb {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.st.is-demo .st__p--1 {
animation-name: st-p-1;
}
.st.is-demo .st__p--2 {
animation-name: st-p-2;
}
.st.is-demo .st__p--3 {
animation-name: st-p-3;
}
.st.is-demo .st__p {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.st.is-demo .st__next {
animation-name: st-next-breath;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@media (max-width: 340px) {
.st {
padding: 8px;
font-size: 11px;
}
.st__h {
margin-bottom: 4px;
}
.st__t {
font-size: 11px;
}
.st__rail {
height: 28px;
}
.st__dot {
width: 20px;
height: 20px;
}
.st__next {
margin-top: 4px;
padding: 0 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.st__next:active {
transform: none;
}
.st.is-demo .st__fill,
.st.is-demo .st__lb,
.st.is-demo .st__p,
.st.is-demo .st__next {
animation: none !important;
transform: none !important;
}
.st.is-demo .st__dot {
animation: none !important;
}
.st.is-demo .st__row[data-row="1"] .st__fill,
.st.is-demo .st__row[data-row="2"] .st__fill {
transform: scaleY(1) !important;
}
.st.is-demo .st__row[data-row="1"] .st__dot,
.st.is-demo .st__row[data-row="2"] .st__dot {
border-color: #4cd4a6 !important;
background: #17141a !important;
color: #4cd4a6 !important;
}
.st.is-demo .st__row[data-row="3"] .st__dot {
border-color: #2f6df6 !important;
background: #2f6df6 !important;
color: #ffffff !important;
}
.st.is-demo .st__row[data-row="1"] .st__lb,
.st.is-demo .st__row[data-row="2"] .st__lb {
color: rgba(255, 255, 255, 0.75) !important;
}
.st.is-demo .st__row[data-row="3"] .st__lb {
color: #fff7e6 !important;
}
.st.is-demo .st__p--1, .st.is-demo .st__p--2 {
visibility: hidden !important;
}
.st.is-demo .st__p--3 {
visibility: visible !important;
}
}// 실제 동작: 다음 단계를 누르면 진행 단계가 실제로 넘어간다 —
// live 단계에만 aria-current="step" L 붙고 안내 문구도 같이 바뀐다.
var st = document.getElementById('st');
var stRows = [].slice.call(document.querySelectorAll('.st__row'));
var stPhase = document.getElementById('st-phase');
var stLive = document.getElementById('st-live');
var stNames = ['Submit', 'Review', 'Approve'];
var stAt = 0;
function stGo(i) {
stAt = (i + stRows.length) % stRows.length;
stRows.forEach(function (r, n) {
r.classList.toggle('is-now', n === stAt);
r.classList.toggle('is-done', n < stAt);
if (n === stAt) { r.setAttribute('aria-current', 'step'); }
else { r.removeAttribute('aria-current'); }
});
stPhase.setAttribute('data-at', String(stAt));
stLive.textContent = stNames[stAt] + ' stage';
}
document.getElementById('st-next').addEventListener('click', function () {
st.classList.remove('is-demo');
stGo(stAt + 1);
});
stGo(0);import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./StatusBadgeTimeline.module.scss";
export type StatusBadgeTimelineProps = {
groupLabel?: string;
docName?: string;
stages?: { key: string; label: string; tm: string }[];
nextLabel?: string;
phaseSuffix?: string;
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
function PulseIcon() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<path d="M160 216h-.4a8.1 8.1 0 0 1-7.1-5.2l-57.2-150l-32 70.5A8 8 0 0 1 56 136H24a8 8 0 0 1 0-16h26.8l37.9-83.3a8.2 8.2 0 0 1 7.6-4.7a8 8 0 0 1 7.2 5.2L161 188.1l31.8-63.7a8.2 8.2 0 0 1 7.2-4.4h32a8 8 0 0 1 0 16h-27.1l-37.7 75.6a8.2 8.2 0 0 1-7.2 4.4Z" />
</svg>
);
}
function PlaneIcon() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<g>
<path d="m223.69 42.18l-58.22 192a8 8 0 0 1-14.92 1.25L108 148l-87.42-42.55a8 8 0 0 1 1.25-14.92l192-58.22a8 8 0 0 1 9.86 9.87" opacity=".2" />
<path d="M227.32 28.68a16 16 0 0 0-15.66-4.08h-.15L19.57 82.84a16 16 0 0 0-2.49 29.8L102 154l41.3 84.87a15.86 15.86 0 0 0 14.44 9.13q.69 0 1.38-.06a15.88 15.88 0 0 0 14-11.51l58.2-191.94v-.15a16 16 0 0 0-4-15.66m-69.49 203.17l-.05.14v-.07l-40.06-82.3l48-48a8 8 0 0 0-11.31-11.31l-48 48l-82.33-40.06h-.07h.14L216 40Z" />
</g>
</svg>
);
}
function SearchIcon() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<g>
<path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2" />
<path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72" />
</g>
</svg>
);
}
function SealIcon() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<g>
<path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2" />
<path d="M174 47.75a254.19 254.19 0 0 0-41.45-38.3a8 8 0 0 0-9.18 0A254.36 254.36 0 0 0 82 47.75C54.51 79.32 40 112.6 40 144a88 88 0 0 0 176 0c0-31.4-14.51-64.68-42-96.25M128 216a72.08 72.08 0 0 1-72-72c0-57.23 55.47-99.58 72-110.93c16.53 11.35 72 53.7 72 110.93a72.08 72.08 0 0 1-72 72" />
</g>
</svg>
);
}
const ICONS = [PlaneIcon, SearchIcon, SealIcon];
const DEFAULT_STAGES = [
{ key: "s1", label: "Submit", tm: "09:12" },
{ key: "s2", label: "Review", tm: "10:05" },
{ key: "s3", label: "Approve", tm: "waiting" },
];
export default function StatusBadgeTimeline({
groupLabel = "Approval progress",
docName = "Autumn campaign proposal",
stages = DEFAULT_STAGES,
nextLabel = "Next stage",
phaseSuffix = " stage",
demo = true,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: StatusBadgeTimelineProps) {
const [live, setLive] = useState(demo);
const [at, setAt] = useState(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function next() {
setLive(false);
setAt((a) => (a + 1) % stages.length);
}
const cls = [styles.st, live ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<section className={cls} style={vars} aria-label={groupLabel}>
<header className={styles.h}>
<span className={styles.ic} aria-hidden="true"><PulseIcon /></span>
<h2 className={styles.t}>{docName}</h2>
<span className={styles.phase} data-at={at}>
{stages.map((s, i) => (
<i key={s.key} className={styles[`p${i + 1}`]} aria-hidden="true">{s.label + phaseSuffix}</i>
))}
</span>
</header>
<span className={styles.sr} aria-live="polite">{stages[at].label + phaseSuffix}</span>
<ol className={styles.tl}>
{stages.map((s, i) => {
const Icon = ICONS[i % ICONS.length];
const rowCls = [
styles.row,
styles[`row${i + 1}`],
i === at ? styles.isNow : "",
i < at ? styles.isDone : "",
].filter(Boolean).join(" ");
return (
<li key={s.key} className={rowCls} aria-current={i === at ? "step" : undefined}>
<span className={[styles.rail, i === stages.length - 1 ? styles.railEnd : ""].filter(Boolean).join(" ")} aria-hidden="true">
<i className={styles.fill}></i>
</span>
<span className={styles.dot} aria-hidden="true"><Icon /></span>
<span className={styles.lb}>{s.label}<time className={styles.tm}>{s.tm}</time></span>
</li>
);
})}
</ol>
<button className={styles.next} type="button" onClick={next}>{nextLabel}</button>
</section>
);
}06 Status badge timeline that lights up stage by stage — Measured area 1.622% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · 9 Activity Feed UI Widgets — Sidebar-Ready Lists
The post this demo lives in
9 Activity Feed UI Widgets — Sidebar-Ready ListsAn activity feed ui is a list that keeps stacking in a screen corner, not a toast that flashes and fades. These nine Sid See all demos in this post


