08Priority Lane That Inserts by a Calculated Priority Gap
When a priority entry is registered, the priority gap against the remaining numbers is calculated so a new badge slots in at that spot, and the numbers behind it are calculated down by the pushed distance.
Best forPriority call marker for expectant or disabled visitors
PRIORITY_SHIFT_SPRING_STIFFNESSpriorityInsertIndexinsertPriority

<main class="stage">
<div class="wpl is-demo" id="wplRoot">
<ul class="wpl__list" id="wplList" role="list"></ul>
<p class="wpl__status" id="wplStatus" aria-live="polite"></p>
<div class="wpl__controls">
<button type="button" class="wpl__btn wpl__btn--primary" id="wplInsert">Priority check-in</button>
<button type="button" class="wpl__btn" id="wplReset">Reset</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 우선순위 차이로 끼어들 자리를 계산하고, 뒤 줄들은 FLIP 실측 거리만큼 스프링 감쇠로
// 내려가며, 새 배지 행은 확대+페이드로 끼어든다.
@include stage($stage-paper);
.wpl {
position: relative;
display: grid;
gap: $sp-3;
justify-items: center;
width: min(92vw, 320px);
}
.wpl__list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: $sp-2;
width: 100%;
}
.wpl__item {
display: grid;
grid-template-columns: 44px 56px 1fr;
align-items: center;
gap: $sp-2;
padding: $sp-2 $sp-3;
border-radius: $r-sm;
background: rgba($stage-ink, .05);
box-sizing: border-box;
}
.wpl__item.is-priority {
background: rgba($subject-blue-deep, .12);
outline: 1.5px solid $subject-blue-deep;
}
.wpl__item.is-enter {
opacity: 0;
}
.wpl__badge {
font-size: 10px;
font-weight: 700;
color: $stage-paper;
background: $subject-blue-deep;
border-radius: $r-pill;
padding: $sp-1 0;
text-align: center;
}
.wpl__spacer {
width: 100%;
}
.wpl__ticket {
font-size: 14px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: $stage-ink;
}
.wpl__note {
font-size: 11px;
font-weight: 600;
color: rgba($stage-ink, .6);
}
.wpl__status {
margin: 0;
font-size: 11px;
font-weight: 600;
color: rgba($stage-ink, .7);
}
.wpl__controls {
display: flex;
gap: $sp-2;
}
.wpl__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;
}
.wpl__btn--primary {
background: $subject-blue-deep;
border-color: $subject-blue-deep;
color: $stage-paper;
}
.wpl__btn:focus-visible {
outline: 2px solid $subject-blue-deep;
outline-offset: 2px;
}
@media (max-width: 340px) {
.wpl__item { grid-template-columns: 38px 48px 1fr; }
}
@media (prefers-reduced-motion: reduce) {
.wpl__item { transition: none; }
}
// 화면 높이가 낮을 때 줄을 촘촘하게 — 예고 없이 스크롤바가 생기지 않게 한다.
.wpl__list { gap: $sp-1; }
.wpl__item { padding-top: $sp-1; padding-bottom: $sp-1; }
@media (max-height: 240px) {
.wpl { gap: $sp-2; }
.wpl__item { padding-top: 0; padding-bottom: 0; min-height: 24px; }
.wpl__status { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
.wpl__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);
}
.wpl {
position: relative;
display: grid;
gap: 12px;
justify-items: center;
width: min(92vw, 320px);
}
.wpl__list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 8px;
width: 100%;
}
.wpl__item {
display: grid;
grid-template-columns: 44px 56px 1fr;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 8px;
background: rgba(23, 20, 26, 0.05);
box-sizing: border-box;
}
.wpl__item.is-priority {
background: rgba(10, 79, 232, 0.12);
outline: 1.5px solid #0a4fe8;
}
.wpl__item.is-enter {
opacity: 0;
}
.wpl__badge {
font-size: 10px;
font-weight: 700;
color: #ffffff;
background: #0a4fe8;
border-radius: 999px;
padding: 4px 0;
text-align: center;
}
.wpl__spacer {
width: 100%;
}
.wpl__ticket {
font-size: 14px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: #17141a;
}
.wpl__note {
font-size: 11px;
font-weight: 600;
color: rgba(23, 20, 26, 0.6);
}
.wpl__status {
margin: 0;
font-size: 11px;
font-weight: 600;
color: rgba(23, 20, 26, 0.7);
}
.wpl__controls {
display: flex;
gap: 8px;
}
.wpl__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;
}
.wpl__btn--primary {
background: #0a4fe8;
border-color: #0a4fe8;
color: #ffffff;
}
.wpl__btn:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
@media (max-width: 340px) {
.wpl__item {
grid-template-columns: 38px 48px 1fr;
}
}
@media (prefers-reduced-motion: reduce) {
.wpl__item {
transition: none;
}
}
.wpl__list {
gap: 4px;
}
.wpl__item {
padding-top: 4px;
padding-bottom: 4px;
}
@media (max-height: 240px) {
.wpl {
gap: 8px;
}
.wpl__item {
padding-top: 0;
padding-bottom: 0;
min-height: 24px;
}
.wpl__status {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
}
.wpl__btn {
padding-top: 4px;
padding-bottom: 4px;
}
}(function () {
// 기법: 새 Priority 대상의 우선순위 값과 기존 각 줄의 우선순위 값을 비교해 끼어들 자리(insertIndex)를
// 계산한다. 그 자리 뒤에 있던 줄들이 화면에서 실제로 얼마나 내려가는지(FLIP)를 실측해
// PRIORITY_SHIFT_SPRING_STIFFNESS 로 감쇠시키며 되돌리고, 새 배지는 확대+페이드로 끼어든다.
var PRIORITY_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)';
function shiftDuration(gapPx) {
return Math.round(60 + 100000 / PRIORITY_SHIFT_SPRING_STIFFNESS + Math.min(240, gapPx * 0.55));
}
var ORDER = [
{ id: 'r1', ticket: '108', note: 'Standard check-in', weight: 0 },
{ id: 'r2', ticket: '109', note: 'Standard check-in', weight: 0 },
{ id: 'r3', ticket: '110', note: 'Standard check-in', weight: 0 },
{ id: 'r4', ticket: '111', note: 'Standard check-in', weight: 0 }
];
var priorityCount = 0;
var root = document.getElementById('wplRoot');
var listEl = document.getElementById('wplList');
var status = document.getElementById('wplStatus');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) root.classList.remove('is-demo');
function buildRow(row) {
var li = document.createElement('li');
li.className = 'wpl__item' + (row.weight > 0 ? ' is-priority' : '');
li.id = 'wplRow-' + row.id;
function cell(cls, text) {
var sp = document.createElement('span');
sp.className = cls;
if (text) sp.textContent = text;
li.appendChild(sp);
}
if (row.weight > 0) cell('wpl__badge', 'Priority'); else cell('wpl__spacer', '');
cell('wpl__ticket', row.ticket);
cell('wpl__note', row.note);
return li;
}
ORDER.forEach(function (row) { listEl.appendChild(buildRow(row)); });
function priorityInsertIndex(newWeight) {
var idx = 0;
for (var i = 0; i < ORDER.length; i += 1) {
if (ORDER[i].weight >= newWeight) idx = i + 1; else break;
}
return idx;
}
function paintStatus(row, idx) {
status.textContent = row.ticket + ' priority check-in · inserted at position ' + (idx + 1) + '.';
}
function insertPriority() {
priorityCount += 1;
var newRow = { id: 'p' + priorityCount, ticket: (301 + priorityCount - 1) + ' ', note: 'Priority call', weight: 10 };
var idx = priorityInsertIndex(newRow.weight);
var below = Array.prototype.slice.call(listEl.children, idx);
var beforeTops = below.map(function (el) { return el.getBoundingClientRect().top; });
var li = buildRow(newRow);
li.classList.add('is-enter');
if (idx >= listEl.children.length) listEl.appendChild(li); else listEl.insertBefore(li, listEl.children[idx]);
ORDER.splice(idx, 0, newRow);
if (!reduce) {
requestAnimationFrame(function () {
var afterTops = below.map(function (el) { return el.getBoundingClientRect().top; });
below.forEach(function (el, i) {
var dy = beforeTops[i] - afterTops[i];
if (Math.abs(dy) < 0.5) return;
el.animate(
[{ transform: 'translateY(' + dy + 'px)' }, { transform: 'translateY(0px)' }],
{ duration: shiftDuration(Math.abs(dy)), easing: SPRING_SETTLE_EASING }
);
});
li.animate(
[{ opacity: 0, transform: 'scale(.85)' }, { opacity: 1, transform: 'scale(1)' }],
{ duration: 260, easing: 'ease-out' }
);
li.classList.remove('is-enter');
});
} else {
li.classList.remove('is-enter');
}
paintStatus(newRow, idx);
}
function resetAll() {
priorityCount = 0;
ORDER = [
{ id: 'r1', ticket: '108', note: 'Standard check-in', weight: 0 },
{ id: 'r2', ticket: '109', note: 'Standard check-in', weight: 0 },
{ id: 'r3', ticket: '110', note: 'Standard check-in', weight: 0 },
{ id: 'r4', ticket: '111', note: 'Standard check-in', weight: 0 }
];
listEl.innerHTML = '';
ORDER.forEach(function (row) { listEl.appendChild(buildRow(row)); });
status.textContent = 'The waiting list has been reset.';
}
var insertBtn = document.getElementById('wplInsert');
var resetBtn = document.getElementById('wplReset');
function onInsert() { root.classList.remove('is-demo'); insertPriority(); }
function onReset() { root.classList.remove('is-demo'); resetAll(); }
insertBtn.addEventListener('click', onInsert);
insertBtn.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onInsert(); } });
resetBtn.addEventListener('click', onReset);
resetBtn.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onReset(); } });
status.textContent = 'A priority entry slots into its calculated position on check-in.';
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 (priorityCount >= 2) { resetAll(); } else { insertPriority(); }
demoIndex += 1;
if (demoIndex >= demoSchedule.length) { demoIndex = 0; demoSchedule = demoSchedule.map(function (v) { return v + now + 2000; }); }
}
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
})();// WaitingQueuePriorityLaneBadgeShift — vanilla 판(08-waiting-queue-priority-lane-badge-shift) 의
// React 포트. 우선순위 차이로 끼어들 자리를 계산하고, 뒤 줄들은 FLIP 실측 거리만큼 스프링
// 감쇠로 내려가며, 새 배지 행은 확대+페이드로 끼어든다.
import { useEffect, useRef, useState } from "react";
import styles from "./WaitingQueuePriorityLaneBadgeShift.module.scss";
type Box<T> = { current: T };
const PRIORITY_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; weight: number };
function initialOrder(): Row[] {
return [
{ id: "r1", ticket: "108번", note: "일반 접수", weight: 0 },
{ id: "r2", ticket: "109번", note: "일반 접수", weight: 0 },
{ id: "r3", ticket: "110번", note: "일반 접수", weight: 0 },
{ id: "r4", ticket: "111번", note: "일반 접수", weight: 0 }
];
}
function shiftDuration(gapPx: number): number {
return Math.round(60 + 100000 / PRIORITY_SHIFT_SPRING_STIFFNESS + Math.min(240, gapPx * 0.55));
}
export default function WaitingQueuePriorityLaneBadgeShift() {
const [order, setOrder] = useState<Row[]>(initialOrder);
const [status, setStatus] = useState("우선 대상이 접수되면 계산된 자리로 끼어듭니다.");
const [reduce, setReduce] = useState(false);
const rowRefs = useRef<Record<string, HTMLLIElement | null>>({}) as Box<Record<string, HTMLLIElement | null>>;
const orderRef = useRef<Row[]>(order) as Box<Row[]>;
const priorityCountRef = 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>;
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
function priorityInsertIndex(newWeight: number): number {
const rows = orderRef.current;
let idx = 0;
for (let i = 0; i < rows.length; i += 1) {
if (rows[i].weight >= newWeight) idx = i + 1; else break;
}
return idx;
}
function insertPriority(): void {
priorityCountRef.current += 1;
const newRow: Row = { id: "p" + priorityCountRef.current, ticket: (301 + priorityCountRef.current - 1) + "번", note: "우선 호출", weight: 10 };
const idx = priorityInsertIndex(newRow.weight);
const next = orderRef.current.slice();
next.splice(idx, 0, newRow);
orderRef.current = next;
setOrder(next);
if (!reduce) {
requestAnimationFrame(() => {
const list = document.getElementById("wplList");
if (!list) return;
const below = Array.prototype.slice.call(list.children, idx + 1);
below.forEach((el: Element, i: number) => {
const li = el as HTMLElement;
const li2 = rowRefs.current[next[idx + 1 + i] ? next[idx + 1 + i].id : ""];
void li;
if (!li2) return;
const gap = li2.offsetHeight + 8;
li2.animate(
[
{ transform: "translateY(" + -gap + "px)" },
{ transform: "translateY(0px)" }
],
{ duration: shiftDuration(gap), easing: SPRING_SETTLE_EASING }
);
});
const newEl = rowRefs.current[newRow.id];
if (newEl) {
newEl.animate(
[{ opacity: 0, transform: "scale(.85)" }, { opacity: 1, transform: "scale(1)" }],
{ duration: 260, easing: "ease-out" }
);
}
});
}
setStatus(newRow.ticket + " 우선 접수 · " + (idx + 1) + "번째 자리로 끼어들었습니다.");
}
function resetAll(): void {
priorityCountRef.current = 0;
orderRef.current = initialOrder();
setOrder(orderRef.current);
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 (priorityCountRef.current >= 2) { resetAll(); } else { insertPriority(); }
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 onInsert(): void { stopDemo(); insertPriority(); }
function onReset(): void { stopDemo(); resetAll(); }
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.wpl}>
<ul className={styles.list} id="wplList" role="list">
{order.map((row) => (
<li
key={row.id}
className={[styles.item, row.weight > 0 ? styles.itemPriority : ""].join(" ")}
ref={(el: HTMLLIElement | null) => { rowRefs.current[row.id] = el; }}
>
{row.weight > 0 ? <span className={styles.badge}>우선</span> : <span className={styles.spacer} />}
<span className={styles.ticket}>{row.ticket}</span>
<span className={styles.note}>{row.note}</span>
</li>
))}
</ul>
<p className={styles.status} aria-live="polite">{status}</p>
<div className={styles.controls}>
<button type="button" className={[styles.btn, styles.btnPrimary].join(" ")} onClick={onInsert} onKeyDown={(e: { key: string; preventDefault: () => void }) => onKeyDown(e, onInsert)}>우선 접수</button>
<button type="button" className={styles.btn} onClick={onReset} onKeyDown={(e: { key: string; preventDefault: () => void }) => onKeyDown(e, onReset)}>초기화</button>
</div>
</div>
</main>
);
}08 Priority Lane That Inserts by a Calculated Priority Gap — Measured area 27.413% · moving frames 10/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
Loaders04 Multi-file stagger — progress rows that queue in one by oneFile Upload Progress Bar Loader: 9 UI Demos
Loaders09 Processing Badge That Swaps to Static Text When a Reduced-motion Value Is DetectedAI Loading Animation: 9 Patterns by One Value
Loaders09 Reduced-motion static badge9 CSS AI Chat Loading Animations to Copy-Paste