01자리 수가 고정된 페이지 창
현재 페이지를 중심으로 양끝 번호와 이웃 번호를 고르고 남는 구간은 말줄임으로 접되, 어느 페이지에서도 칸 수가 같아 버튼 줄의 너비가 흔들리지 않는다.
쓰임페이지 번호 줄의 기본 계산
SLOT_COUNTTOTAL_PAGESGAP

<main class="stage">
<div class="pg is-demo" id="pgRoot">
<nav class="pg__nav" id="pgNav" aria-label="페이지 이동"></nav>
<div class="pg__kinds" id="pgKinds" aria-hidden="true"></div>
<div class="pg__read">
<span class="pg__k">현재</span><b id="pgCur">1</b>
<span class="pg__k">전체</span><b id="pgTotal">40</b>
<span class="pg__k">칸</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 () {
// 기법: 어느 쪽에서도 버튼 칸 수(SLOT_COUNT)가 같도록 보이는 번호를 계산한다.
// 양끝(첫·끝 쪽)은 항상 남기고, 현재 쪽이 한쪽 끝 가까이면 말줄임 하나만, 가운데면 말줄임 둘을 둔다.
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 = '현재 ' + 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 자리 수가 고정된 페이지 창 — 측정 누적면적 5.581% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 페이지네이션 ui 9가지 — 창 계산부터 스프링 알약까지 고르는 쪽 이동
이 데모가 속한 글
페이지네이션 ui 9가지 — 창 계산부터 스프링 알약까지 고르는 쪽 이동페이지네이션은 번호 열 개만 늘어놓으면 끝날 것 같지만, 40쪽쯤 되면 칸 수가 흔들리고 현재 위치가 눈에서 사라진다 — 01번이 그 두 군데를 막는다. 페이지네이션 ui 는 긴 목록을 쪽으로 나눠 지금 어디이고 어 이 글의 데모 전부 보기


