04Waiting List That Springs Into Place by a Calculated Shift Distance
When a number ahead drops out, the distance each row must rise is calculated and the rows spring into their new spot, while the top row grows slightly to mark the next turn.
Best forShared waiting-number list viewed by a group
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 min waiting</span>
</li>
<li class="wls__item" data-id="d2">
<span class="wls__ticket">102</span>
<span class="wls__note">9 min waiting</span>
</li>
<li class="wls__item" data-id="d3">
<span class="wls__ticket">103</span>
<span class="wls__note">14 min waiting</span>
</li>
<li class="wls__item" data-id="d4">
<span class="wls__ticket">104</span>
<span class="wls__note">18 min waiting</span>
</li>
<li class="wls__item" data-id="d5">
<span class="wls__ticket">105</span>
<span class="wls__note">22 min waiting</span>
</li>
</ul>
<div class="wls__controls">
<button type="button" class="wls__btn" id="wlsReset">Undo</button>
<button type="button" class="wls__btn wls__btn--primary" id="wlsCallBtn">Served</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 = 'The waiting list is empty.'; 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 + ' served · next is ' + nextTicket
: ticket + ' served · the waiting list is empty.';
}
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 = 'The waiting list has been restored.';
}
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 is next in line.';
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 Waiting List That Springs Into Place by a Calculated Shift Distance — Measured area 32.848% · 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
Loaders08 Picture that arrives late into a ratio box already holding its placePage Loading Animation CSS: 9 Route-Change Demos
Loaders06 Ratchet tick — an indicator that briefly snaps in position each time the pulled distance value passes a tick valuePull to Refresh CSS: 9 Loading Patterns
Loaders08 Finished-item position-value move — a finished item's position value shifts into a set area of the listDownload Progress Bar: 9 Loading UI Patterns