07Total Waiting Count That Rolls by a Calculated Difference
When someone joins or leaves the queue, the gap from the previous value is calculated to roll the total count up or down by that amount, flashing a different color for a rise than a fall.
Best forTotal-count display for the whole queue
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"> waiting</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">Join</button>
<button type="button" class="wtc__btn" id="wtcLeave">Leave</button>
<button type="button" class="wtc__btn" id="wtcReset">Reset</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 () {
// 기법: Join·퇴장이 있을 때마다 이전 값과 새 값의 차이를 계산해 그 차이만큼 자릿수 릴을
// 굴린다. 값이 늘면 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 + ' more, now ' + TOTAL + ' ') : delta < 0 ? ((-delta) + ' fewer, now ' + TOTAL + ' ') : (TOTAL + ' waiting');
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 Total Waiting Count That Rolls by a Calculated Difference — Measured area 12.766% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · Queue Number Display: 9 Waiting Board Patterns
The post this demo lives in
Queue Number Display: 9 Waiting Board PatternsWhen the average time changes, the estimated wait recalculates as a number, the list rows pull up to fill the gap, and t See all demos in this post
If you like this effect - similar demos
Loaders04 Multi-file stagger — progress rows that queue in one by oneFile Upload Progress Bar Loader: 9 UI Demos
Loaders09 A Percent Loader That Smoothly Follows Uneven Real Progress and Pulses the Logo at the EndLogo Loading Animation: 9 Progress-Driven Marks
Loaders03 A Percent Counter That Eases After the Real ValueSplash Screen Animation: 9 Canvas Opening Scenes