04오프라인 대기 건수 — 연결이 끊긴 동안 쌓인 대기 저장 건수 값이 연결이 돌아오는 순간 0으로 줄어드는 방식
연결이 끊긴 동안 대기 건수 값이 하나씩 늘고, 연결이 돌아오는 순간 그 값이 차례로 줄어들며 0에 이릅니다.
쓰임오프라인 지원 메모 앱, 현장 입력 폼의 지연 전송
MAX_QUEUEADD_MSDRAIN_MS

<main class="stage">
<div class="soq is-demo" id="soq">
<div class="soq__row">
<span class="soq__label" id="soqLabel">오프라인</span>
<span class="soq__count" id="soqCount">0건 대기</span>
</div>
<div class="soq__track"><div class="soq__fill" id="soqFill"></div></div>
<button class="soq__btn" id="soqBtn" type="button">연결 전환</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 막대 채움 값 = 대기 건수 ÷ 최대 건수(scaleX 산술) — index.html 참조
@include stage($stage-ink);
.soq {
width: 100%;
max-width: 260px;
display: grid;
justify-items: center;
gap: $sp-3;
}
.soq__row {
width: 100%;
display: flex;
align-items: baseline;
justify-content: space-between;
font: 600 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-cream;
}
.soq__count { font-variant-numeric: tabular-nums; color: rgba($subject-cream, .8); }
.soq--online .soq__label { color: $subject-mint; }
.soq__track {
width: 100%;
height: $sp-2;
border-radius: $r-pill;
background: rgba($subject-cream, .16);
overflow: hidden;
}
.soq__fill {
height: 100%;
width: 100%;
transform-origin: left center;
transform: scaleX(0);
border-radius: $r-pill;
background: $subject-blue-light;
transition: transform $dur-quick $ease-out, background $dur-base $ease-out;
}
.soq--online .soq__fill { background: $subject-mint; }
.soq__btn {
border: 0;
border-radius: $r-control;
padding: $sp-2 $sp-4;
background: $subject-cream;
color: $stage-ink;
font: 700 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
transition: transform $dur-instant $ease-out;
&:active { transform: scale(.96); }
}
@media (max-width: 340px) {
.soq { max-width: 200px; }
}
@media (prefers-reduced-motion: reduce) {
.soq__fill { transition: none; }
.soq__btn { 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);
}
.soq {
width: 100%;
max-width: 260px;
display: grid;
justify-items: center;
gap: 12px;
}
.soq__row {
width: 100%;
display: flex;
align-items: baseline;
justify-content: space-between;
font: 600 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #fff7e6;
}
.soq__count {
font-variant-numeric: tabular-nums;
color: rgba(255, 247, 230, 0.8);
}
.soq--online .soq__label {
color: #4cd4a6;
}
.soq__track {
width: 100%;
height: 8px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.16);
overflow: hidden;
}
.soq__fill {
height: 100%;
width: 100%;
transform-origin: left center;
transform: scaleX(0);
border-radius: 999px;
background: #5b8cf8;
transition: transform 220ms ease-out, background 300ms ease-out;
}
.soq--online .soq__fill {
background: #4cd4a6;
}
.soq__btn {
border: 0;
border-radius: 12px;
padding: 8px 16px;
background: #fff7e6;
color: #17141a;
font: 700 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
transition: transform 140ms ease-out;
}
.soq__btn:active {
transform: scale(0.96);
}
@media (max-width: 340px) {
.soq {
max-width: 200px;
}
}
@media (prefers-reduced-motion: reduce) {
.soq__fill {
transition: none;
}
.soq__btn {
transition: none;
}
}(function () {
var root = document.getElementById('soq');
var btn = document.getElementById('soqBtn');
var label = document.getElementById('soqLabel');
var countEl = document.getElementById('soqCount');
var fill = document.getElementById('soqFill');
// 상수: 대기 칸 최대 값 · 쌓이는 간격(가속 ms) · 줄어드는 간격(가속 ms)
var MAX_QUEUE = 6;
var ADD_MS = 260;
var DRAIN_MS = 150;
var online = false;
var count = 0;
var timer = null;
// 계산 겹: 막대 채움 값 = 대기 건수 ÷ 최대 건수 — scaleX 산술
function paint() {
countEl.textContent = count + '건 대기';
fill.style.transform = 'scaleX(' + (count / MAX_QUEUE).toFixed(3) + ')';
label.textContent = online ? '온라인' : '오프라인';
root.classList.toggle('soq--online', online);
}
function tick() {
if (!online) {
count = Math.min(MAX_QUEUE, count + 1);
paint();
if (count >= MAX_QUEUE) { online = true; paint(); timer = setTimeout(tick, DRAIN_MS); return; }
timer = setTimeout(tick, ADD_MS);
} else {
count = Math.max(0, count - 1);
paint();
if (count <= 0) { timer = setTimeout(function () { online = false; paint(); timer = setTimeout(tick, ADD_MS); }, 500); return; }
timer = setTimeout(tick, DRAIN_MS);
}
}
timer = setTimeout(tick, ADD_MS);
paint();
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
if (timer) clearTimeout(timer);
online = !online;
paint();
timer = setTimeout(tick, online ? DRAIN_MS : ADD_MS);
});
})();import { useEffect, useRef } from "react";
import styles from "./SaveStatusOfflineQueue.module.scss";
type Box<T> = { current: T };
export default function SaveStatusOfflineQueue() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement | null>;
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const label = root.querySelector(`.${styles.label}`) as HTMLElement | null;
const countEl = root.querySelector(`.${styles.count}`) as HTMLElement | null;
const fill = root.querySelector(`.${styles.fill}`) as HTMLElement | null;
const btn = root.querySelector(`.${styles.btn}`) as HTMLElement | null;
if (!label || !countEl || !fill || !btn) return;
// 상수: 대기 칸 최대 값 · 쌓이는 간격(가속 ms) · 줄어드는 간격(가속 ms)
const MAX_QUEUE = 6;
const ADD_MS = 260;
const DRAIN_MS = 150;
let online = false;
let count = 0;
let timer = 0;
let stopped = false;
// 계산 겹: 막대 채움 값 = 대기 건수 ÷ 최대 건수 — scaleX 산술
function paint() {
countEl!.textContent = count + "건 대기";
fill!.style.transform = "scaleX(" + (count / MAX_QUEUE).toFixed(3) + ")";
label!.textContent = online ? "온라인" : "오프라인";
root!.classList.toggle(styles["soq--online"], online);
}
function tick() {
if (stopped) return;
if (!online) {
count = Math.min(MAX_QUEUE, count + 1);
paint();
if (count >= MAX_QUEUE) { online = true; paint(); timer = window.setTimeout(tick, DRAIN_MS); return; }
timer = window.setTimeout(tick, ADD_MS);
} else {
count = Math.max(0, count - 1);
paint();
if (count <= 0) {
timer = window.setTimeout(() => { online = false; paint(); timer = window.setTimeout(tick, ADD_MS); }, 500);
return;
}
timer = window.setTimeout(tick, DRAIN_MS);
}
}
timer = window.setTimeout(tick, ADD_MS);
paint();
function onClick() {
root!.classList.remove(styles["is-demo"]);
window.clearTimeout(timer);
online = !online;
paint();
timer = window.setTimeout(tick, online ? DRAIN_MS : ADD_MS);
}
btn.addEventListener("click", onClick);
return () => {
stopped = true;
window.clearTimeout(timer);
btn.removeEventListener("click", onClick);
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.soq} ${styles["is-demo"]}`}>
<div className={styles.row}>
<span className={styles.label}>오프라인</span>
<span className={styles.count}>0건 대기</span>
</div>
<div className={styles.track}><div className={styles.fill} /></div>
<button className={styles.btn} type="button">연결 전환</button>
</div>
);
}04 오프라인 대기 건수 — 연결이 끊긴 동안 쌓인 대기 저장 건수 값이 연결이 돌아오는 순간 0으로 줄어드는 방식 — 측정 누적면적 1.761% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 자동저장 표시 9종 — 값이 초 단위로 갱신되는 저장 상태 아홉 가지
이 데모가 속한 글
자동저장 표시 9종 — 값이 초 단위로 갱신되는 저장 상태 아홉 가지자동저장 표시는 지금 저장이 어느 상태에 있는지를 값 하나로 계산해 화면에 드러내는 인터랙션을 말하는데, 아홉 개 중 하나는 저장 중이라는 문구가 점 세 개와 함께 반복되고, 하나는 마지막 저장 시각 값이 초 단위로 이 글의 데모 전부 보기


