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 オリジナル · SCSS と React は zip に同梱 · 呼出表示器 実装9パターン — 待ち順と予想時間を計算する順番板
このデモが属する記事
呼出表示器 実装9パターン — 待ち順と予想時間を計算する順番板呼出表示器は、呼び出し番号と残り人数、予想待ち時間を数字で見せる待ち順の案内画面です。平均処理時間が変わると残り人数と掛け合わされた値も一緒に変わって予想待ち時間が数値として更新され、リストは空いた分だけ上に詰まって並び替わり、合計人数はそ この記事のデモをすべて見る


