01남은 인원 수를 다시 계산해 자릿수가 굴러가는 호출 번호
새 번호가 호출되면 이전 번호와의 차이를 계산해 남은 대기 인원 숫자가 자리별로 굴러 내려가고, 큰 순번 숫자는 자리마다 슬롯머신처럼 새 값으로 바뀐다.
쓰임병원 진료 대기 화면
CALL_NUMBER_ROLL_MSrollDigitsDIGIT_STAGGER_MS

<main class="stage">
<div class="wcn is-demo" id="wcnRoot">
<div class="wcn__board">
<div class="wcn__odometer" id="wcnCallOdo" aria-hidden="true"></div>
<div class="wcn__remainWrap" aria-hidden="true">
<span class="wcn__remainLabel">남은</span>
<div class="wcn__odometer wcn__odometer--small" id="wcnRemainOdo"></div>
<span class="wcn__remainUnit">명</span>
</div>
</div>
<p class="wcn__status" id="wcnStatus" aria-live="polite"></p>
<div class="wcn__controls">
<button type="button" class="wcn__btn" id="wcnReset">처음으로</button>
<button type="button" class="wcn__btn wcn__btn--primary" id="wcnNext">다음 호출</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 순번·남은인원 두 오도미터 모두 자리별 릴로 굴러간다. 남은인원은 총원에서
// 호출된 인원 수를 뺀 값을 매 호출마다 다시 계산해서 자리 단위로 내려간다.
@include stage($stage-ink);
.wcn {
position: relative;
display: grid;
gap: $sp-4;
justify-items: center;
width: min(90vw, 300px);
}
.wcn__board {
border-radius: $r-card;
display: grid;
gap: $sp-3;
justify-items: center;
}
.wcn__odometer {
display: flex;
gap: $sp-1;
font-variant-numeric: tabular-nums;
}
.wcn__digit {
position: relative;
width: 54px;
height: 80px;
overflow: hidden;
border-radius: $r-xs;
background: rgba($stage-paper, .08);
}
.wcn__odometer--small .wcn__digit {
width: 34px;
height: 52px;
}
.wcn__reel {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
.wcn__reel i {
display: flex;
align-items: center;
justify-content: center;
height: 80px;
font-style: normal;
font-size: 52px;
font-weight: 700;
color: $subject-blue-light;
}
.wcn__odometer--small .wcn__reel i {
height: 52px;
font-size: 32px;
color: $stage-paper;
}
.wcn__remainWrap {
display: flex;
align-items: center;
gap: $sp-2;
}
.wcn__remainLabel,
.wcn__remainUnit {
font-size: 12px;
font-weight: 600;
color: rgba($stage-paper, .72);
}
.wcn__status {
margin: 0;
font-size: 11px;
font-weight: 600;
color: rgba($stage-paper, .7);
}
.wcn__controls {
display: flex;
gap: $sp-2;
}
.wcn__btn {
border: 1px solid rgba($stage-paper, .3);
background: transparent;
color: $stage-paper;
border-radius: $r-control;
padding: $sp-2 $sp-4;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.wcn__btn--primary {
background: $subject-blue-deep;
border-color: $subject-blue-deep;
color: $stage-paper;
}
.wcn__btn:focus-visible {
outline: 2px solid $subject-blue-light;
outline-offset: 2px;
}
@media (max-width: 340px) {
.wcn__digit { width: 30px; height: 44px; }
.wcn__reel i { height: 44px; font-size: 28px; }
.wcn__odometer--small .wcn__digit { width: 22px; height: 32px; }
.wcn__odometer--small .wcn__reel i { height: 32px; font-size: 20px; }
}
@media (prefers-reduced-motion: reduce) {
.wcn__reel { transition: none; }
}
// 호출 신호 — 새 번호가 불릴 때마다 보드 전체가 한 번 밝아졌다 가라앉는다.
.wcn__board.is-flash { animation: wcnFlash 900ms ease-out; }
@keyframes wcnFlash {
from { background: rgba($subject-blue-deep, .38); }
to { background: rgba($subject-blue-deep, 0); }
}
@media (prefers-reduced-motion: reduce) {
.wcn__board.is-flash { animation: 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);
}
.wcn {
position: relative;
display: grid;
gap: 16px;
justify-items: center;
width: min(90vw, 300px);
}
.wcn__board {
border-radius: 16px;
display: grid;
gap: 12px;
justify-items: center;
}
.wcn__odometer {
display: flex;
gap: 4px;
font-variant-numeric: tabular-nums;
}
.wcn__digit {
position: relative;
width: 54px;
height: 80px;
overflow: hidden;
border-radius: 4px;
background: rgba(255, 255, 255, 0.08);
}
.wcn__odometer--small .wcn__digit {
width: 34px;
height: 52px;
}
.wcn__reel {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
.wcn__reel i {
display: flex;
align-items: center;
justify-content: center;
height: 80px;
font-style: normal;
font-size: 52px;
font-weight: 700;
color: #5b8cf8;
}
.wcn__odometer--small .wcn__reel i {
height: 52px;
font-size: 32px;
color: #ffffff;
}
.wcn__remainWrap {
display: flex;
align-items: center;
gap: 8px;
}
.wcn__remainLabel,
.wcn__remainUnit {
font-size: 12px;
font-weight: 600;
color: rgba(255, 255, 255, 0.72);
}
.wcn__status {
margin: 0;
font-size: 11px;
font-weight: 600;
color: rgba(255, 255, 255, 0.7);
}
.wcn__controls {
display: flex;
gap: 8px;
}
.wcn__btn {
border: 1px solid rgba(255, 255, 255, 0.3);
background: transparent;
color: #ffffff;
border-radius: 12px;
padding: 8px 16px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.wcn__btn--primary {
background: #0a4fe8;
border-color: #0a4fe8;
color: #ffffff;
}
.wcn__btn:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
@media (max-width: 340px) {
.wcn__digit {
width: 30px;
height: 44px;
}
.wcn__reel i {
height: 44px;
font-size: 28px;
}
.wcn__odometer--small .wcn__digit {
width: 22px;
height: 32px;
}
.wcn__odometer--small .wcn__reel i {
height: 32px;
font-size: 20px;
}
}
@media (prefers-reduced-motion: reduce) {
.wcn__reel {
transition: none;
}
}
.wcn__board.is-flash {
animation: wcnFlash 900ms ease-out;
}
@keyframes wcnFlash {
from {
background: rgba(10, 79, 232, 0.38);
}
to {
background: rgba(10, 79, 232, 0);
}
}
@media (prefers-reduced-motion: reduce) {
.wcn__board.is-flash {
animation: none;
}
}(function () {
// 기법: 새 순번이 호출될 때마다 남은 대기 인원(총원 - 처리된 인원)을 다시 계산해, 그 차이를
// 자릿수 릴(디지트 릴)로 자리마다 순서대로 굴려 내린다. 순번 숫자 자체도 자리별 릴이며,
// CALL_NUMBER_ROLL_MS 는 한 자리가 다음 값으로 굴러가는 데 걸리는 시간, DIGIT_STAGGER_MS 는
// 자리마다 시작을 늦추는 간격이다 — CSS 트랜지션이 아니라 rAF 로 각 릴의 위치를 계산한다.
var CALL_NUMBER_ROLL_MS = 480;
var DIGIT_STAGGER_MS = 70;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(434011);
var TOTAL_PARTIES = 42;
var CALL_BASE = 101;
var callIndex = 0; // 지금까지 호출된 인원 수
var root = document.getElementById('wcnRoot');
var callOdo = document.getElementById('wcnCallOdo');
var remainOdo = document.getElementById('wcnRemainOdo');
var status = document.getElementById('wcnStatus');
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;
}
// 자릿수 릴: 각 digit 은 {el, from, to, startMs, doneMs}
function buildOdometer(container, digitCount) {
container.innerHTML = '';
var digits = [];
for (var i = 0; i < digitCount; i += 1) {
var d = document.createElement('div');
d.className = 'wcn__digit';
var reel = document.createElement('div');
reel.className = 'wcn__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 callDigits = buildOdometer(callOdo, 3);
var remainDigits = buildOdometer(remainOdo, 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(digits, 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 flash(el) {
if (reduce) return;
el.classList.remove('is-flash');
void el.offsetWidth;
el.classList.add('is-flash');
}
function rollDigits(digits, num, nowMs) {
if (reduce) { setDigitsImmediate(digits, num); return; }
flash(callOdo.parentNode);
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 = CALL_NUMBER_ROLL_MS;
}
}
function stepDigits(digits, 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;
// 두 자리 사이를 최단 방향으로 굴린다(9→0 처럼 넘어가는 경우 고려).
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 callNum = CALL_BASE;
var remain = TOTAL_PARTIES;
setDigitsImmediate(callDigits, 0);
setDigitsImmediate(remainDigits, remain);
function paintStatus() {
status.textContent = '호출 번호 ' + callNum + ' · 남은 ' + remain + '명';
}
function callNext(nowMs) {
if (remain <= 0) { paintStatus(); return; }
callIndex += 1;
callNum = CALL_BASE + callIndex - 1;
remain = Math.max(0, TOTAL_PARTIES - callIndex);
rollDigits(callDigits, callNum, nowMs);
rollDigits(remainDigits, remain, nowMs);
paintStatus();
}
function resetAll(nowMs) {
callIndex = 0;
callNum = CALL_BASE;
remain = TOTAL_PARTIES;
rollDigits(callDigits, 0, nowMs);
rollDigits(remainDigits, remain, nowMs);
paintStatus();
}
var nextBtn = document.getElementById('wcnNext');
var resetBtn = document.getElementById('wcnReset');
function onNext() {
root.classList.remove('is-demo');
callNext(perfNow());
}
function onReset() {
root.classList.remove('is-demo');
resetAll(perfNow());
}
nextBtn.addEventListener('click', onNext);
nextBtn.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onNext(); }
});
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; }
// 시연 스케줄: 첫 변화는 200ms 뒤, 이후 자연스러운 간격으로 반복.
var demoSchedule = [200, 1200];
var demoIndex = 0;
function tick(ts) {
if (t0 === null) t0 = ts;
var now = ts - t0;
if (!reduce) {
stepDigits(callDigits, now);
stepDigits(remainDigits, now);
if (root.classList.contains('is-demo') && demoIndex < demoSchedule.length && now >= demoSchedule[demoIndex]) {
if (remain <= 0) { resetAll(now); } else { callNext(now); }
demoIndex += 1;
if (demoIndex >= demoSchedule.length) demoIndex = 0, demoSchedule = demoSchedule.map(function (v) { return v + now + 2000; });
}
}
requestAnimationFrame(tick);
}
if (reduce) {
paintStatus();
} else {
requestAnimationFrame(tick);
}
})();// WaitingQueueCallNumberRoll — vanilla 판(01-waiting-queue-call-number-roll-remaining) 의 React 포트.
// 새 순번이 호출될 때마다 남은 대기 인원(총원 - 호출된 인원)을 다시 계산해 자릿수 릴로 굴려 내리고,
// 순번 숫자도 자리마다 CALL_NUMBER_ROLL_MS 동안 굴러간다 — CSS 트랜지션이 아니라 rAF 보간.
import { useEffect, useRef, useState } from "react";
import styles from "./WaitingQueueCallNumberRoll.module.scss";
type Box<T> = { current: T };
const CALL_NUMBER_ROLL_MS = 480;
const DIGIT_STAGGER_MS = 70;
const TOTAL_PARTIES = 42;
const CALL_BASE = 101;
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;
}
function flash(el: HTMLElement | null, cls: string, reduce: boolean): void {
if (reduce || !el) return;
el.classList.remove(cls);
void el.offsetWidth;
el.classList.add(cls);
}
export default function WaitingQueueCallNumberRoll() {
seedRand(434011);
const [status, setStatus] = useState("");
const [reduce, setReduce] = useState(false);
const callDigitsRef = useRef<Digit[]>([{ value: 0, from: 0, to: 0, start: 0, dur: 0 }, { value: 0, from: 0, to: 0, start: 0, dur: 0 }, { value: 0, from: 0, to: 0, start: 0, dur: 0 }]) as Box<Digit[]>;
const remainDigitsRef = 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 callReelRefs = useRef<Array<HTMLDivElement | null>>([null, null, null]) as Box<Array<HTMLDivElement | null>>;
const remainReelRefs = useRef<Array<HTMLDivElement | null>>([null, null]) as Box<Array<HTMLDivElement | null>>;
const callNumRef = useRef<number>(CALL_BASE) as Box<number>;
const remainRef = useRef<number>(TOTAL_PARTIES) as Box<number>;
const callIndexRef = 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>;
const boardRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
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(digits: Digit[], num: number, nowMs: number): void {
if (digits === callDigitsRef.current) flash(boardRef.current, styles.isFlash, reduce);
const s = digitsOf(num, digits.length);
if (reduce) {
const refs = digits === callDigitsRef.current ? callReelRefs.current : remainReelRefs.current;
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(refs[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 = CALL_NUMBER_ROLL_MS;
}
}
function stepDigits(digits: Digit[], refs: Array<HTMLDivElement | null>, nowMs: number): void {
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(): void {
setStatus("호출 번호 " + callNumRef.current + " · 남은 " + remainRef.current + "명");
}
function callNext(nowMs: number): void {
if (remainRef.current <= 0) { paintStatus(); return; }
callIndexRef.current += 1;
callNumRef.current = CALL_BASE + callIndexRef.current - 1;
remainRef.current = Math.max(0, TOTAL_PARTIES - callIndexRef.current);
rollDigits(callDigitsRef.current, callNumRef.current, nowMs);
rollDigits(remainDigitsRef.current, remainRef.current, nowMs);
paintStatus();
}
function resetAll(nowMs: number): void {
callIndexRef.current = 0;
callNumRef.current = CALL_BASE;
remainRef.current = TOTAL_PARTIES;
rollDigits(callDigitsRef.current, 0, nowMs);
rollDigits(remainDigitsRef.current, remainRef.current, nowMs);
paintStatus();
}
function stopDemo(): void { demoRef.current = false; }
useEffect(() => {
paintStatus();
let t0 = 0;
function tick(ts: number): void {
if (t0 === 0) t0 = ts;
const now = ts - t0;
if (!reduce) {
stepDigits(callDigitsRef.current, callReelRefs.current, now);
stepDigits(remainDigitsRef.current, remainReelRefs.current, now);
const sched = demoScheduleRef.current;
if (demoRef.current && demoIndexRef.current < sched.length && now >= sched[demoIndexRef.current]) {
if (remainRef.current <= 0) { resetAll(now); } else { callNext(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 onNext(): void { stopDemo(); callNext(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.wcn}>
<div className={styles.board} ref={boardRef}>
<div className={styles.odometer} aria-hidden="true">
{[0, 1, 2].map((i) => (
<div className={styles.digit} key={i}>
<div className={styles.reel} ref={(el: HTMLDivElement | null) => { callReelRefs.current[i] = el; }}>
{"0123456789".split("").map((ch) => <i key={ch}>{ch}</i>)}
</div>
</div>
))}
</div>
<div className={styles.remainWrap} aria-hidden="true">
<span className={styles.remainLabel}>남은</span>
<div className={[styles.odometer, styles.odometerSmall].join(" ")}>
{[0, 1].map((i) => (
<div className={styles.digit} key={i}>
<div className={styles.reel} ref={(el: HTMLDivElement | null) => { remainReelRefs.current[i] = el; }}>
{"0123456789".split("").map((ch) => <i key={ch}>{ch}</i>)}
</div>
</div>
))}
</div>
<span className={styles.remainUnit}>명</span>
</div>
</div>
<p className={styles.status} aria-live="polite">{status}</p>
<div className={styles.controls}>
<button type="button" className={styles.btn} onClick={onReset} onKeyDown={(e: { key: string; preventDefault: () => void }) => onKeyDown(e, onReset)}>처음으로</button>
<button type="button" className={[styles.btn, styles.btnPrimary].join(" ")} onClick={onNext} onKeyDown={(e: { key: string; preventDefault: () => void }) => onKeyDown(e, onNext)}>다음 호출</button>
</div>
</div>
</main>
);
}01 남은 인원 수를 다시 계산해 자릿수가 굴러가는 호출 번호 — 측정 누적면적 16.738% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 웨이팅 화면 9가지 — 대기 순서와 예상 시간을 계산하는 순번판
이 데모가 속한 글
웨이팅 화면 9가지 — 대기 순서와 예상 시간을 계산하는 순번판웨이팅 화면은 호출 번호와 남은 인원, 예상 대기 시간을 숫자로 보여 주는 대기 안내 화면이다. 평균 처리 시간이 바뀌면 남은 대기 인원과 곱해진 값도 같이 바뀌어 예상 대기 시간이 숫자로 다시 뜨고, 목록은 빠진 이 글의 데모 전부 보기


