06Node run status
Waiting, running, done and failed split across a border colour and a left band, and each change cuts in a single frame so no half-blended state appears.
Best forRun result screens, batch job monitors
steps(1, end)aria-livedata-state

<main class="stage">
<div class="ng is-demo" id="ng">
<p class="ng__livewrap">
<span class="ng__live" id="ngLive" aria-live="polite">Waiting · 3 steps</span>
<span class="ng__demolive" aria-hidden="true">
<span class="ng__lstrip">
<span class="ng__lt">Waiting · 3 steps</span>
<span class="ng__lt">Running · 3 steps</span>
<span class="ng__lt">2 done · 1 failed</span>
</span>
</span>
</p>
<ul class="ng__list">
<li class="ng__card ng__card--1" data-state="wait">
<span class="ng__band" aria-hidden="true"></span>
<span class="ng__title">Form intake</span>
<span class="ng__chips" aria-hidden="true"><span class="ng__strip">
<span class="ng__chip ng__chip--wait"><svg class="ng__ic" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2"/><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm64-88a8,8,0,0,1-8,8H128a8,8,0,0,1-8-8V72a8,8,0,0,1,16,0v48h48A8,8,0,0,1,192,128Z"/></svg><span class="ng__txt">Waiting</span></span>
<span class="ng__chip ng__chip--run"><svg class="ng__ic ng__ic--spin" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2"/><path d="M136,32V64a8,8,0,0,1-16,0V32a8,8,0,0,1,16,0Zm88,88H192a8,8,0,0,0,0,16h32a8,8,0,0,0,0-16Zm-45.09,47.6a8,8,0,0,0-11.31,11.31l22.62,22.63a8,8,0,0,0,11.32-11.32ZM128,184a8,8,0,0,0-8,8v32a8,8,0,0,0,16,0V192A8,8,0,0,0,128,184ZM77.09,167.6,54.46,190.22a8,8,0,0,0,11.32,11.32L88.4,178.91A8,8,0,0,0,77.09,167.6ZM72,128a8,8,0,0,0-8-8H32a8,8,0,0,0,0,16H64A8,8,0,0,0,72,128ZM65.78,54.46A8,8,0,0,0,54.46,65.78L77.09,88.4A8,8,0,0,0,88.4,77.09Z"/></svg><span class="ng__txt">Running</span></span>
<span class="ng__chip ng__chip--done"><svg class="ng__ic" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2"/><path d="M173.66,98.34a8,8,0,0,1,0,11.32l-56,56a8,8,0,0,1-11.32,0l-24-24a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35A8,8,0,0,1,173.66,98.34ZM232,128A104,104,0,1,1,128,24,104.11,104.11,0,0,1,232,128Zm-16,0a88,88,0,1,0-88,88A88.1,88.1,0,0,0,216,128Z"/></svg><span class="ng__txt">Done</span></span>
<span class="ng__chip ng__chip--fail"><svg class="ng__ic" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2"/><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm-8-80V80a8,8,0,0,1,16,0v56a8,8,0,0,1-16,0Zm20,36a12,12,0,1,1-12-12A12,12,0,0,1,140,172Z"/></svg><span class="ng__txt">Failed</span></span>
</span></span>
</li>
<li class="ng__card ng__card--2" data-state="wait">
<span class="ng__band" aria-hidden="true"></span>
<span class="ng__title">AI recap</span>
<span class="ng__chips" aria-hidden="true"><span class="ng__strip">
<span class="ng__chip ng__chip--wait"><svg class="ng__ic" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2"/><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm64-88a8,8,0,0,1-8,8H128a8,8,0,0,1-8-8V72a8,8,0,0,1,16,0v48h48A8,8,0,0,1,192,128Z"/></svg><span class="ng__txt">Waiting</span></span>
<span class="ng__chip ng__chip--run"><svg class="ng__ic ng__ic--spin" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2"/><path d="M136,32V64a8,8,0,0,1-16,0V32a8,8,0,0,1,16,0Zm88,88H192a8,8,0,0,0,0,16h32a8,8,0,0,0,0-16Zm-45.09,47.6a8,8,0,0,0-11.31,11.31l22.62,22.63a8,8,0,0,0,11.32-11.32ZM128,184a8,8,0,0,0-8,8v32a8,8,0,0,0,16,0V192A8,8,0,0,0,128,184ZM77.09,167.6,54.46,190.22a8,8,0,0,0,11.32,11.32L88.4,178.91A8,8,0,0,0,77.09,167.6ZM72,128a8,8,0,0,0-8-8H32a8,8,0,0,0,0,16H64A8,8,0,0,0,72,128ZM65.78,54.46A8,8,0,0,0,54.46,65.78L77.09,88.4A8,8,0,0,0,88.4,77.09Z"/></svg><span class="ng__txt">Running</span></span>
<span class="ng__chip ng__chip--done"><svg class="ng__ic" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2"/><path d="M173.66,98.34a8,8,0,0,1,0,11.32l-56,56a8,8,0,0,1-11.32,0l-24-24a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35A8,8,0,0,1,173.66,98.34ZM232,128A104,104,0,1,1,128,24,104.11,104.11,0,0,1,232,128Zm-16,0a88,88,0,1,0-88,88A88.1,88.1,0,0,0,216,128Z"/></svg><span class="ng__txt">Done</span></span>
<span class="ng__chip ng__chip--fail"><svg class="ng__ic" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2"/><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm-8-80V80a8,8,0,0,1,16,0v56a8,8,0,0,1-16,0Zm20,36a12,12,0,1,1-12-12A12,12,0,0,1,140,172Z"/></svg><span class="ng__txt">Failed</span></span>
</span></span>
</li>
<li class="ng__card ng__card--3" data-state="wait">
<span class="ng__band" aria-hidden="true"></span>
<span class="ng__title">Send email</span>
<span class="ng__chips" aria-hidden="true"><span class="ng__strip">
<span class="ng__chip ng__chip--wait"><svg class="ng__ic" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2"/><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm64-88a8,8,0,0,1-8,8H128a8,8,0,0,1-8-8V72a8,8,0,0,1,16,0v48h48A8,8,0,0,1,192,128Z"/></svg><span class="ng__txt">Waiting</span></span>
<span class="ng__chip ng__chip--run"><svg class="ng__ic ng__ic--spin" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2"/><path d="M136,32V64a8,8,0,0,1-16,0V32a8,8,0,0,1,16,0Zm88,88H192a8,8,0,0,0,0,16h32a8,8,0,0,0,0-16Zm-45.09,47.6a8,8,0,0,0-11.31,11.31l22.62,22.63a8,8,0,0,0,11.32-11.32ZM128,184a8,8,0,0,0-8,8v32a8,8,0,0,0,16,0V192A8,8,0,0,0,128,184ZM77.09,167.6,54.46,190.22a8,8,0,0,0,11.32,11.32L88.4,178.91A8,8,0,0,0,77.09,167.6ZM72,128a8,8,0,0,0-8-8H32a8,8,0,0,0,0,16H64A8,8,0,0,0,72,128ZM65.78,54.46A8,8,0,0,0,54.46,65.78L77.09,88.4A8,8,0,0,0,88.4,77.09Z"/></svg><span class="ng__txt">Running</span></span>
<span class="ng__chip ng__chip--done"><svg class="ng__ic" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2"/><path d="M173.66,98.34a8,8,0,0,1,0,11.32l-56,56a8,8,0,0,1-11.32,0l-24-24a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35A8,8,0,0,1,173.66,98.34ZM232,128A104,104,0,1,1,128,24,104.11,104.11,0,0,1,232,128Zm-16,0a88,88,0,1,0-88,88A88.1,88.1,0,0,0,216,128Z"/></svg><span class="ng__txt">Done</span></span>
<span class="ng__chip ng__chip--fail"><svg class="ng__ic" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2"/><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm-8-80V80a8,8,0,0,1,16,0v56a8,8,0,0,1-16,0Zm20,36a12,12,0,1,1-12-12A12,12,0,0,1,140,172Z"/></svg><span class="ng__txt">Failed</span></span>
</span></span>
</li>
</ul>
<button type="button" class="ng__run" id="ngRun">Run again</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변형 3개는 이 셋만 바꾼다
$duration: $dur-loop;
$easing: steps(1, end);
$color: $subject-blue;
// 흰 무대 위 글자는 4.5:1 이 필요하다 — 토큰 $subject-blue 는 4.53 으로 아슬아슬해 한 단 어둡게 쓰고,
// 성공 초록은 팔레트에 없어 민트를 어둡게 잡았다 (실측 5.63 · 6.08, 279·280 이 CTA 파랑에 쓴 방식)
$run-ink: #7c90be;
$done-ink: #479077;
$wait-ink: rgba(23, 20, 26, .62);
$line-ink: rgba(23, 20, 26, .7);
$wait-edge: rgba(23, 20, 26, .18);
$wait-band: rgba(23, 20, 26, .28);
// 상태 한 칸의 높이. 칩 네 장을 세로로 쌓고 이 값의 배수만큼 밀어 한 장만 보여 준다
$cell: 20px;
@include stage($stage-paper);
.ng { width: min(100%, 360px); display: grid; gap: $sp-2; }
// ── 알림 줄: 진짜 aria-live 와 미리보기용 줄을 같은 칸에 겹쳐 둔다.
// 감출 때 display:none 을 쓰면 칸 폭까지 사라진다 → opacity 로 자리만 지킨다
.ng__livewrap { display: grid; justify-items: center; margin: 0; }
.ng__live, .ng__demolive { grid-area: 1 / 1; }
.ng__live {
font-weight: 700; font-size: clamp(10px, 3vw, 11px); line-height: $cell; color: $line-ink;
letter-spacing: .01em;
}
.ng__demolive { height: $cell; overflow: hidden; opacity: 0; }
.ng__lstrip { display: grid; }
.ng__lt {
height: $cell; font-weight: 700; font-size: clamp(10px, 3vw, 11px); line-height: $cell; color: $line-ink;
letter-spacing: .01em; white-space: nowrap; text-align: center;
}
// ── 카드
.ng__list { list-style: none; margin: 0; padding: 0; display: grid; gap: $sp-2; }
.ng__card {
position: relative; overflow: hidden;
display: grid; grid-template-columns: 1fr auto; align-items: center; gap: $sp-2;
padding: $sp-2 $sp-3; padding-left: $sp-4;
border: 2px solid $wait-edge; border-radius: $r-control; background: $stage-paper;
}
.ng__title { font-weight: 700; font-size: clamp(11px, 3.2vw, 13px); line-height: $cell; color: $subject-ink; }
// 왼쪽 띠는 위 변에 **붙어** 아래로 자란다 — 붙는 지점이 있는 요소라 origin 이 중심이 아니다 (blog-demo 규칙 8)
.ng__band {
position: absolute; left: 0; top: 0; bottom: 0; width: 4px;
background: $wait-band; transform: scaleY(0); transform-origin: 50% 0;
}
// ── 상태 칩: 네 장을 쌓고 $cell 배수만큼 밀어 한 장만 보인다
.ng__chips { display: block; height: $cell; overflow: hidden; }
.ng__strip { display: grid; }
.ng__chip {
display: flex; align-items: center; justify-content: flex-end; gap: $sp-1;
height: $cell; white-space: nowrap;
font-weight: 700; font-size: clamp(10px, 3vw, 11px); line-height: 1;
}
.ng__ic { width: 14px; height: 14px; flex: 0 0 auto; }
.ng__chip--wait { color: $wait-ink; }
.ng__chip--run { color: $run-ink; }
.ng__chip--done { color: $done-ink; }
.ng__chip--fail { color: $error; }
// ── 정적 경로: data-state 가 그대로 화면이다 (미리보기 루프가 없어도 완성 화면)
.ng__card[data-state="wait"] .ng__strip { transform: translateY(0); }
.ng__card[data-state="run"] .ng__strip { transform: translateY(-$cell); }
.ng__card[data-state="done"] .ng__strip { transform: translateY(-$cell * 2); }
.ng__card[data-state="fail"] .ng__strip { transform: translateY(-$cell * 3); }
.ng__card[data-state="run"] { border-color: $run-ink; }
.ng__card[data-state="done"] { border-color: $done-ink; }
.ng__card[data-state="fail"] { border-color: $error; }
.ng__card[data-state="run"] .ng__band {
background: $run-ink; transform-origin: 50% 0;
animation-name: ngFill; animation-duration: 800ms; animation-timing-function: $ease-out; animation-fill-mode: forwards;
}
.ng__card[data-state="done"] .ng__band { background: $done-ink; transform: scaleY(1); }
.ng__card[data-state="fail"] .ng__band { background: $error; transform: scaleY(1); }
// 실행 중 아이콘은 제 중심에서 계속 돈다 — 컷 전환만으로는 모자란 프레임을 이게 채운다
.ng__ic--spin {
transform-origin: 50% 50%;
animation-name: ngSpin; animation-duration: 1s; animation-timing-function: linear; animation-iteration-count: infinite;
}
// ── 단추
.ng__run {
justify-self: center; font-family: inherit; font-weight: 700; font-size: clamp(10px, 3vw, 11px); line-height: 1.8;
color: $subject-cream; background: $run-ink; border: none; border-radius: $r-pill; padding: $sp-1 $sp-5; cursor: pointer;
}
.ng__run:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
// ── 미리보기 루프 (2s): 세 카드가 차례로 실행되고 88% 안에 대기로 돌아온다.
// 교체는 steps(1, end) 컷 — 크로스페이드 중간 장(반투명 유령)을 만들지 않는다
.ng.is-demo .ng__live { opacity: 0; }
.ng.is-demo .ng__demolive { opacity: 1; }
.ng.is-demo .ng__lstrip {
animation-name: ngLine; animation-duration: $duration; animation-timing-function: $easing; animation-iteration-count: infinite;
}
.ng.is-demo .ng__card--1 { animation-name: ngEdge1; }
.ng.is-demo .ng__card--2 { animation-name: ngEdge2; }
.ng.is-demo .ng__card--3 { animation-name: ngEdge3; }
.ng.is-demo .ng__card--1 .ng__strip { animation-name: ngStrip1; }
.ng.is-demo .ng__card--2 .ng__strip { animation-name: ngStrip2; }
.ng.is-demo .ng__card--3 .ng__strip { animation-name: ngStrip3; }
.ng.is-demo .ng__card,
.ng.is-demo .ng__strip {
animation-duration: $duration; animation-timing-function: $easing; animation-iteration-count: infinite;
}
.ng.is-demo .ng__card--1 .ng__band { animation-name: ngBand1; }
.ng.is-demo .ng__card--2 .ng__band { animation-name: ngBand2; }
.ng.is-demo .ng__card--3 .ng__band { animation-name: ngBand3; }
.ng.is-demo .ng__band {
animation-duration: $duration; animation-timing-function: $easing; animation-iteration-count: infinite;
}
@keyframes ngSpin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes ngFill {
from { transform: scaleY(0); }
to { transform: scaleY(1); }
}
@keyframes ngLine {
0%, 11.9% { transform: translateY(0); }
12%, 71.9% { transform: translateY(-$cell); }
72%, 87.9% { transform: translateY(-$cell * 2); }
88%, 100% { transform: translateY(0); }
}
// 1번: 대기 → 실행 중(12%) → 성공(36%) → 대기(80%)
@keyframes ngStrip1 {
0%, 11.9% { transform: translateY(0); }
12%, 35.9% { transform: translateY(-$cell); }
36%, 79.9% { transform: translateY(-$cell * 2); }
80%, 100% { transform: translateY(0); }
}
@keyframes ngEdge1 {
0%, 11.9% { border-color: $wait-edge; }
12%, 35.9% { border-color: $run-ink; }
36%, 79.9% { border-color: $done-ink; }
80%, 100% { border-color: $wait-edge; }
}
// 띠는 실행 구간만 linear 로 자라고 나머지는 컷이다 (키프레임마다 타이밍을 따로 준다)
@keyframes ngBand1 {
0% { transform: scaleY(0); background-color: $wait-band; animation-timing-function: steps(1, end); }
12% { transform: scaleY(0); background-color: $run-ink; animation-timing-function: linear; }
35.9% { transform: scaleY(1); background-color: $run-ink; animation-timing-function: steps(1, end); }
36%, 79.9% { transform: scaleY(1); background-color: $done-ink; animation-timing-function: steps(1, end); }
80%, 100% { transform: scaleY(0); background-color: $wait-band; }
}
// 2번: 대기 → 실행 중(24%) → 성공(56%) → 대기(84%)
@keyframes ngStrip2 {
0%, 23.9% { transform: translateY(0); }
24%, 55.9% { transform: translateY(-$cell); }
56%, 83.9% { transform: translateY(-$cell * 2); }
84%, 100% { transform: translateY(0); }
}
@keyframes ngEdge2 {
0%, 23.9% { border-color: $wait-edge; }
24%, 55.9% { border-color: $run-ink; }
56%, 83.9% { border-color: $done-ink; }
84%, 100% { border-color: $wait-edge; }
}
@keyframes ngBand2 {
0% { transform: scaleY(0); background-color: $wait-band; animation-timing-function: steps(1, end); }
24% { transform: scaleY(0); background-color: $run-ink; animation-timing-function: linear; }
55.9% { transform: scaleY(1); background-color: $run-ink; animation-timing-function: steps(1, end); }
56%, 83.9% { transform: scaleY(1); background-color: $done-ink; animation-timing-function: steps(1, end); }
84%, 100% { transform: scaleY(0); background-color: $wait-band; }
}
// 3번: 대기 → 실행 중(36%) → 실패(72%) → 대기(88%)
@keyframes ngStrip3 {
0%, 35.9% { transform: translateY(0); }
36%, 71.9% { transform: translateY(-$cell); }
72%, 87.9% { transform: translateY(-$cell * 3); }
88%, 100% { transform: translateY(0); }
}
@keyframes ngEdge3 {
0%, 35.9% { border-color: $wait-edge; }
36%, 71.9% { border-color: $run-ink; }
72%, 87.9% { border-color: $error; }
88%, 100% { border-color: $wait-edge; }
}
@keyframes ngBand3 {
0% { transform: scaleY(0); background-color: $wait-band; animation-timing-function: steps(1, end); }
36% { transform: scaleY(0); background-color: $run-ink; animation-timing-function: linear; }
71.9% { transform: scaleY(1); background-color: $run-ink; animation-timing-function: steps(1, end); }
72%, 87.9% { transform: scaleY(1); background-color: $error; animation-timing-function: steps(1, end); }
88%, 100% { transform: scaleY(0); background-color: $wait-band; }
}
// ── 320×200 폰 (BD9): 여백만 줄인다. 여기서 값을 올리면 세로 174px 안에 안 들어간다
@media (max-width: 340px), (max-height: 220px) {
.ng { gap: $sp-1; }
.ng__list { gap: $sp-1; }
.ng__card { padding: $sp-1 $sp-2; padding-left: $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.ng__ic--spin,
.ng__band,
.ng__card,
.ng__strip,
.ng__lstrip { animation: none !important; }
.ng__card[data-state="run"] .ng__band { transform: scaleY(1) !important; }
}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);
}
.ng {
width: min(100%, 360px);
display: grid;
gap: 8px;
}
.ng__livewrap {
display: grid;
justify-items: center;
margin: 0;
}
.ng__live, .ng__demolive {
grid-area: 1/1;
}
.ng__live {
font-weight: 700;
font-size: clamp(10px, 3vw, 11px);
line-height: 20px;
color: rgba(23, 20, 26, 0.7);
letter-spacing: 0.01em;
}
.ng__demolive {
height: 20px;
overflow: hidden;
opacity: 0;
}
.ng__lstrip {
display: grid;
}
.ng__lt {
height: 20px;
font-weight: 700;
font-size: clamp(10px, 3vw, 11px);
line-height: 20px;
color: rgba(23, 20, 26, 0.7);
letter-spacing: 0.01em;
white-space: nowrap;
text-align: center;
}
.ng__list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 8px;
}
.ng__card {
position: relative;
overflow: hidden;
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 8px;
padding: 8px 12px;
padding-left: 16px;
border: 2px solid rgba(23, 20, 26, 0.18);
border-radius: 12px;
background: #ffffff;
}
.ng__title {
font-weight: 700;
font-size: clamp(11px, 3.2vw, 13px);
line-height: 20px;
color: #17141a;
}
.ng__band {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
background: rgba(23, 20, 26, 0.28);
transform: scaleY(0);
transform-origin: 50% 0;
}
.ng__chips {
display: block;
height: 20px;
overflow: hidden;
}
.ng__strip {
display: grid;
}
.ng__chip {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
height: 20px;
white-space: nowrap;
font-weight: 700;
font-size: clamp(10px, 3vw, 11px);
line-height: 1;
}
.ng__ic {
width: 14px;
height: 14px;
flex: 0 0 auto;
}
.ng__chip--wait {
color: rgba(23, 20, 26, 0.62);
}
.ng__chip--run {
color: #7c90be;
}
.ng__chip--done {
color: #479077;
}
.ng__chip--fail {
color: #c2361a;
}
.ng__card[data-state=wait] .ng__strip {
transform: translateY(0);
}
.ng__card[data-state=run] .ng__strip {
transform: translateY(-20px);
}
.ng__card[data-state=done] .ng__strip {
transform: translateY(-40px);
}
.ng__card[data-state=fail] .ng__strip {
transform: translateY(-60px);
}
.ng__card[data-state=run] {
border-color: #7c90be;
}
.ng__card[data-state=done] {
border-color: #479077;
}
.ng__card[data-state=fail] {
border-color: #c2361a;
}
.ng__card[data-state=run] .ng__band {
background: #7c90be;
transform-origin: 50% 0;
animation-name: ngFill;
animation-duration: 800ms;
animation-timing-function: ease-out;
animation-fill-mode: forwards;
}
.ng__card[data-state=done] .ng__band {
background: #479077;
transform: scaleY(1);
}
.ng__card[data-state=fail] .ng__band {
background: #c2361a;
transform: scaleY(1);
}
.ng__ic--spin {
transform-origin: 50% 50%;
animation-name: ngSpin;
animation-duration: 1s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.ng__run {
justify-self: center;
font-family: inherit;
font-weight: 700;
font-size: clamp(10px, 3vw, 11px);
line-height: 1.8;
color: #fff7e6;
background: #7c90be;
border: none;
border-radius: 999px;
padding: 4px 20px;
cursor: pointer;
}
.ng__run:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.ng.is-demo .ng__live {
opacity: 0;
}
.ng.is-demo .ng__demolive {
opacity: 1;
}
.ng.is-demo .ng__lstrip {
animation-name: ngLine;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ng.is-demo .ng__card--1 {
animation-name: ngEdge1;
}
.ng.is-demo .ng__card--2 {
animation-name: ngEdge2;
}
.ng.is-demo .ng__card--3 {
animation-name: ngEdge3;
}
.ng.is-demo .ng__card--1 .ng__strip {
animation-name: ngStrip1;
}
.ng.is-demo .ng__card--2 .ng__strip {
animation-name: ngStrip2;
}
.ng.is-demo .ng__card--3 .ng__strip {
animation-name: ngStrip3;
}
.ng.is-demo .ng__card,
.ng.is-demo .ng__strip {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ng.is-demo .ng__card--1 .ng__band {
animation-name: ngBand1;
}
.ng.is-demo .ng__card--2 .ng__band {
animation-name: ngBand2;
}
.ng.is-demo .ng__card--3 .ng__band {
animation-name: ngBand3;
}
.ng.is-demo .ng__band {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes ngSpin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes ngFill {
from {
transform: scaleY(0);
}
to {
transform: scaleY(1);
}
}
@keyframes ngLine {
0%, 11.9% {
transform: translateY(0);
}
12%, 71.9% {
transform: translateY(-20px);
}
72%, 87.9% {
transform: translateY(-40px);
}
88%, 100% {
transform: translateY(0);
}
}
@keyframes ngStrip1 {
0%, 11.9% {
transform: translateY(0);
}
12%, 35.9% {
transform: translateY(-20px);
}
36%, 79.9% {
transform: translateY(-40px);
}
80%, 100% {
transform: translateY(0);
}
}
@keyframes ngEdge1 {
0%, 11.9% {
border-color: rgba(23, 20, 26, 0.18);
}
12%, 35.9% {
border-color: #7c90be;
}
36%, 79.9% {
border-color: #479077;
}
80%, 100% {
border-color: rgba(23, 20, 26, 0.18);
}
}
@keyframes ngBand1 {
0% {
transform: scaleY(0);
background-color: rgba(23, 20, 26, 0.28);
animation-timing-function: steps(1, end);
}
12% {
transform: scaleY(0);
background-color: #7c90be;
animation-timing-function: linear;
}
35.9% {
transform: scaleY(1);
background-color: #7c90be;
animation-timing-function: steps(1, end);
}
36%, 79.9% {
transform: scaleY(1);
background-color: #479077;
animation-timing-function: steps(1, end);
}
80%, 100% {
transform: scaleY(0);
background-color: rgba(23, 20, 26, 0.28);
}
}
@keyframes ngStrip2 {
0%, 23.9% {
transform: translateY(0);
}
24%, 55.9% {
transform: translateY(-20px);
}
56%, 83.9% {
transform: translateY(-40px);
}
84%, 100% {
transform: translateY(0);
}
}
@keyframes ngEdge2 {
0%, 23.9% {
border-color: rgba(23, 20, 26, 0.18);
}
24%, 55.9% {
border-color: #7c90be;
}
56%, 83.9% {
border-color: #479077;
}
84%, 100% {
border-color: rgba(23, 20, 26, 0.18);
}
}
@keyframes ngBand2 {
0% {
transform: scaleY(0);
background-color: rgba(23, 20, 26, 0.28);
animation-timing-function: steps(1, end);
}
24% {
transform: scaleY(0);
background-color: #7c90be;
animation-timing-function: linear;
}
55.9% {
transform: scaleY(1);
background-color: #7c90be;
animation-timing-function: steps(1, end);
}
56%, 83.9% {
transform: scaleY(1);
background-color: #479077;
animation-timing-function: steps(1, end);
}
84%, 100% {
transform: scaleY(0);
background-color: rgba(23, 20, 26, 0.28);
}
}
@keyframes ngStrip3 {
0%, 35.9% {
transform: translateY(0);
}
36%, 71.9% {
transform: translateY(-20px);
}
72%, 87.9% {
transform: translateY(-60px);
}
88%, 100% {
transform: translateY(0);
}
}
@keyframes ngEdge3 {
0%, 35.9% {
border-color: rgba(23, 20, 26, 0.18);
}
36%, 71.9% {
border-color: #7c90be;
}
72%, 87.9% {
border-color: #c2361a;
}
88%, 100% {
border-color: rgba(23, 20, 26, 0.18);
}
}
@keyframes ngBand3 {
0% {
transform: scaleY(0);
background-color: rgba(23, 20, 26, 0.28);
animation-timing-function: steps(1, end);
}
36% {
transform: scaleY(0);
background-color: #7c90be;
animation-timing-function: linear;
}
71.9% {
transform: scaleY(1);
background-color: #7c90be;
animation-timing-function: steps(1, end);
}
72%, 87.9% {
transform: scaleY(1);
background-color: #c2361a;
animation-timing-function: steps(1, end);
}
88%, 100% {
transform: scaleY(0);
background-color: rgba(23, 20, 26, 0.28);
}
}
@media (max-width: 340px), (max-height: 220px) {
.ng {
gap: 4px;
}
.ng__list {
gap: 4px;
}
.ng__card {
padding: 4px 8px;
padding-left: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.ng__ic--spin,
.ng__band,
.ng__card,
.ng__strip,
.ng__lstrip {
animation: none !important;
}
.ng__card[data-state=run] .ng__band {
transform: scaleY(1) !important;
}
}const root = document.getElementById('ng');
const runButton = document.getElementById('ngRun');
const live = document.getElementById('ngLive');
const cards = Array.prototype.slice.call(document.querySelectorAll('.ng__card'));
const RESULT = ['done', 'done', 'fail'];
const LINE = { wait: 'Waiting · 3 steps', run: 'Running · 3 steps', end: '2 done · 1 failed' };
let timers = [];
function runPipeline() {
root.classList.remove('is-demo');
timers.forEach(function (id) { clearTimeout(id); });
timers = [];
cards.forEach(function (card) { card.setAttribute('data-state', 'wait'); });
live.textContent = LINE.wait;
cards.forEach(function (card, i) {
timers.push(setTimeout(function () {
card.setAttribute('data-state', 'run');
if (i === 0) { live.textContent = LINE.run; }
}, 300 + i * 600));
timers.push(setTimeout(function () {
card.setAttribute('data-state', RESULT[i]);
if (i === cards.length - 1) { live.textContent = LINE.end; }
}, 1100 + i * 600));
});
}
runButton.addEventListener('click', runPipeline);import { useRef, useState } from "react";
import styles from "./RunStatus.module.scss";
export type RunStatusProps = { duration?: number; easing?: string; color?: string };
/** ambient 의 useRef 는 T|null 을 돌려준다 — 배열 상자로 단언해 null 가드를 줄인다 */
type Box<T> = { current: T };
const CLOCK = (
<svg className={styles.ic} viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2" /><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm64-88a8,8,0,0,1-8,8H128a8,8,0,0,1-8-8V72a8,8,0,0,1,16,0v48h48A8,8,0,0,1,192,128Z" /></svg>
);
const SPINNER = (
<svg className={`${styles.ic} ${styles.icSpin}`} viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2" /><path d="M136,32V64a8,8,0,0,1-16,0V32a8,8,0,0,1,16,0Zm88,88H192a8,8,0,0,0,0,16h32a8,8,0,0,0,0-16Zm-45.09,47.6a8,8,0,0,0-11.31,11.31l22.62,22.63a8,8,0,0,0,11.32-11.32ZM128,184a8,8,0,0,0-8,8v32a8,8,0,0,0,16,0V192A8,8,0,0,0,128,184ZM77.09,167.6,54.46,190.22a8,8,0,0,0,11.32,11.32L88.4,178.91A8,8,0,0,0,77.09,167.6ZM72,128a8,8,0,0,0-8-8H32a8,8,0,0,0,0,16H64A8,8,0,0,0,72,128ZM65.78,54.46A8,8,0,0,0,54.46,65.78L77.09,88.4A8,8,0,0,0,88.4,77.09Z" /></svg>
);
const CHECK = (
<svg className={styles.ic} viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2" /><path d="M173.66,98.34a8,8,0,0,1,0,11.32l-56,56a8,8,0,0,1-11.32,0l-24-24a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35A8,8,0,0,1,173.66,98.34ZM232,128A104,104,0,1,1,128,24,104.11,104.11,0,0,1,232,128Zm-16,0a88,88,0,1,0-88,88A88.1,88.1,0,0,0,216,128Z" /></svg>
);
const WARN = (
<svg className={styles.ic} viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2" /><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm-8-80V80a8,8,0,0,1,16,0v56a8,8,0,0,1-16,0Zm20,36a12,12,0,1,1-12-12A12,12,0,0,1,140,172Z" /></svg>
);
const CHIPS = [
{ key: "wait", cls: styles.chipWait, icon: CLOCK, text: "대기" },
{ key: "run", cls: styles.chipRun, icon: SPINNER, text: "실행 중" },
{ key: "done", cls: styles.chipDone, icon: CHECK, text: "성공" },
{ key: "fail", cls: styles.chipFail, icon: WARN, text: "실패" },
];
const TITLES = ["폼 접수", "AI 요약", "메일 보내기"];
const RESULT = ["done", "done", "fail"];
const LINE = { wait: "대기 중 · 3단계", run: "실행 중 · 3단계", end: "성공 2 · 실패 1" };
const CARD_CLASS = [styles.card1, styles.card2, styles.card3];
export default function RunStatus({ duration = 800, easing = "ease-out", color = "#2a5fd8" }: RunStatusProps) {
const [demo, setDemo] = useState(true);
const [states, setStates] = useState<string[]>(["wait", "wait", "wait"]);
const [line, setLine] = useState<string>(LINE.wait);
const timers = useRef<number[]>([]) as Box<number[]>;
const runPipeline = () => {
setDemo(false);
timers.current.forEach((id) => clearTimeout(id));
timers.current = [];
setStates(["wait", "wait", "wait"]);
setLine(LINE.wait);
TITLES.forEach((_, i) => {
timers.current.push(setTimeout(() => {
setStates((prev) => prev.map((v, j) => (j === i ? "run" : v)));
if (i === 0) { setLine(LINE.run); }
}, 300 + i * 600));
timers.current.push(setTimeout(() => {
setStates((prev) => prev.map((v, j) => (j === i ? RESULT[i] : v)));
if (i === TITLES.length - 1) { setLine(LINE.end); }
}, 1100 + i * 600));
});
};
return (
<main className={styles.stage}>
<div
className={`${styles.ng} ${demo ? styles.isDemo : ""}`}
style={{ "--ng-duration": `${duration}ms`, "--ng-easing": easing, "--ng-color": color }}
>
<p className={styles.livewrap}>
<span className={styles.live} aria-live="polite">{line}</span>
<span className={styles.demolive} aria-hidden="true">
<span className={styles.lstrip}>
<span className={styles.lt}>{LINE.wait}</span>
<span className={styles.lt}>{LINE.run}</span>
<span className={styles.lt}>{LINE.end}</span>
</span>
</span>
</p>
<ul className={styles.list}>
{TITLES.map((title, i) => (
<li key={title} className={`${styles.card} ${CARD_CLASS[i]}`} data-state={states[i]}>
<span className={styles.band} aria-hidden="true" />
<span className={styles.title}>{title}</span>
<span className={styles.chips} aria-hidden="true">
<span className={styles.strip}>
{CHIPS.map((chip) => (
<span key={chip.key} className={`${styles.chip} ${chip.cls}`}>
{chip.icon}
<span className={styles.txt}>{chip.text}</span>
</span>
))}
</span>
</span>
</li>
))}
</ul>
<button type="button" className={styles.run} onClick={runPipeline}>다시 실행</button>
</div>
</main>
);
}06 Node run status — Measured area 4.892% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · Node Based UI Design: 9 Patterns, No Library
The post this demo lives in
Node Based UI Design: 9 Patterns, No LibraryNode based ui design is the wiring screen — boxes dropped on a canvas and joined with lines, the way n8n, Figma, and Ble See all demos in this post


