05재호출까지 남은 시간을 게이지로 줄여가는 노쇼 카운트다운
번호가 호출된 시점부터 재호출까지 남은 시간을 계산해 게이지 바가 그 비율만큼 줄어들고, 시간이 다 되면 바가 붉게 바뀌며 짧게 떨린다.
쓰임응답 없는 순번 재호출 안내
RECALL_COUNTDOWN_SECstepGaugeSHAKE_DURATION_MS

<main class="stage">
<div class="wrc is-demo" id="wrcRoot">
<div class="wrc__panel" id="wrcPanel">
<span class="wrc__ticket" id="wrcTicket" aria-hidden="true">201번</span>
<span class="wrc__badge" id="wrcBadge" aria-hidden="true">대기중</span>
<div class="wrc__track" aria-hidden="true"><div class="wrc__gauge" id="wrcGauge"></div></div>
<span class="wrc__sec" id="wrcSec" aria-hidden="true">60초 남음</span>
</div>
<p class="wrc__status" id="wrcStatus" aria-live="polite"></p>
<div class="wrc__controls">
<button type="button" class="wrc__btn wrc__btn--primary" id="wrcCheckin">확인 완료</button>
<button type="button" class="wrc__btn" id="wrcReset">초기화</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 호출 시점부터 지난 시간을 매 프레임 다시 계산해 게이지 폭을 비율로 줄인다.
// 비율이 0 에 닿는 순간을 계산해 배지·색을 바꾸고 짧은 흔들림을 한 번 재생한다.
@include stage($stage-ink);
.wrc {
position: relative;
display: grid;
gap: $sp-3;
justify-items: center;
width: min(90vw, 300px);
}
.wrc__panel {
display: grid;
gap: $sp-2;
justify-items: center;
width: 100%;
padding: $sp-4;
border-radius: $r-card;
background: rgba($stage-paper, .06);
}
.wrc__ticket {
font-size: 28px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: $stage-paper;
}
.wrc__badge {
font-size: 11px;
font-weight: 700;
padding: $sp-1 $sp-2;
border-radius: $r-pill;
color: $subject-blue-light;
background: rgba($subject-blue-light, .16);
}
.wrc__badge.is-expired {
color: $tick-down;
background: rgba($tick-down, .18);
}
.wrc__track {
width: 100%;
height: 10px;
border-radius: $r-pill;
background: rgba($stage-paper, .16);
overflow: hidden;
}
.wrc__gauge {
height: 100%;
width: 100%;
border-radius: $r-pill;
background: $subject-blue-light;
}
.wrc__gauge.is-warn { background: $tick-down; }
.wrc__gauge.is-expired { background: $tick-down; width: 0% !important; }
.wrc__sec {
font-size: 11px;
font-weight: 600;
color: rgba($stage-paper, .6);
}
.wrc__status {
margin: 0;
font-size: 11px;
font-weight: 600;
color: rgba($stage-paper, .7);
}
.wrc__controls {
display: flex;
gap: $sp-2;
}
.wrc__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;
}
.wrc__btn--primary {
background: $subject-blue-deep;
border-color: $subject-blue-deep;
color: $stage-paper;
}
.wrc__btn:focus-visible {
outline: 2px solid $subject-blue-light;
outline-offset: 2px;
}
@media (max-width: 340px) {
.wrc__ticket { font-size: 22px; }
.wrc__panel { padding: $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.wrc__gauge { transition: none; }
}
@media (max-height: 240px) {
.wrc { gap: $sp-2; }
.wrc__status { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
.wrc__btn { padding-top: $sp-1; padding-bottom: $sp-1; }
}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);
}
.wrc {
position: relative;
display: grid;
gap: 12px;
justify-items: center;
width: min(90vw, 300px);
}
.wrc__panel {
display: grid;
gap: 8px;
justify-items: center;
width: 100%;
padding: 16px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.06);
}
.wrc__ticket {
font-size: 28px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: #ffffff;
}
.wrc__badge {
font-size: 11px;
font-weight: 700;
padding: 4px 8px;
border-radius: 999px;
color: #5b8cf8;
background: rgba(91, 140, 248, 0.16);
}
.wrc__badge.is-expired {
color: #ff7a5e;
background: rgba(255, 122, 94, 0.18);
}
.wrc__track {
width: 100%;
height: 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.16);
overflow: hidden;
}
.wrc__gauge {
height: 100%;
width: 100%;
border-radius: 999px;
background: #5b8cf8;
}
.wrc__gauge.is-warn {
background: #ff7a5e;
}
.wrc__gauge.is-expired {
background: #ff7a5e;
width: 0% !important;
}
.wrc__sec {
font-size: 11px;
font-weight: 600;
color: rgba(255, 255, 255, 0.6);
}
.wrc__status {
margin: 0;
font-size: 11px;
font-weight: 600;
color: rgba(255, 255, 255, 0.7);
}
.wrc__controls {
display: flex;
gap: 8px;
}
.wrc__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;
}
.wrc__btn--primary {
background: #0a4fe8;
border-color: #0a4fe8;
color: #ffffff;
}
.wrc__btn:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
@media (max-width: 340px) {
.wrc__ticket {
font-size: 22px;
}
.wrc__panel {
padding: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.wrc__gauge {
transition: none;
}
}
@media (max-height: 240px) {
.wrc {
gap: 8px;
}
.wrc__status {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
}
.wrc__btn {
padding-top: 4px;
padding-bottom: 4px;
}
}(function () {
// 기법: 번호가 호출된 시점(가상 시계 시작점)부터 지금까지 지난 시간을 계산해 남은 비율만큼
// 게이지 바 width 를 줄인다. 비율이 0 에 닿으면 만료로 계산해 배지를 바꾸고 흔들림을 한 번
// 계산해 재생한다. RECALL_COUNTDOWN_SEC 초 동안 진행되며, Date 없이 rAF 타임스탬프만 쓴다.
var RECALL_COUNTDOWN_SEC = 60;
var SHAKE_DURATION_MS = 380;
var root = document.getElementById('wrcRoot');
var panel = document.getElementById('wrcPanel');
var ticketEl = document.getElementById('wrcTicket');
var badgeEl = document.getElementById('wrcBadge');
var gaugeEl = document.getElementById('wrcGauge');
var secEl = document.getElementById('wrcSec');
var status = document.getElementById('wrcStatus');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) root.classList.remove('is-demo');
var CALL_BASE = 201;
var ticketIndex = 0;
var callStartMs = 0;
var expired = false;
var shaken = false;
function ticketNum() { return CALL_BASE + ticketIndex; }
function paintGauge(ratio) {
gaugeEl.style.width = (ratio * 100).toFixed(1) + '%';
gaugeEl.classList.toggle('is-warn', ratio <= 0.34 && ratio > 0);
gaugeEl.classList.toggle('is-expired', ratio <= 0);
}
function shake() {
if (reduce) return;
panel.animate(
[
{ transform: 'translateX(0px)' },
{ transform: 'translateX(-6px)' },
{ transform: 'translateX(6px)' },
{ transform: 'translateX(-4px)' },
{ transform: 'translateX(4px)' },
{ transform: 'translateX(0px)' }
],
{ duration: SHAKE_DURATION_MS, easing: 'ease-out' }
);
}
function startTicket(nowMs) {
ticketEl.textContent = ticketNum() + '번';
badgeEl.textContent = '대기중';
badgeEl.classList.remove('is-expired');
callStartMs = nowMs;
expired = false;
shaken = false;
paintGauge(1);
secEl.textContent = RECALL_COUNTDOWN_SEC + '초 남음';
status.textContent = ticketNum() + '번 호출 · ' + RECALL_COUNTDOWN_SEC + '초 안에 확인해주세요.';
}
function checkin(nowMs) {
status.textContent = ticketNum() + '번 확인 완료.';
ticketIndex += 1;
startTicket(nowMs);
}
function resetAll(nowMs) {
ticketIndex = 0;
startTicket(nowMs);
status.textContent = ticketNum() + '번 호출 · ' + RECALL_COUNTDOWN_SEC + '초 안에 확인해주세요.';
}
function stepGauge(nowMs) {
var elapsedS = (nowMs - callStartMs) / 1000;
var ratio = Math.max(0, 1 - elapsedS / RECALL_COUNTDOWN_SEC);
paintGauge(ratio);
var remainSec = Math.max(0, Math.ceil(RECALL_COUNTDOWN_SEC - elapsedS));
secEl.textContent = remainSec + '초 남음';
if (ratio <= 0 && !expired) {
expired = true;
badgeEl.textContent = '만료 · 재호출 필요';
badgeEl.classList.add('is-expired');
status.textContent = ticketNum() + '번 시간 만료 · 재호출이 필요합니다.';
if (!shaken) { shaken = true; shake(); }
}
}
var checkinBtn = document.getElementById('wrcCheckin');
var resetBtn = document.getElementById('wrcReset');
function onCheckin() { root.classList.remove('is-demo'); checkin(perfNow()); }
function onReset() { root.classList.remove('is-demo'); resetAll(perfNow()); }
checkinBtn.addEventListener('click', onCheckin);
checkinBtn.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onCheckin(); }
});
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; }
// 데모 압축: 실제 60초 대신 시연에서는 흐름을 빠르게 계산해 보여준다(시계 자체는 여전히
// rAF 경과 시간 기반 — DEMO_TIME_SCALE 만큼 가속해 같은 비율 계산식을 통과시킨다).
// 렌더 캡처가 2 초뿐이라 60초 카운트다운을 1.5 초 안에 다 보이도록 40 배로 압축한다.
var DEMO_TIME_SCALE = 40;
function tick(ts) {
if (t0 === null) t0 = ts;
var now = ts - t0;
if (!reduce) {
var demoNow = root.classList.contains('is-demo') ? callStartMs + (now - callStartMs) * DEMO_TIME_SCALE : now;
stepGauge(demoNow);
if (root.classList.contains('is-demo') && expired) {
checkin(now);
}
}
requestAnimationFrame(tick);
}
startTicket(0);
requestAnimationFrame(tick);
})();// WaitingQueueNoshowRecallCountdown — vanilla 판(05-waiting-queue-noshow-recall-countdown) 의 React 포트.
// 호출 시점부터 지난 시간을 매 프레임 다시 계산해 남은 비율만큼 게이지 width 를 줄이고,
// 비율 0 도달을 계산해 배지를 만료로 바꾸고 흔들림을 한 번 재생한다. Date 없이 rAF 시간만 쓴다.
import { useEffect, useRef, useState } from "react";
import styles from "./WaitingQueueNoshowRecallCountdown.module.scss";
type Box<T> = { current: T };
const RECALL_COUNTDOWN_SEC = 60;
const SHAKE_DURATION_MS = 380;
const CALL_BASE = 201;
const DEMO_TIME_SCALE = 40;
export default function WaitingQueueNoshowRecallCountdown() {
const [ticketText, setTicketText] = useState(CALL_BASE + "번");
const [badgeText, setBadgeText] = useState("대기중");
const [expired, setExpired] = useState(false);
const [warn, setWarn] = useState(false);
const [gaugeW, setGaugeW] = useState(100);
const [secText, setSecText] = useState(RECALL_COUNTDOWN_SEC + "초 남음");
const [status, setStatus] = useState("");
const [reduce, setReduce] = useState(false);
const panelRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const rootRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const ticketIndexRef = useRef<number>(0) as Box<number>;
const callStartRef = useRef<number>(0) as Box<number>;
const expiredRef = useRef<boolean>(false) as Box<boolean>;
const shakenRef = useRef<boolean>(false) as Box<boolean>;
const demoRef = useRef<boolean>(true) as Box<boolean>;
const t0Ref = useRef<number>(0) as Box<number>;
const frameRef = useRef<number>(0) as Box<number>;
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
function ticketNum(): number { return CALL_BASE + ticketIndexRef.current; }
function shake(): void {
if (reduce) return;
const panel = panelRef.current;
if (!panel) return;
panel.animate(
[
{ transform: "translateX(0px)" },
{ transform: "translateX(-6px)" },
{ transform: "translateX(6px)" },
{ transform: "translateX(-4px)" },
{ transform: "translateX(4px)" },
{ transform: "translateX(0px)" }
],
{ duration: SHAKE_DURATION_MS, easing: "ease-out" }
);
}
function startTicket(nowMs: number): void {
setTicketText(ticketNum() + "번");
setBadgeText("대기중");
setExpired(false);
setWarn(false);
callStartRef.current = nowMs;
expiredRef.current = false;
shakenRef.current = false;
setGaugeW(100);
setSecText(RECALL_COUNTDOWN_SEC + "초 남음");
setStatus(ticketNum() + "번 호출 · " + RECALL_COUNTDOWN_SEC + "초 안에 확인해주세요.");
}
function checkin(nowMs: number): void {
setStatus(ticketNum() + "번 확인 완료.");
ticketIndexRef.current += 1;
startTicket(nowMs);
}
function resetAll(nowMs: number): void {
ticketIndexRef.current = 0;
startTicket(nowMs);
setStatus(ticketNum() + "번 호출 · " + RECALL_COUNTDOWN_SEC + "초 안에 확인해주세요.");
}
function stepGauge(nowMs: number): void {
const elapsedS = (nowMs - callStartRef.current) / 1000;
const ratio = Math.max(0, 1 - elapsedS / RECALL_COUNTDOWN_SEC);
setGaugeW(Math.round(ratio * 1000) / 10);
setWarn(ratio <= 0.34 && ratio > 0);
if (ratio <= 0 && !expiredRef.current) setExpired(true);
const remainSec = Math.max(0, Math.ceil(RECALL_COUNTDOWN_SEC - elapsedS));
setSecText(remainSec + "초 남음");
if (ratio <= 0 && !expiredRef.current) {
expiredRef.current = true;
setBadgeText("만료 · 재호출 필요");
setStatus(ticketNum() + "번 시간 만료 · 재호출이 필요합니다.");
if (!shakenRef.current) { shakenRef.current = true; shake(); }
}
}
function stopDemo(): void { demoRef.current = false; }
useEffect(() => {
startTicket(0);
function tick(ts: number): void {
if (t0Ref.current === 0) t0Ref.current = ts;
const now = ts - t0Ref.current;
if (!reduce) {
const inDemo = demoRef.current;
const demoNow = inDemo ? callStartRef.current + (now - callStartRef.current) * DEMO_TIME_SCALE : now;
stepGauge(demoNow);
if (inDemo && expiredRef.current) {
checkin(now);
}
}
frameRef.current = requestAnimationFrame(tick);
}
frameRef.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frameRef.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [reduce]);
function onCheckin(): void { stopDemo(); checkin(perfNow()); }
function onReset(): void { stopDemo(); resetAll(perfNow()); }
function perfNow(): number { return t0Ref.current === 0 ? 0 : performance.now() - t0Ref.current; }
function onKeyDown(e: { key: string; preventDefault: () => void }, fn: () => void): void {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); fn(); }
}
const badgeClass = [styles.badge, expired ? styles.badgeExpired : ""].join(" ").trim();
const gaugeClass = [styles.gauge, warn ? styles.gaugeWarn : "", expired ? styles.gaugeExpired : ""].join(" ").trim();
const rootClass = [styles.wrc, reduce ? "" : "is-demo"].join(" ").trim();
return (
<main className={styles.stage}>
<div className={rootClass} ref={rootRef}>
<div className={styles.panel} ref={panelRef}>
<span className={styles.ticket} aria-hidden="true">{ticketText}</span>
<span className={badgeClass} aria-hidden="true">{badgeText}</span>
<div className={styles.track} aria-hidden="true"><div className={gaugeClass} style={{ width: gaugeW + "%" }} /></div>
<span className={styles.sec} aria-hidden="true">{secText}</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={onCheckin} onKeyDown={(e: { key: string; preventDefault: () => void }) => onKeyDown(e, onCheckin)}>확인 완료</button>
<button type="button" className={styles.btn} onClick={onReset} onKeyDown={(e: { key: string; preventDefault: () => void }) => onKeyDown(e, onReset)}>초기화</button>
</div>
</div>
</main>
);
}05 재호출까지 남은 시간을 게이지로 줄여가는 노쇼 카운트다운 — 측정 누적면적 6.165% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 웨이팅 화면 9가지 — 대기 순서와 예상 시간을 계산하는 순번판
이 데모가 속한 글
웨이팅 화면 9가지 — 대기 순서와 예상 시간을 계산하는 순번판웨이팅 화면은 호출 번호와 남은 인원, 예상 대기 시간을 숫자로 보여 주는 대기 안내 화면이다. 평균 처리 시간이 바뀌면 남은 대기 인원과 곱해진 값도 같이 바뀌어 예상 대기 시간이 숫자로 다시 뜨고, 목록은 빠진 이 글의 데모 전부 보기


