01Page Window with a Fixed Slot Count
It keeps the end numbers and the neighbours of the current page and folds the rest into an ellipsis, always filling the same number of slots so the row never changes width.
Best forThe base calculation of any page-number row
SLOT_COUNTTOTAL_PAGESGAP

<main class="stage">
<div class="pg is-demo" id="pgRoot">
<nav class="pg__nav" id="pgNav" aria-label="Page navigation"></nav>
<div class="pg__kinds" id="pgKinds" aria-hidden="true"></div>
<div class="pg__read">
<span class="pg__k">Page</span><b id="pgCur">1</b>
<span class="pg__k">Total</span><b id="pgTotal">40</b>
<span class="pg__k">Slots</span><b id="pgSlots">7</b>
</div>
<p class="pg__sr" id="pgSr" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 칸 수(SLOT_COUNT)는 고정이고, 어느 쪽에서도 버튼 줄의 너비가 같다.
// 숫자가 바뀐 칸만 JS 가 transform 으로 살짝 튕긴다.
$slot-w: 32px;
$slot-gap: 4px;
@include stage($stage-ink);
.pg {
display: grid;
gap: $sp-3;
justify-items: center;
width: 100%;
max-width: 320px;
color: $stage-paper;
}
.pg__nav, .pg__kinds { display: flex; justify-content: center; gap: $slot-gap; width: 100%; }
.pg__b {
box-sizing: border-box;
flex: 0 1 $slot-w;
min-width: 0;
height: $slot-w;
padding: 0;
border: 1px solid rgba($stage-paper, .28);
border-radius: $r-control;
background: rgba($stage-paper, .08);
color: $stage-paper;
font-family: inherit;
font-size: 13px;
line-height: 16px;
font-weight: 600;
font-variant-numeric: tabular-nums;
cursor: pointer;
}
.pg__b.is-cur { background: $subject-mint; border-color: $subject-mint; color: $subject-ink; }
.pg__b.is-gap { border-color: transparent; background: transparent; color: rgba($stage-paper, .72); cursor: default; }
.pg__b:focus-visible { outline: 2px solid $subject-sky; outline-offset: 2px; }
.pg__kind { flex: 0 1 $slot-w; min-width: 0; height: 4px; border-radius: $r-xs; background: rgba($stage-paper, .24); }
.pg__kind.is-win { background: rgba($subject-mint, .72); }
.pg__kind.is-edge { background: rgba($subject-lilac, .82); }
.pg__read {
display: flex;
align-items: baseline;
gap: $sp-2;
font-size: 12px;
line-height: 16px;
}
.pg__k { color: rgba($stage-paper, .82); }
.pg__read b { color: $subject-sky; font-variant-numeric: tabular-nums; margin-right: $sp-2; }
.pg__sr {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.pg__b { font-size: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.pg__b { transform: none; }
}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);
}
.pg {
display: grid;
gap: 12px;
justify-items: center;
width: 100%;
max-width: 320px;
color: #ffffff;
}
.pg__nav, .pg__kinds {
display: flex;
justify-content: center;
gap: 4px;
width: 100%;
}
.pg__b {
box-sizing: border-box;
flex: 0 1 32px;
min-width: 0;
height: 32px;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.28);
border-radius: 12px;
background: rgba(255, 255, 255, 0.08);
color: #ffffff;
font-family: inherit;
font-size: 13px;
line-height: 16px;
font-weight: 600;
font-variant-numeric: tabular-nums;
cursor: pointer;
}
.pg__b.is-cur {
background: #4cd4a6;
border-color: #4cd4a6;
color: #17141a;
}
.pg__b.is-gap {
border-color: transparent;
background: transparent;
color: rgba(255, 255, 255, 0.72);
cursor: default;
}
.pg__b:focus-visible {
outline: 2px solid #6ec8ff;
outline-offset: 2px;
}
.pg__kind {
flex: 0 1 32px;
min-width: 0;
height: 4px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.24);
}
.pg__kind.is-win {
background: rgba(76, 212, 166, 0.72);
}
.pg__kind.is-edge {
background: rgba(185, 160, 255, 0.82);
}
.pg__read {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 12px;
line-height: 16px;
}
.pg__k {
color: rgba(255, 255, 255, 0.82);
}
.pg__read b {
color: #6ec8ff;
font-variant-numeric: tabular-nums;
margin-right: 8px;
}
.pg__sr {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.pg__b {
font-size: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.pg__b {
transform: none;
}
}(function () {
// 기법: 어느 쪽에서도 버튼 Slots 수(SLOT_COUNT)가 같도록 보이는 번호를 계산한다.
// 양끝(첫·End 쪽)은 항상 남기고, Page 쪽이 한쪽 End 가까이면 말줄임 하나만, 가운데면 말줄임 둘을 둔다.
var SLOT_COUNT = 7;
var TOTAL_PAGES = 40;
var GAP = -1;
var STEP_MS = 230;
var START_MS = 120;
var DEMO_PAGES = [1, 2, 4, 5, 9, 17, 26, 35, 37, 40, 36, 21, 3];
function windowSlots(cur, total, slots) {
var out = [];
var i;
if (total <= slots) {
for (i = 1; i <= total; i += 1) out.push(i);
return out;
}
var inner = slots - 4;
if (cur <= slots - 3) {
for (i = 1; i <= slots - 2; i += 1) out.push(i);
out.push(GAP, total);
return out;
}
if (cur >= total - slots + 4) {
out.push(1, GAP);
for (i = total - slots + 3; i <= total; i += 1) out.push(i);
return out;
}
var lo = cur - Math.floor((inner - 1) / 2);
out.push(1, GAP);
for (i = 0; i < inner; i += 1) out.push(lo + i);
out.push(GAP, total);
return out;
}
var root = document.getElementById('pgRoot');
var nav = document.getElementById('pgNav');
var kindsEl = document.getElementById('pgKinds');
var curEl = document.getElementById('pgCur');
var sr = document.getElementById('pgSr');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var btns = [];
var kinds = [];
var pulse = [];
var jump = [];
for (var i = 0; i < SLOT_COUNT; i += 1) {
var b = document.createElement('button');
b.type = 'button';
b.className = 'pg__b';
nav.appendChild(b);
btns.push(b);
var k = document.createElement('span');
k.className = 'pg__kind';
kindsEl.appendChild(k);
kinds.push(k);
pulse.push(-1);
jump.push(1);
}
document.getElementById('pgTotal').textContent = String(TOTAL_PAGES);
document.getElementById('pgSlots').textContent = String(SLOT_COUNT);
var cur = 1;
var t0 = null;
var lastStep = -1;
var wantFocus = false;
function perfNow() { return t0 === null ? 0 : performance.now() - t0; }
function render(now) {
var arr = windowSlots(cur, TOTAL_PAGES, SLOT_COUNT);
for (var s = 0; s < SLOT_COUNT; s += 1) {
var v = arr[s];
var el = btns[s];
var isGap = v === GAP;
var txt = isGap ? '…' : String(v);
if (el.textContent !== txt) {
el.textContent = txt;
if (!reduce) pulse[s] = now;
}
el.className = 'pg__b' + (isGap ? ' is-gap' : '') + (v === cur ? ' is-cur' : '');
if (v === cur) { el.setAttribute('aria-current', 'page'); } else { el.removeAttribute('aria-current'); }
el.setAttribute('aria-hidden', isGap ? 'true' : 'false');
el.tabIndex = isGap ? -1 : 0;
jump[s] = isGap ? Math.round((arr[s - 1] + arr[s + 1]) / 2) : v;
kinds[s].className = 'pg__kind ' + (isGap ? 'is-gap' : (v === 1 || v === TOTAL_PAGES) ? 'is-edge' : 'is-win');
if (v === cur && wantFocus) { el.focus({ preventScroll: true }); wantFocus = false; }
}
curEl.textContent = String(cur);
}
function go(n, byUser) {
var next = Math.max(1, Math.min(TOTAL_PAGES, n));
if (byUser) root.classList.remove('is-demo');
if (next === cur) return;
cur = next;
render(perfNow());
if (byUser) sr.textContent = 'Page ' + cur + ' / ' + TOTAL_PAGES;
}
nav.addEventListener('click', function (e) {
var el = e.target;
var idx = btns.indexOf(el);
if (idx < 0) return;
go(jump[idx], true);
});
nav.addEventListener('keydown', function (e) {
var key = e.key;
var to = null;
if (key === 'ArrowRight') to = cur + 1;
else if (key === 'ArrowLeft') to = cur - 1;
else if (key === 'Home') to = 1;
else if (key === 'End') to = TOTAL_PAGES;
if (to === null) return;
e.preventDefault();
wantFocus = true;
go(to, true);
wantFocus = false;
});
function tick(ts) {
if (t0 === null) t0 = ts;
var now = ts - t0;
if (root.classList.contains('is-demo')) {
var step = now < START_MS ? 0 : Math.floor((now - START_MS) / STEP_MS) % DEMO_PAGES.length;
if (step !== lastStep) {
lastStep = step;
cur = DEMO_PAGES[step];
render(now);
}
}
for (var s = 0; s < SLOT_COUNT; s += 1) {
if (pulse[s] < 0) continue;
var age = now - pulse[s];
var sc = age >= 420 ? 1 : 1 - 0.5 * Math.exp(-age / 80) * Math.cos(age / 60);
btns[s].style.transform = sc === 1 ? '' : 'scale(' + sc.toFixed(3) + ')';
if (sc === 1) pulse[s] = -1;
}
requestAnimationFrame(tick);
}
if (reduce) {
root.classList.remove('is-demo');
cur = 17;
render(0);
} else {
cur = DEMO_PAGES[0];
render(0);
requestAnimationFrame(tick);
}
})();// FixedSlotPager — vanilla 판(01-pager-fixed-slot-window-ellipsis-collapse) 의 React 포트.
// 어느 쪽에서도 버튼 칸 수(SLOT_COUNT)가 같도록 보이는 번호를 계산한다. 숫자가 바뀐 칸만 transform 으로 튕긴다.
import { useEffect, useRef } from "react";
import styles from "./FixedSlotPager.module.scss";
type Box<T> = { current: T };
const SLOT_COUNT = 7;
const TOTAL_PAGES = 40;
const GAP = -1;
const STEP_MS = 230;
const START_MS = 120;
const DEMO_PAGES = [1, 2, 4, 5, 9, 17, 26, 35, 37, 40, 36, 21, 3];
function windowSlots(cur: number, total: number, slots: number): number[] {
const out: number[] = [];
if (total <= slots) {
for (let i = 1; i <= total; i += 1) out.push(i);
return out;
}
const inner = slots - 4;
if (cur <= slots - 3) {
for (let i = 1; i <= slots - 2; i += 1) out.push(i);
out.push(GAP, total);
return out;
}
if (cur >= total - slots + 4) {
out.push(1, GAP);
for (let i = total - slots + 3; i <= total; i += 1) out.push(i);
return out;
}
const lo = cur - Math.floor((inner - 1) / 2);
out.push(1, GAP);
for (let i = 0; i < inner; i += 1) out.push(lo + i);
out.push(GAP, total);
return out;
}
export default function FixedSlotPager() {
const navRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
const curRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
const srRef = useRef<HTMLParagraphElement | null>(null) as Box<HTMLParagraphElement | null>;
const btnRefs = useRef<Array<HTMLButtonElement | null>>([]) as Box<Array<HTMLButtonElement | null>>;
const kindRefs = useRef<Array<HTMLSpanElement | null>>([]) as Box<Array<HTMLSpanElement | null>>;
useEffect(() => {
const nav = navRef.current;
const curEl = curRef.current;
const sr = srRef.current;
if (!nav || !curEl || !sr) return;
const btns = btnRefs.current;
const kinds = kindRefs.current;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const pulse: number[] = [];
const jump: number[] = [];
for (let i = 0; i < SLOT_COUNT; i += 1) { pulse.push(-1); jump.push(1); }
let demo = true;
let cur = 1;
let t0: number | null = null;
let lastStep = -1;
let wantFocus = false;
let raf = 0;
const perfNow = (): number => (t0 === null ? 0 : performance.now() - t0);
function render(now: number): void {
const arr = windowSlots(cur, TOTAL_PAGES, SLOT_COUNT);
for (let s = 0; s < SLOT_COUNT; s += 1) {
const v = arr[s];
const el = btns[s];
const kind = kinds[s];
if (!el || !kind) continue;
const isGap = v === GAP;
const txt = isGap ? "…" : String(v);
if (el.textContent !== txt) {
el.textContent = txt;
if (!reduce) pulse[s] = now;
}
el.className = [styles.pg__b, isGap ? styles["is-gap"] : "", v === cur ? styles["is-cur"] : ""].join(" ");
if (v === cur) el.setAttribute("aria-current", "page"); else el.removeAttribute("aria-current");
el.setAttribute("aria-hidden", isGap ? "true" : "false");
el.tabIndex = isGap ? -1 : 0;
jump[s] = isGap ? Math.round((arr[s - 1] + arr[s + 1]) / 2) : v;
const kindClass = isGap ? styles["is-gap"] : v === 1 || v === TOTAL_PAGES ? styles["is-edge"] : styles["is-win"];
kind.className = [styles.pg__kind, kindClass].join(" ");
if (v === cur && wantFocus) { el.focus({ preventScroll: true }); wantFocus = false; }
}
curEl!.textContent = String(cur);
}
function go(n: number, byUser: boolean): void {
const next = Math.max(1, Math.min(TOTAL_PAGES, n));
if (byUser) demo = false;
if (next === cur) return;
cur = next;
render(perfNow());
if (byUser) sr!.textContent = "현재 " + cur + " / " + TOTAL_PAGES;
}
const onClick = (e: MouseEvent): void => {
const idx = btns.indexOf(e.target as HTMLButtonElement);
if (idx >= 0) go(jump[idx], true);
};
const onKey = (e: KeyboardEvent): void => {
let to: number | null = null;
if (e.key === "ArrowRight") to = cur + 1;
else if (e.key === "ArrowLeft") to = cur - 1;
else if (e.key === "Home") to = 1;
else if (e.key === "End") to = TOTAL_PAGES;
if (to === null) return;
e.preventDefault();
wantFocus = true;
go(to, true);
wantFocus = false;
};
nav.addEventListener("click", onClick);
nav.addEventListener("keydown", onKey);
function tick(ts: number): void {
if (t0 === null) t0 = ts;
const now = ts - t0;
if (demo) {
const step = now < START_MS ? 0 : Math.floor((now - START_MS) / STEP_MS) % DEMO_PAGES.length;
if (step !== lastStep) {
lastStep = step;
cur = DEMO_PAGES[step];
render(now);
}
}
for (let s = 0; s < SLOT_COUNT; s += 1) {
if (pulse[s] < 0) continue;
const age = now - pulse[s];
const sc = age >= 420 ? 1 : 1 - 0.5 * Math.exp(-age / 80) * Math.cos(age / 60);
const el = btns[s];
if (el) el.style.transform = sc === 1 ? "" : "scale(" + sc.toFixed(3) + ")";
if (sc === 1) pulse[s] = -1;
}
raf = requestAnimationFrame(tick);
}
if (reduce) {
demo = false;
cur = 17;
render(0);
} else {
cur = DEMO_PAGES[0];
render(0);
raf = requestAnimationFrame(tick);
}
return () => {
cancelAnimationFrame(raf);
nav.removeEventListener("click", onClick);
nav.removeEventListener("keydown", onKey);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.pg}>
<nav className={styles.pg__nav} ref={navRef} aria-label="페이지 이동">
{Array.from({ length: SLOT_COUNT }, (_, i) => (
<button type="button" className={styles.pg__b} key={i} ref={(el: HTMLButtonElement | null) => { btnRefs.current[i] = el; }}></button>
))}
</nav>
<div className={styles.pg__kinds} aria-hidden="true">
{Array.from({ length: SLOT_COUNT }, (_, i) => (
<span className={styles.pg__kind} key={i} ref={(el: HTMLSpanElement | null) => { kindRefs.current[i] = el; }}></span>
))}
</div>
<div className={styles.pg__read}>
<span className={styles.pg__k}>현재</span><b ref={curRef}>1</b>
<span className={styles.pg__k}>전체</span><b>{TOTAL_PAGES}</b>
<span className={styles.pg__k}>칸</span><b>{SLOT_COUNT}</b>
</div>
<p className={styles.pg__sr} ref={srRef} aria-live="polite"></p>
</div>
</main>
);
}01 Page Window with a Fixed Slot Count — Measured area 5.581% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · Pagination CSS: 9 Pagers with Spring Pills
The post this demo lives in
Pagination CSS: 9 Pagers with Spring PillsPagination looks finished once ten numbers sit in a row, until page 40 makes the row change width and the current spot v See all demos in this post


