04移動距離を計算してスプリングで収まる待ちリスト
前の番号が抜けると各行が上へ動く距離を計算してスプリング減衰で位置を移し、リスト先頭の行はわずかに大きくなって次の順番を知らせる。
使いどころ複数人で見る待ち順番リスト
LIST_SHIFT_SPRING_STIFFNESSshiftDurationSPRING_SETTLE_EASING

<main class="stage">
<div class="wls is-demo" id="wlsRoot">
<ul class="wls__list" role="list" id="wlsList">
<li class="wls__item is-next" data-id="d1">
<span class="wls__ticket">101番</span>
<span class="wls__note">待ち3分</span>
</li>
<li class="wls__item" data-id="d2">
<span class="wls__ticket">102番</span>
<span class="wls__note">待ち9分</span>
</li>
<li class="wls__item" data-id="d3">
<span class="wls__ticket">103番</span>
<span class="wls__note">待ち14分</span>
</li>
<li class="wls__item" data-id="d4">
<span class="wls__ticket">104番</span>
<span class="wls__note">待ち18分</span>
</li>
<li class="wls__item" data-id="d5">
<span class="wls__ticket">105番</span>
<span class="wls__note">待ち22分</span>
</li>
</ul>
<div class="wls__controls">
<button type="button" class="wls__btn" id="wlsReset">元に戻す</button>
<button type="button" class="wls__btn wls__btn--primary" id="wlsCallBtn">呼び出し完了</button>
</div>
<p class="wls__status" id="wlsStatus" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 앞 순번이 빠지면 아래 행들이 이동할 거리(gap)를 실측해 스프링 감쇠로 자리를 메운다.
// 새로 맨 앞이 된 행은 is-next 로 계산되어 살짝 커지고 테두리가 강조된다.
@include stage($stage-paper);
.wls {
position: relative;
display: grid;
gap: $sp-3;
justify-items: center;
width: min(92vw, 320px);
}
.wls__list {
width: 100%;
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $sp-2;
}
.wls__item {
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-2;
padding: $sp-2 $sp-3;
border-radius: $r-sm;
background: rgba($stage-ink, .05);
transform-origin: left center;
}
.wls__item.is-next {
background: rgba($subject-blue-deep, .12);
outline: 1.5px solid $subject-blue-deep;
transform: scale(1.03);
}
.wls__ticket {
font-size: 15px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: $stage-ink;
}
.wls__note {
font-size: 11px;
font-weight: 600;
color: rgba($stage-ink, .55);
}
.wls__controls {
display: flex;
gap: $sp-2;
}
.wls__btn {
border: 1px solid rgba($stage-ink, .25);
background: transparent;
color: $stage-ink;
border-radius: $r-control;
padding: $sp-2 $sp-4;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.wls__btn--primary {
background: $subject-blue-deep;
border-color: $subject-blue-deep;
color: $stage-paper;
}
.wls__btn:disabled { opacity: .4; cursor: default; }
.wls__btn:focus-visible {
outline: 2px solid $subject-blue-deep;
outline-offset: 2px;
}
.wls__status {
margin: 0;
font-size: 11px;
font-weight: 600;
color: rgba($stage-ink, .6);
}
@media (max-width: 340px) {
.wls__ticket { font-size: 13px; }
.wls__note { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.wls__item { transition: none; }
}
// 화면 높이가 낮을 때 줄을 촘촘하게 — 예고 없이 스크롤바가 생기지 않게 한다.
.wls__list { gap: $sp-1; }
.wls__item { padding-top: $sp-1; padding-bottom: $sp-1; }
@media (max-height: 240px) {
.wls { gap: $sp-2; }
.wls__item { padding-top: 0; padding-bottom: 0; min-height: 24px; }
.wls__status { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
.wls__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: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.wls {
position: relative;
display: grid;
gap: 12px;
justify-items: center;
width: min(92vw, 320px);
}
.wls__list {
width: 100%;
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.wls__item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 12px;
border-radius: 8px;
background: rgba(23, 20, 26, 0.05);
transform-origin: left center;
}
.wls__item.is-next {
background: rgba(10, 79, 232, 0.12);
outline: 1.5px solid #0a4fe8;
transform: scale(1.03);
}
.wls__ticket {
font-size: 15px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: #17141a;
}
.wls__note {
font-size: 11px;
font-weight: 600;
color: rgba(23, 20, 26, 0.55);
}
.wls__controls {
display: flex;
gap: 8px;
}
.wls__btn {
border: 1px solid rgba(23, 20, 26, 0.25);
background: transparent;
color: #17141a;
border-radius: 12px;
padding: 8px 16px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.wls__btn--primary {
background: #0a4fe8;
border-color: #0a4fe8;
color: #ffffff;
}
.wls__btn:disabled {
opacity: 0.4;
cursor: default;
}
.wls__btn:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
.wls__status {
margin: 0;
font-size: 11px;
font-weight: 600;
color: rgba(23, 20, 26, 0.6);
}
@media (max-width: 340px) {
.wls__ticket {
font-size: 13px;
}
.wls__note {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.wls__item {
transition: none;
}
}
.wls__list {
gap: 4px;
}
.wls__item {
padding-top: 4px;
padding-bottom: 4px;
}
@media (max-height: 240px) {
.wls {
gap: 8px;
}
.wls__item {
padding-top: 0;
padding-bottom: 0;
min-height: 24px;
}
.wls__status {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
}
.wls__btn {
padding-top: 4px;
padding-bottom: 4px;
}
}(function () {
// 기법: 맨 앞 순번이 빠지면 그 행의 높이를 실측해 0 으로 접고, 아래 행들은 접힌 높이만큼
// 위로 이동할 거리를 First/Last 로 계산해 스프링 감쇠(LIST_SHIFT_SPRING_STIFFNESS)로 되돌아온다.
// 이동이 끝난 새 맨 앞 행에는 is-next 를 계산해 붙여 살짝 커지게 한다.
var LIST_SHIFT_SPRING_STIFFNESS = 260;
var SPRING_SETTLE_EASING = 'linear(0.0, 0.051, 0.176, 0.341, 0.518, 0.685, 0.831, 0.947, 1.032, 1.087, 1.117, 1.126, 1.121, 1.105, 1.085, 1.062, 1.041, 1.022, 1.008, 0.997, 1)';
var ORDER = ['d1', 'd2', 'd3', 'd4', 'd5'];
var root = document.getElementById('wlsRoot');
var list = document.getElementById('wlsList');
var status = document.getElementById('wlsStatus');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) root.classList.remove('is-demo');
var frontIndex = 0;
function shiftDuration(gapPx) {
return Math.round(60 + 100000 / LIST_SHIFT_SPRING_STIFFNESS + Math.min(240, gapPx * 0.55));
}
function visibleRows() {
var all = list.querySelectorAll('.wls__item');
var vis = [];
for (var i = 0; i < all.length; i += 1) {
if (all[i].getBoundingClientRect().height > 0 && all[i].style.display !== 'none') vis.push(all[i]);
}
return vis;
}
function callFront() {
if (frontIndex >= ORDER.length) { status.textContent = '待ちリストが空です。'; return; }
var id = ORDER[frontIndex];
var row = list.querySelector('.wls__item[data-id="' + id + '"]');
if (!row) return;
var ticket = row.querySelector('.wls__ticket').textContent;
row.classList.remove('is-next');
var below = visibleRows().filter(function (el) { return el !== row; });
var before = below.map(function (el) { return el.getBoundingClientRect().top; });
var gap = row.offsetHeight + 8;
if (reduce) {
row.style.display = 'none';
} else {
var h = row.offsetHeight;
row.style.height = h + 'px';
row.style.overflow = 'hidden';
row.style.boxSizing = 'border-box';
row.style.marginTop = '0px';
row.style.marginBottom = '0px';
row.animate(
[{ height: h + 'px', opacity: 1 }, { height: '0px', opacity: 0 }],
{ duration: 220, easing: 'ease-in', fill: 'forwards' }
).onfinish = function () { row.style.display = 'none'; };
var dur = shiftDuration(gap);
below.forEach(function (el, i) {
el.animate(
[
{ transform: 'translateY(0px)' },
{ transform: 'translateY(' + gap + 'px)' },
{ transform: 'translateY(0px)' }
],
{ duration: dur, delay: 220, easing: SPRING_SETTLE_EASING, fill: 'both' }
);
});
}
frontIndex += 1;
var nextId = ORDER[frontIndex];
var nextTicket = '';
if (nextId) {
var nextRow = list.querySelector('.wls__item[data-id="' + nextId + '"]');
if (nextRow) { nextRow.classList.add('is-next'); nextTicket = nextRow.querySelector('.wls__ticket').textContent; }
}
var callBtn = document.getElementById('wlsCallBtn');
if (callBtn) callBtn.disabled = frontIndex >= ORDER.length;
status.textContent = nextTicket
? ticket + ' 呼び出し完了・次は' + nextTicket
: ticket + ' 呼び出し完了・待ちリストが空になりました。';
}
function restoreAll() {
var all = list.querySelectorAll('.wls__item');
for (var i = 0; i < all.length; i += 1) {
all[i].getAnimations().forEach(function (a) { a.cancel(); });
all[i].style.display = '';
all[i].style.height = '';
all[i].style.overflow = '';
all[i].style.marginTop = '';
all[i].style.marginBottom = '';
all[i].classList.remove('is-next');
}
frontIndex = 0;
var firstRow = list.querySelector('.wls__item[data-id="' + ORDER[0] + '"]');
if (firstRow) firstRow.classList.add('is-next');
var callBtn = document.getElementById('wlsCallBtn');
if (callBtn) callBtn.disabled = false;
status.textContent = '待ちリストを元に戻しました。';
}
var callBtn = document.getElementById('wlsCallBtn');
var resetBtn = document.getElementById('wlsReset');
function onCall() { root.classList.remove('is-demo'); callFront(); }
function onReset() { root.classList.remove('is-demo'); restoreAll(); }
callBtn.addEventListener('click', onCall);
callBtn.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onCall(); }
});
resetBtn.addEventListener('click', onReset);
resetBtn.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onReset(); }
});
var demoSchedule = [200, 1200];
var demoIndex = 0;
var t0 = null;
function tick(ts) {
if (t0 === null) t0 = ts;
var now = ts - t0;
if (!reduce && root.classList.contains('is-demo') && demoIndex < demoSchedule.length && now >= demoSchedule[demoIndex]) {
if (frontIndex >= ORDER.length) { restoreAll(); } else { callFront(); }
demoIndex += 1;
if (demoIndex >= demoSchedule.length) { demoIndex = 0; demoSchedule = demoSchedule.map(function (v) { return v + now + 2000; }); }
}
requestAnimationFrame(tick);
}
status.textContent = '101番が次の順番です。';
requestAnimationFrame(tick);
})();// WaitingQueueListShiftPositionSpring — vanilla 판(04-waiting-queue-list-shift-position-spring) 의
// React 포트. 맨 앞 순번이 빠지면 아래 행들이 이동할 거리(gap)를 실측해 FLIP + 스프링 감쇠
// (LIST_SHIFT_SPRING_STIFFNESS)로 자리를 메우고, 새 맨 앞 행은 is-next 계산으로 강조한다.
import { useEffect, useRef, useState } from "react";
import styles from "./WaitingQueueListShiftPositionSpring.module.scss";
type Box<T> = { current: T };
const LIST_SHIFT_SPRING_STIFFNESS = 260;
const SPRING_SETTLE_EASING = "linear(0.0, 0.051, 0.176, 0.341, 0.518, 0.685, 0.831, 0.947, 1.032, 1.087, 1.117, 1.126, 1.121, 1.105, 1.085, 1.062, 1.041, 1.022, 1.008, 0.997, 1)";
type Row = { id: string; ticket: string; note: string };
const ROWS: Row[] = [
{ id: "d1", ticket: "101번", note: "대기 3분" },
{ id: "d2", ticket: "102번", note: "대기 9분" },
{ id: "d3", ticket: "103번", note: "대기 14분" },
{ id: "d4", ticket: "104번", note: "대기 18분" },
{ id: "d5", ticket: "105번", note: "대기 22분" }
];
function shiftDuration(gapPx: number): number {
return Math.round(60 + 100000 / LIST_SHIFT_SPRING_STIFFNESS + Math.min(240, gapPx * 0.55));
}
export default function WaitingQueueListShiftPositionSpring() {
const [frontIndex, setFrontIndex] = useState(0);
const [hiddenIds, setHiddenIds] = useState<string[]>([]);
const [status, setStatus] = useState("101번이 다음 차례입니다.");
const [reduce, setReduce] = useState(false);
const rowRefs = useRef<Record<string, HTMLLIElement | null>>({}) as Box<Record<string, HTMLLIElement | null>>;
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 visibleRows(excludeId?: string): HTMLLIElement[] {
return ROWS.filter((r) => !hiddenIds.includes(r.id) && r.id !== excludeId)
.map((r) => rowRefs.current[r.id])
.filter((el): el is HTMLLIElement => !!el);
}
function callFront(): void {
if (frontIndex >= ROWS.length) { setStatus("대기 목록이 비었습니다."); return; }
const row = ROWS[frontIndex];
const el = rowRefs.current[row.id];
const below = visibleRows(row.id);
const before = below.map((r) => r.getBoundingClientRect().top);
const gap = (el ? el.offsetHeight : 0) + 8;
if (!reduce && el) {
const h = el.offsetHeight;
el.style.height = h + "px";
el.style.overflow = "hidden";
el.style.boxSizing = "border-box";
el.style.marginTop = "0px";
el.style.marginBottom = "0px";
el.animate(
[{ height: h + "px", opacity: 1 }, { height: "0px", opacity: 0 }],
{ duration: 220, easing: "ease-in", fill: "forwards" }
);
const dur = shiftDuration(gap);
below.forEach((rEl) => {
rEl.animate(
[
{ transform: "translateY(0px)" },
{ transform: "translateY(" + gap + "px)" },
{ transform: "translateY(0px)" }
],
{ duration: dur, delay: 220, easing: SPRING_SETTLE_EASING, fill: "both" }
);
});
}
setHiddenIds((prev) => prev.concat(row.id));
const nextIdx = frontIndex + 1;
setFrontIndex(nextIdx);
const next = ROWS[nextIdx];
setStatus(next ? row.ticket + " 호출 완료 · 다음은 " + next.ticket : row.ticket + " 호출 완료 · 대기 목록이 비었습니다.");
void before;
}
function restoreAll(): void {
Object.keys(rowRefs.current).forEach((id) => {
const el = rowRefs.current[id];
if (!el) return;
el.getAnimations().forEach((a) => a.cancel());
el.style.height = "";
el.style.overflow = "";
el.style.marginTop = "";
el.style.marginBottom = "";
});
setHiddenIds([]);
setFrontIndex(0);
setStatus("대기 목록을 되돌렸습니다.");
}
function stopDemo(): void { demoRef.current = false; }
useEffect(() => {
let t0 = 0;
function tick(ts: number): void {
if (t0 === 0) t0 = ts;
const now = ts - t0;
const sched = demoScheduleRef.current;
if (!reduce && demoRef.current && demoIndexRef.current < sched.length && now >= sched[demoIndexRef.current]) {
if (frontIndexIsDone()) { restoreAll(); } else { callFront(); }
demoIndexRef.current += 1;
if (demoIndexRef.current >= sched.length) {
demoIndexRef.current = 0;
demoScheduleRef.current = sched.map((v) => v + now + 2000);
}
}
frameRef.current = requestAnimationFrame(tick);
}
function frontIndexIsDone(): boolean { return frontIndex >= ROWS.length; }
frameRef.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frameRef.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [reduce, frontIndex]);
function onCall(): void { stopDemo(); callFront(); }
function onReset(): void { stopDemo(); restoreAll(); }
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.wls}>
<ul className={styles.list} role="list">
{ROWS.map((r, i) => (
<li
key={r.id}
className={[styles.item, i === frontIndex ? styles.itemNext : ""].join(" ")}
style={{ display: hiddenIds.includes(r.id) ? "none" : "" }}
ref={(el: HTMLLIElement | null) => { rowRefs.current[r.id] = el; }}
>
<span className={styles.ticket}>{r.ticket}</span>
<span className={styles.note}>{r.note}</span>
</li>
))}
</ul>
<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(" ")} disabled={frontIndex >= ROWS.length} onClick={onCall} onKeyDown={(e: { key: string; preventDefault: () => void }) => onKeyDown(e, onCall)}>호출 완료</button>
</div>
<p className={styles.status} aria-live="polite">{status}</p>
</div>
</main>
);
}04 移動距離を計算してスプリングで収まる待ちリスト — 計測 累積面積 32.848% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · 呼出表示器 実装9パターン — 待ち順と予想時間を計算する順番板
このデモが属する記事
呼出表示器 実装9パターン — 待ち順と予想時間を計算する順番板呼出表示器は、呼び出し番号と残り人数、予想待ち時間を数字で見せる待ち順の案内画面です。平均処理時間が変わると残り人数と掛け合わされた値も一緒に変わって予想待ち時間が数値として更新され、リストは空いた分だけ上に詰まって並び替わり、合計人数はそ この記事のデモをすべて見る


