07차이만큼 굴러가는 전체 대기 인원 카운터
새 인원이 대기열에 들어오거나 빠지면 이전 값과의 차이를 계산해 전체 대기 인원 숫자가 그 차이만큼 굴러 오르내리고, 늘어날 때와 줄어들 때 색이 다르게 반짝인다.
쓰임대기열 전체 인원 표시판
COUNT_ROLL_MSapplyDeltaFLASH_MS

<main class="stage">
<div class="wtc is-demo" id="wtcRoot">
<div class="wtc__board" id="wtcBoard">
<div class="wtc__odometer" id="wtcOdo" aria-hidden="true"></div>
<span class="wtc__unit" aria-hidden="true">명 대기중</span>
</div>
<p class="wtc__status" id="wtcStatus" aria-live="polite"></p>
<div class="wtc__controls">
<button type="button" class="wtc__btn wtc__btn--primary" id="wtcJoin">입장</button>
<button type="button" class="wtc__btn" id="wtcLeave">퇴장</button>
<button type="button" class="wtc__btn" id="wtcReset">초기화</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 입장·퇴장 차이를 계산해 자릿수 릴을 그 폭만큼 굴리고, 늘었는지 줄었는지에 따라
// 보드 배경에 is-up/is-down 을 계산해 잠깐 켰다 끈다.
@include stage($stage-orange);
.wtc {
position: relative;
display: grid;
gap: $sp-4;
justify-items: center;
width: min(90vw, 300px);
}
.wtc__board {
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-3 $sp-4;
border-radius: $r-card;
background: rgba($stage-paper, .55);
transition: background $dur-quick $ease-out, box-shadow $dur-quick $ease-out;
}
.wtc__board.is-up {
background: rgba($tick-up, .22);
box-shadow: 0 0 0 1px rgba($tick-up, .45);
}
.wtc__board.is-down {
background: rgba($tick-down, .18);
box-shadow: 0 0 0 1px rgba($tick-down, .45);
}
.wtc__odometer {
display: flex;
gap: $sp-1;
font-variant-numeric: tabular-nums;
}
.wtc__digit {
position: relative;
width: 40px;
height: 58px;
overflow: hidden;
border-radius: $r-xs;
background: rgba($stage-ink, .08);
}
.wtc__reel {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
.wtc__reel i {
display: flex;
align-items: center;
justify-content: center;
height: 58px;
font-style: normal;
font-size: 38px;
font-weight: 700;
color: $subject-orange-deep;
}
.wtc__board.is-up .wtc__reel i { color: $tick-up; }
.wtc__board.is-down .wtc__reel i { color: $tick-down; }
.wtc__unit {
font-size: 12px;
font-weight: 600;
color: rgba($stage-ink, .72);
}
.wtc__status {
margin: 0;
font-size: 11px;
font-weight: 600;
color: rgba($stage-ink, .78);
}
.wtc__controls {
display: flex;
gap: $sp-2;
}
.wtc__btn {
border: 1px solid rgba($stage-ink, .3);
background: transparent;
color: $stage-ink;
border-radius: $r-control;
padding: $sp-2 $sp-3;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.wtc__btn--primary {
background: $subject-orange-deep;
border-color: $subject-orange-deep;
color: $stage-paper;
}
.wtc__btn:focus-visible {
outline: 2px solid $subject-orange-deep;
outline-offset: 2px;
}
@media (max-width: 340px) {
.wtc__digit { width: 30px; height: 44px; }
.wtc__reel i { height: 44px; font-size: 28px; }
}
@media (prefers-reduced-motion: reduce) {
.wtc__board { transition: none; }
.wtc__reel { 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);
}
.wtc {
position: relative;
display: grid;
gap: 16px;
justify-items: center;
width: min(90vw, 300px);
}
.wtc__board {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.55);
transition: background 220ms ease-out, box-shadow 220ms ease-out;
}
.wtc__board.is-up {
background: rgba(61, 220, 151, 0.22);
box-shadow: 0 0 0 1px rgba(61, 220, 151, 0.45);
}
.wtc__board.is-down {
background: rgba(255, 122, 94, 0.18);
box-shadow: 0 0 0 1px rgba(255, 122, 94, 0.45);
}
.wtc__odometer {
display: flex;
gap: 4px;
font-variant-numeric: tabular-nums;
}
.wtc__digit {
position: relative;
width: 40px;
height: 58px;
overflow: hidden;
border-radius: 4px;
background: rgba(23, 20, 26, 0.08);
}
.wtc__reel {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
.wtc__reel i {
display: flex;
align-items: center;
justify-content: center;
height: 58px;
font-style: normal;
font-size: 38px;
font-weight: 700;
color: #c93000;
}
.wtc__board.is-up .wtc__reel i {
color: #3ddc97;
}
.wtc__board.is-down .wtc__reel i {
color: #ff7a5e;
}
.wtc__unit {
font-size: 12px;
font-weight: 600;
color: rgba(23, 20, 26, 0.72);
}
.wtc__status {
margin: 0;
font-size: 11px;
font-weight: 600;
color: rgba(23, 20, 26, 0.78);
}
.wtc__controls {
display: flex;
gap: 8px;
}
.wtc__btn {
border: 1px solid rgba(23, 20, 26, 0.3);
background: transparent;
color: #17141a;
border-radius: 12px;
padding: 8px 12px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.wtc__btn--primary {
background: #c93000;
border-color: #c93000;
color: #ffffff;
}
.wtc__btn:focus-visible {
outline: 2px solid #c93000;
outline-offset: 2px;
}
@media (max-width: 340px) {
.wtc__digit {
width: 30px;
height: 44px;
}
.wtc__reel i {
height: 44px;
font-size: 28px;
}
}
@media (prefers-reduced-motion: reduce) {
.wtc__board {
transition: none;
}
.wtc__reel {
transition: none;
}
}(function () {
// 기법: 입장·퇴장이 있을 때마다 이전 값과 새 값의 차이를 계산해 그 차이만큼 자릿수 릴을
// 굴린다. 값이 늘면 wtc__board 에 is-up 을, 줄면 is-down 을 계산해 잠깐 켜 색으로 알린다.
var COUNT_ROLL_MS = 420;
var DIGIT_STAGGER_MS = 60;
var FLASH_MS = 520;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(434071);
var root = document.getElementById('wtcRoot');
var board = document.getElementById('wtcBoard');
var odo = document.getElementById('wtcOdo');
var status = document.getElementById('wtcStatus');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) root.classList.remove('is-demo');
function easeInOutCubic(t) { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; }
function buildOdometer(container, digitCount) {
container.innerHTML = '';
var digits = [];
for (var i = 0; i < digitCount; i += 1) {
var d = document.createElement('div');
d.className = 'wtc__digit';
var reel = document.createElement('div');
reel.className = 'wtc__reel';
d.appendChild(reel);
container.appendChild(d);
digits.push({ el: d, reel: reel, value: 0, from: 0, to: 0, start: 0, dur: 0 });
}
return digits;
}
var digits = buildOdometer(odo, 2);
function paintReel(digit, value) {
var reel = digit.reel;
if (reel.childElementCount !== 10) {
reel.innerHTML = '';
for (var n = 0; n < 10; n += 1) {
var i = document.createElement('i');
i.textContent = String(n);
reel.appendChild(i);
}
}
reel.style.transform = 'translateY(' + (-value * 10) + '%)';
}
function setDigitsImmediate(num) {
var s = String(num);
while (s.length < digits.length) s = '0' + s;
for (var i = 0; i < digits.length; i += 1) {
var v = Number(s[i]);
digits[i].value = v; digits[i].from = v; digits[i].to = v; digits[i].dur = 0;
paintReel(digits[i], v);
}
}
function rollDigits(num, nowMs) {
if (reduce) { setDigitsImmediate(num); return; }
var s = String(num);
while (s.length < digits.length) s = '0' + s;
for (var i = 0; i < digits.length; i += 1) {
var v = Number(s[i]);
if (v === digits[i].to) continue;
digits[i].from = digits[i].value;
digits[i].to = v;
digits[i].start = nowMs + i * DIGIT_STAGGER_MS;
digits[i].dur = COUNT_ROLL_MS;
}
}
function stepDigits(nowMs) {
for (var i = 0; i < digits.length; i += 1) {
var d = digits[i];
if (d.value === d.to && nowMs >= d.start) continue;
if (nowMs < d.start) continue;
var t = Math.min(1, (nowMs - d.start) / d.dur);
var e = easeInOutCubic(t);
var span = d.to - d.from;
if (Math.abs(span) > 5) span = span > 0 ? span - 10 : span + 10;
var cur = d.from + span * e;
cur = ((cur % 10) + 10) % 10;
d.value = t >= 1 ? d.to : cur;
paintReel(d, cur);
}
}
var TOTAL = 18;
var flashUntil = 0;
setDigitsImmediate(TOTAL);
function paintStatus(delta) {
var word = delta > 0 ? (delta + '명 늘어 ' + TOTAL + '명') : delta < 0 ? ((-delta) + '명 줄어 ' + TOTAL + '명') : (TOTAL + '명 대기중');
status.textContent = word + '.';
}
function applyDelta(delta, nowMs) {
if (delta === 0) { paintStatus(0); return; }
var next = Math.max(0, Math.min(99, TOTAL + delta));
var actualDelta = next - TOTAL;
TOTAL = next;
rollDigits(TOTAL, nowMs);
board.classList.remove('is-up', 'is-down');
if (actualDelta > 0) board.classList.add('is-up');
else if (actualDelta < 0) board.classList.add('is-down');
flashUntil = nowMs + FLASH_MS;
paintStatus(actualDelta);
}
function join(nowMs) { applyDelta(1 + Math.floor(rand() * 3), nowMs); }
function leave(nowMs) { applyDelta(-(1 + Math.floor(rand() * 2)), nowMs); }
function resetAll(nowMs) {
TOTAL = 18;
rollDigits(TOTAL, nowMs);
board.classList.remove('is-up', 'is-down');
paintStatus(0);
}
var joinBtn = document.getElementById('wtcJoin');
var leaveBtn = document.getElementById('wtcLeave');
var resetBtn = document.getElementById('wtcReset');
function onJoin() { root.classList.remove('is-demo'); join(perfNow()); }
function onLeave() { root.classList.remove('is-demo'); leave(perfNow()); }
function onReset() { root.classList.remove('is-demo'); resetAll(perfNow()); }
joinBtn.addEventListener('click', onJoin);
joinBtn.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onJoin(); } });
leaveBtn.addEventListener('click', onLeave);
leaveBtn.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onLeave(); } });
resetBtn.addEventListener('click', onReset);
resetBtn.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onReset(); } });
var t0 = null;
function perfNow() { return t0 === null ? 0 : performance.now() - t0; }
var demoSchedule = [200, 1200];
var demoIndex = 0;
function tick(ts) {
if (t0 === null) t0 = ts;
var now = ts - t0;
if (!reduce) {
stepDigits(now);
if (flashUntil && now >= flashUntil) { board.classList.remove('is-up', 'is-down'); flashUntil = 0; }
if (root.classList.contains('is-demo') && demoIndex < demoSchedule.length && now >= demoSchedule[demoIndex]) {
if (demoIndex % 2 === 0) join(now); else leave(now);
demoIndex += 1;
if (demoIndex >= demoSchedule.length) demoIndex = 0, demoSchedule = demoSchedule.map(function (v) { return v + now + 2000; });
}
}
requestAnimationFrame(tick);
}
if (reduce) { paintStatus(0); } else { requestAnimationFrame(tick); }
})();// WaitingQueueTotalCountRollUpdown — vanilla 판(07-waiting-queue-total-count-roll-updown) 의 React 포트.
// 입장·퇴장이 있을 때마다 이전 값과 새 값의 차이를 계산해 자릿수 릴을 그 차이만큼 굴리고,
// 늘었는지 줄었는지를 계산해 보드에 is-up/is-down 을 잠깐 켰다 끈다.
import { useEffect, useRef, useState } from "react";
import styles from "./WaitingQueueTotalCountRollUpdown.module.scss";
type Box<T> = { current: T };
const COUNT_ROLL_MS = 420;
const DIGIT_STAGGER_MS = 60;
const FLASH_MS = 520;
function seedRand(seed: number): () => number {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function easeInOutCubic(t: number): number {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
type Digit = { value: number; from: number; to: number; start: number; dur: number };
function digitsOf(n: number, len: number): number[] {
const s = String(n).padStart(len, "0");
const out: number[] = [];
for (let i = 0; i < s.length; i += 1) out.push(s.charCodeAt(i) - 48);
return out;
}
export default function WaitingQueueTotalCountRollUpdown() {
const randRef = useRef<() => number>(seedRand(434071)) as Box<() => number>;
const [status, setStatus] = useState("");
const [reduce, setReduce] = useState(false);
const [flashClass, setFlashClass] = useState("");
const digitsRef = useRef<Digit[]>([{ value: 0, from: 0, to: 0, start: 0, dur: 0 }, { value: 0, from: 0, to: 0, start: 0, dur: 0 }]) as Box<Digit[]>;
const reelRefs = useRef<Array<HTMLDivElement | null>>([null, null]) as Box<Array<HTMLDivElement | null>>;
const totalRef = useRef<number>(18) as Box<number>;
const flashUntilRef = useRef<number>(0) as Box<number>;
const demoRef = useRef<boolean>(true) as Box<boolean>;
const demoIndexRef = useRef<number>(0) as Box<number>;
const demoScheduleRef = useRef<number[]>([200, 1200]) as Box<number[]>;
const frameRef = useRef<number>(0) as Box<number>;
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
function paintReel(el: HTMLDivElement | null, value: number): void {
if (!el) return;
el.style.transform = "translateY(" + (-value * 10) + "%)";
}
function rollDigits(num: number, nowMs: number): void {
const digits = digitsRef.current;
const s = digitsOf(num, digits.length);
if (reduce) {
for (let i = 0; i < digits.length; i += 1) {
digits[i].value = s[i]; digits[i].from = s[i]; digits[i].to = s[i]; digits[i].dur = 0;
paintReel(reelRefs.current[i], s[i]);
}
return;
}
for (let i = 0; i < digits.length; i += 1) {
const v = s[i];
if (v === digits[i].to) continue;
digits[i].from = digits[i].value;
digits[i].to = v;
digits[i].start = nowMs + i * DIGIT_STAGGER_MS;
digits[i].dur = COUNT_ROLL_MS;
}
}
function stepDigits(nowMs: number): void {
const digits = digitsRef.current;
const refs = reelRefs.current;
for (let i = 0; i < digits.length; i += 1) {
const d = digits[i];
if (d.value === d.to && nowMs >= d.start) continue;
if (nowMs < d.start) continue;
const t = Math.min(1, (nowMs - d.start) / d.dur);
const e = easeInOutCubic(t);
let span = d.to - d.from;
if (Math.abs(span) > 5) span = span > 0 ? span - 10 : span + 10;
let cur = d.from + span * e;
cur = ((cur % 10) + 10) % 10;
d.value = t >= 1 ? d.to : cur;
paintReel(refs[i], cur);
}
}
function paintStatus(delta: number): void {
const total = totalRef.current;
const word = delta > 0 ? (delta + "명 늘어 " + total + "명") : delta < 0 ? ((-delta) + "명 줄어 " + total + "명") : (total + "명 대기중");
setStatus(word + ".");
}
function applyDelta(delta: number, nowMs: number): void {
if (delta === 0) { paintStatus(0); return; }
const next = Math.max(0, Math.min(99, totalRef.current + delta));
const actualDelta = next - totalRef.current;
totalRef.current = next;
rollDigits(totalRef.current, nowMs);
setFlashClass(actualDelta > 0 ? styles.boardUp : actualDelta < 0 ? styles.boardDown : "");
flashUntilRef.current = nowMs + FLASH_MS;
paintStatus(actualDelta);
}
function join(nowMs: number): void { applyDelta(1 + Math.floor(randRef.current() * 3), nowMs); }
function leave(nowMs: number): void { applyDelta(-(1 + Math.floor(randRef.current() * 2)), nowMs); }
function resetAll(nowMs: number): void {
totalRef.current = 18;
rollDigits(totalRef.current, nowMs);
setFlashClass("");
paintStatus(0);
}
function stopDemo(): void { demoRef.current = false; }
useEffect(() => {
paintStatus(0);
let t0 = 0;
function tick(ts: number): void {
if (t0 === 0) t0 = ts;
const now = ts - t0;
if (!reduce) {
stepDigits(now);
if (flashUntilRef.current && now >= flashUntilRef.current) {
setFlashClass("");
flashUntilRef.current = 0;
}
const sched = demoScheduleRef.current;
if (demoRef.current && demoIndexRef.current < sched.length && now >= sched[demoIndexRef.current]) {
if (demoIndexRef.current % 2 === 0) join(now); else leave(now);
demoIndexRef.current += 1;
if (demoIndexRef.current >= sched.length) {
demoIndexRef.current = 0;
demoScheduleRef.current = sched.map((v) => v + now + 2000);
}
}
}
frameRef.current = requestAnimationFrame(tick);
}
frameRef.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frameRef.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [reduce]);
function onJoin(): void { stopDemo(); join(performance.now()); }
function onLeave(): void { stopDemo(); leave(performance.now()); }
function onReset(): void { stopDemo(); resetAll(performance.now()); }
function onKeyDown(e: { key: string; preventDefault: () => void }, fn: () => void): void {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); fn(); }
}
return (
<main className={styles.stage}>
<div className={styles.wtc}>
<div className={[styles.board, flashClass].join(" ")}>
<div className={styles.odometer} aria-hidden="true">
{[0, 1].map((i) => (
<div className={styles.digit} key={i}>
<div className={styles.reel} ref={(el: HTMLDivElement | null) => { reelRefs.current[i] = el; }}>
{"0123456789".split("").map((ch) => <i key={ch}>{ch}</i>)}
</div>
</div>
))}
</div>
<span className={styles.unit}>명 대기중</span>
</div>
<p className={styles.status} aria-live="polite">{status}</p>
<div className={styles.controls}>
<button type="button" className={[styles.btn, styles.btnPrimary].join(" ")} onClick={onJoin} onKeyDown={(e: { key: string; preventDefault: () => void }) => onKeyDown(e, onJoin)}>입장</button>
<button type="button" className={styles.btn} onClick={onLeave} onKeyDown={(e: { key: string; preventDefault: () => void }) => onKeyDown(e, onLeave)}>퇴장</button>
<button type="button" className={styles.btn} onClick={onReset} onKeyDown={(e: { key: string; preventDefault: () => void }) => onKeyDown(e, onReset)}>초기화</button>
</div>
</div>
</main>
);
}07 차이만큼 굴러가는 전체 대기 인원 카운터 — 측정 누적면적 12.766% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 웨이팅 화면 9가지 — 대기 순서와 예상 시간을 계산하는 순번판
이 데모가 속한 글
웨이팅 화면 9가지 — 대기 순서와 예상 시간을 계산하는 순번판웨이팅 화면은 호출 번호와 남은 인원, 예상 대기 시간을 숫자로 보여 주는 대기 안내 화면이다. 평균 처리 시간이 바뀌면 남은 대기 인원과 곱해진 값도 같이 바뀌어 예상 대기 시간이 숫자로 다시 뜨고, 목록은 빠진 이 글의 데모 전부 보기


