페이지네이션 ui 9가지 — 창 계산부터 스프링 알약까지 고르는 쪽 이동
페이지네이션은 번호 열 개만 늘어놓으면 끝날 것 같지만, 40쪽쯤 되면 칸 수가 흔들리고 현재 위치가 눈에서 사라진다 — 01번이 그 두 군데를 막는다. 페이지네이션 ui 는 긴 목록을 쪽으로 나눠 지금 어디이고 어디로 갈 수 있는지 알려 주는 번호 줄이다.
01 pager-fixed-slot-window-ellipsis-collapse
02 pager-active-pill-spring-slide-overshoot
03 pager-direction-aware-content-slide
04 pager-long-jump-stagger-trail-ripple
05 pager-edge-rubber-band-nudge-disable
06 pager-dots-to-bar-morph-compact
07 pager-progress-rail-scrub-inertia-snap
08 pager-cursor-next-only-breadcrumb-trail
09 pager-load-more-remaining-count-ring
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 자리 수가 고정된 페이지 창
- 02 스프링으로 미끄러지는 활성 알약
- 03 방향을 아는 콘텐츠 전환
- 04 멀리 뛸 때 번호가 차례로 번쩍이는 궤적
- 05 끝에서 고무줄처럼 튕기는 이전·다음
- 06 점이 막대로 늘어나는 모바일 페이저
- 07 레일을 끌어 쪽을 고르는 관성 스냅
- 08 다음만 있는 커서 페이저와 지나온 길
- 09 남은 개수를 링으로 보여 주는 더 보기
페이지네이션 ui 9가지 — 계산한 창과 스프링이 이동을 매끄럽게 한다
아홉 개는 쪽 번호 줄이 깨지는 순서대로 놓았다. 01 은 칸 수가 변하지 않는 번호 창의 계산이고, 02 는 현재 쪽을 가리키는 알약이 스프링으로 따라가는 장면, 03 은 본문이 어느 방향에서 들어오는지 정하는 전환이다. 04 는 멀리 뛸 때 거쳐 가는 번호가 차례로 켜지는 궤적, 05 는 끝에서 고무줄처럼 튕기는 이전·다음, 06 은 점이 막대로 늘어나는 모바일용 줄이다. 07 은 레일을 끌어 쪽을 고르는 관성 스냅, 08 은 다음만 있는 커서 방식과 지나온 길, 09 는 남은 개수를 링과 숫자로 보여 주는 더 보기 버튼이다.
이런 화면에서 쓴다
쇼핑 목록이 40쪽까지 이어지거나 관리자 화면의 표가 수백 행일 때, 사용자는 지금 몇 쪽인지와 마지막이 어디인지를 번호 줄에서 읽는다. 모바일 페이지네이션 ui 는 손가락이 닿는 폭이 좁아서 번호를 전부 늘어놓지 못하고, 그래서 01 의 창 계산이나 06 의 점 줄처럼 보이는 칸을 줄이는 방식이 먼저 필요하다. 목록 본문이 바뀌는 순간은 03, 현재 위치 표시는 02, 서버가 총 쪽수를 모를 때는 08 로 나눠 읽으면 된다.
이웃 편과 무엇이 다른가
스와이프 삭제 리스트 UI 9가지 — 페이지네이션까지는 번호 말줄임 하나를 스와이프 리스트의 한 항목으로 다뤘다. 이 편은 칸 수 자체를 고정하는 계산, 알약과 본문 전환의 스프링, 먼 점프의 궤적처럼 쪽 번호 줄 하나만 아홉 가지로 깊게 판다.
기법별 코드로 보는 페이지 이동
아홉 항목은 nav 안의 버튼(07 은 슬라이더 손잡이, 09 는 더 보기 버튼 하나)과 현재 쪽을 읽어 주는 숨은 문단으로 구성되고, 아래에는 이동을 계산하는 핵심 구간만 발췌했다. 쪽은 서버에서 받지 않고 상수로 정한 총 쪽수 안에서 번호만 바꾸며, 시연 중에 직접 누르거나 키를 치면 자동 시연이 멈추고 같은 계산이 입력을 받는다. 나머지 마크업과 스타일은 데모 아래 압축 파일에 통째로 들어 있다.
01칸 수가 변하지 않는 번호 창
windowSlots 는 총 쪽수와 칸 수를 받아 언제나 같은 길이의 배열을 돌려주고, GAP 값 -1 이 말줄임 칸이 된다. 현재 쪽이 4 이하이면 1부터 5 와 끝 쪽, 37 이상이면 첫 쪽과 36부터 40, 그 사이는 첫 쪽·가운데 세 쪽·끝 쪽을 고르므로 어느 쪽에서도 줄 너비가 같다. 01 항목은 누적면적 5.6%·강도 평균 71.5·움직인 프레임 20/23 이다.

<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 포함 · 비밀번호 보기
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;
}
02스프링으로 미끄러지는 활성 알약
알약은 위치 x 와 속도 v 를 들고 -PILL_STIFFNESS * (x - target) - PILL_DAMPING * v 로 가속도를 구해 1/240초씩 쪼개 적분하므로 도착점을 살짝 넘었다 돌아온다. 가로로 늘어나는 정도는 이동 거리가 아니라 그 순간의 속도에 비례해서, 알약이 멈추는 순간에는 원래 모양으로 돌아온다. 02 항목은 누적면적 7.7%·강도 평균 180.0·움직인 프레임 21/23 이다.

<main class="stage">
<div class="pg is-demo" id="pgRoot">
<nav class="pg__nav" id="pgNav" aria-label="페이지 이동">
<span class="pg__pill" id="pgPill" aria-hidden="true"></span>
</nav>
<div class="pg__read">
<span class="pg__k">오버슛</span><b id="pgOver">0</b><span class="pg__u">%</span>
</div>
<p class="pg__sr" id="pgSr" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 알약 위치는 JS 가 스프링으로 적분한 값이다 (PILL_STIFFNESS · PILL_DAMPING).
// 속도에 비례해 알약이 늘어나고(scale) 도착점을 넘었다 돌아온다.
$slot-w: 36px;
$slot-gap: 4px;
@include stage($stage-ink);
.pg { display: grid; gap: $sp-4; justify-items: center; color: $stage-paper; }
.pg__nav {
position: relative;
display: flex;
gap: $slot-gap;
padding: $sp-1;
border-radius: $r-pill;
background: rgba($stage-paper, .1);
}
.pg__pill {
position: absolute;
left: $sp-1;
top: $sp-1;
width: $slot-w;
height: $slot-w;
border-radius: $r-pill;
background: $subject-lilac;
transform-origin: 50% 50%;
will-change: transform;
}
.pg__b {
position: relative;
box-sizing: border-box;
width: $slot-w;
height: $slot-w;
padding: 0;
border: 0;
border-radius: $r-pill;
background: transparent;
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-under { color: $subject-ink; }
.pg__b:focus-visible { outline: 2px solid $subject-sky; outline-offset: 2px; }
.pg__read { display: flex; align-items: baseline; gap: $sp-1; font-size: 12px; line-height: 16px; }
.pg__k { color: rgba($stage-paper, .82); margin-right: $sp-1; }
.pg__u { color: rgba($stage-paper, .82); }
.pg__read b { color: $subject-pink; font-variant-numeric: tabular-nums; min-width: 24px; text-align: right; }
.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__pill { transition: 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: 16px;
justify-items: center;
color: #ffffff;
}
.pg__nav {
position: relative;
display: flex;
gap: 4px;
padding: 4px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.1);
}
.pg__pill {
position: absolute;
left: 4px;
top: 4px;
width: 36px;
height: 36px;
border-radius: 999px;
background: #b9a0ff;
transform-origin: 50% 50%;
will-change: transform;
}
.pg__b {
position: relative;
box-sizing: border-box;
width: 36px;
height: 36px;
padding: 0;
border: 0;
border-radius: 999px;
background: transparent;
color: #ffffff;
font-family: inherit;
font-size: 13px;
line-height: 16px;
font-weight: 600;
font-variant-numeric: tabular-nums;
cursor: pointer;
}
.pg__b.is-under {
color: #17141a;
}
.pg__b:focus-visible {
outline: 2px solid #6ec8ff;
outline-offset: 2px;
}
.pg__read {
display: flex;
align-items: baseline;
gap: 4px;
font-size: 12px;
line-height: 16px;
}
.pg__k {
color: rgba(255, 255, 255, 0.82);
margin-right: 4px;
}
.pg__u {
color: rgba(255, 255, 255, 0.82);
}
.pg__read b {
color: #ff9ebb;
font-variant-numeric: tabular-nums;
min-width: 24px;
text-align: right;
}
.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__pill {
transition: none;
}
}(function () {
// 기법: 알약의 x 위치를 CSS 전환이 아니라 스프링 방정식으로 직접 적분한다.
// 가속도 = -강성 x (위치 - 목표) - 감쇠 x 속도. 속도가 클수록 알약이 진행 방향으로 늘어난다.
var PAGE_COUNT = 7;
var SLOT_W = 36;
var SLOT_GAP = 4;
var PILL_STIFFNESS = 320;
var PILL_DAMPING = 17;
var STRETCH_GAIN = 0.0011;
var STRETCH_MAX = 0.7;
var SUBSTEP = 1 / 240;
var STEP_MS = 400;
var START_MS = 150;
var DEMO_PAGES = [1, 7, 3, 6, 1, 4, 7];
var root = document.getElementById('pgRoot');
var nav = document.getElementById('pgNav');
var pill = document.getElementById('pgPill');
var overEl = document.getElementById('pgOver');
var sr = document.getElementById('pgSr');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var btns = [];
for (var i = 0; i < PAGE_COUNT; i += 1) {
var b = document.createElement('button');
b.type = 'button';
b.className = 'pg__b';
b.textContent = String(i + 1);
nav.appendChild(b);
btns.push(b);
}
function slotX(page) { return (page - 1) * (SLOT_W + SLOT_GAP); }
var cur = 1;
var x = 0;
var v = 0;
var fromX = 0;
var peak = 0;
var lastTs = null;
var t0 = null;
var lastStep = -1;
function setCur(n, byUser) {
var next = Math.max(1, Math.min(PAGE_COUNT, n));
if (byUser) root.classList.remove('is-demo');
if (next === cur) return;
cur = next;
fromX = x;
peak = 0;
for (var s = 0; s < PAGE_COUNT; s += 1) {
if (s + 1 === cur) btns[s].setAttribute('aria-current', 'page'); else btns[s].removeAttribute('aria-current');
}
if (reduce) { x = slotX(cur); v = 0; paint(); }
if (byUser) sr.textContent = '현재 ' + cur + ' / ' + PAGE_COUNT;
}
function paint() {
var sx = 1 + Math.min(STRETCH_MAX, Math.abs(v) * STRETCH_GAIN);
var sy = 1 / Math.sqrt(sx);
pill.style.transform = 'translateX(' + x.toFixed(2) + 'px) scale(' + sx.toFixed(3) + ',' + sy.toFixed(3) + ')';
var near = Math.max(0, Math.min(PAGE_COUNT - 1, Math.round(x / (SLOT_W + SLOT_GAP))));
for (var s = 0; s < PAGE_COUNT; s += 1) btns[s].className = 'pg__b' + (s === near ? ' is-under' : '');
}
nav.addEventListener('click', function (e) {
var idx = btns.indexOf(e.target);
if (idx >= 0) setCur(idx + 1, true);
});
nav.addEventListener('keydown', function (e) {
var to = 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 = PAGE_COUNT;
if (to === null) return;
e.preventDefault();
setCur(to, true);
btns[Math.max(1, Math.min(PAGE_COUNT, to)) - 1].focus({ preventScroll: true });
});
function tick(ts) {
if (t0 === null) t0 = ts;
var now = ts - t0;
var dt = lastTs === null ? 0.016 : Math.min(0.05, (ts - lastTs) / 1000);
lastTs = ts;
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; setCur(DEMO_PAGES[step], false); }
}
var target = slotX(cur);
var left = dt;
while (left > 0) {
var h = Math.min(SUBSTEP, left);
v += (-PILL_STIFFNESS * (x - target) - PILL_DAMPING * v) * h;
x += v * h;
left -= h;
}
var span = target - fromX;
if (span !== 0) {
var over = ((x - target) * Math.sign(span)) / Math.abs(span);
if (over > peak) peak = over;
overEl.textContent = String(Math.round(peak * 100));
}
paint();
requestAnimationFrame(tick);
}
btns[0].setAttribute('aria-current', 'page');
if (reduce) {
root.classList.remove('is-demo');
cur = 4;
x = slotX(cur);
for (var q = 0; q < PAGE_COUNT; q += 1) {
if (q + 1 === cur) btns[q].setAttribute('aria-current', 'page'); else btns[q].removeAttribute('aria-current');
}
paint();
} else {
paint();
requestAnimationFrame(tick);
}
})();// SpringPillPager — vanilla 판(02-pager-active-pill-spring-slide-overshoot) 의 React 포트.
// 알약의 x 위치를 CSS 전환이 아니라 스프링 방정식(강성·감쇠)으로 직접 적분하고, 속도에 비례해 늘어나게 한다.
import { useEffect, useRef } from "react";
import styles from "./SpringPillPager.module.scss";
type Box<T> = { current: T };
const PAGE_COUNT = 7;
const SLOT_W = 36;
const SLOT_GAP = 4;
const PILL_STIFFNESS = 320;
const PILL_DAMPING = 17;
const STRETCH_GAIN = 0.0011;
const STRETCH_MAX = 0.7;
const SUBSTEP = 1 / 240;
const STEP_MS = 400;
const START_MS = 150;
const DEMO_PAGES = [1, 7, 3, 6, 1, 4, 7];
export default function SpringPillPager() {
const navRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
const pillRef = useRef<HTMLSpanElement | null>(null) as Box<HTMLSpanElement | null>;
const overRef = 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>>;
useEffect(() => {
const nav = navRef.current;
const pill = pillRef.current;
const overEl = overRef.current;
const sr = srRef.current;
if (!nav || !pill || !overEl || !sr) return;
const btns = btnRefs.current;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const slotX = (page: number): number => (page - 1) * (SLOT_W + SLOT_GAP);
let demo = true;
let cur = 1;
let x = 0;
let v = 0;
let fromX = 0;
let peak = 0;
let lastTs: number | null = null;
let t0: number | null = null;
let lastStep = -1;
let raf = 0;
function mark(): void {
for (let s = 0; s < PAGE_COUNT; s += 1) {
const el = btns[s];
if (!el) continue;
if (s + 1 === cur) el.setAttribute("aria-current", "page"); else el.removeAttribute("aria-current");
}
}
function paint(): void {
const sx = 1 + Math.min(STRETCH_MAX, Math.abs(v) * STRETCH_GAIN);
const sy = 1 / Math.sqrt(sx);
pill!.style.transform = "translateX(" + x.toFixed(2) + "px) scale(" + sx.toFixed(3) + "," + sy.toFixed(3) + ")";
const near = Math.max(0, Math.min(PAGE_COUNT - 1, Math.round(x / (SLOT_W + SLOT_GAP))));
for (let s = 0; s < PAGE_COUNT; s += 1) {
const el = btns[s];
if (el) el.className = [styles.pg__b, s === near ? styles["is-under"] : ""].join(" ");
}
}
function setCur(n: number, byUser: boolean): void {
const next = Math.max(1, Math.min(PAGE_COUNT, n));
if (byUser) demo = false;
if (next === cur) return;
cur = next;
fromX = x;
peak = 0;
mark();
if (reduce) { x = slotX(cur); v = 0; paint(); }
if (byUser) sr!.textContent = "현재 " + cur + " / " + PAGE_COUNT;
}
const onClick = (e: MouseEvent): void => {
const idx = btns.indexOf(e.target as HTMLButtonElement);
if (idx >= 0) setCur(idx + 1, 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 = PAGE_COUNT;
if (to === null) return;
e.preventDefault();
setCur(to, true);
const target = btns[Math.max(1, Math.min(PAGE_COUNT, to)) - 1];
if (target) target.focus({ preventScroll: true });
};
nav.addEventListener("click", onClick);
nav.addEventListener("keydown", onKey);
function tick(ts: number): void {
if (t0 === null) t0 = ts;
const now = ts - t0;
const dt = lastTs === null ? 0.016 : Math.min(0.05, (ts - lastTs) / 1000);
lastTs = ts;
if (demo) {
const step = now < START_MS ? 0 : Math.floor((now - START_MS) / STEP_MS) % DEMO_PAGES.length;
if (step !== lastStep) { lastStep = step; setCur(DEMO_PAGES[step], false); }
}
const target = slotX(cur);
let left = dt;
while (left > 0) {
const h = Math.min(SUBSTEP, left);
v += (-PILL_STIFFNESS * (x - target) - PILL_DAMPING * v) * h;
x += v * h;
left -= h;
}
const span = target - fromX;
if (span !== 0) {
const over = ((x - target) * Math.sign(span)) / Math.abs(span);
if (over > peak) peak = over;
overEl!.textContent = String(Math.round(peak * 100));
}
paint();
raf = requestAnimationFrame(tick);
}
if (reduce) {
demo = false;
cur = 4;
x = slotX(cur);
mark();
paint();
} else {
mark();
paint();
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="페이지 이동">
<span className={styles.pg__pill} ref={pillRef} aria-hidden="true"></span>
{Array.from({ length: PAGE_COUNT }, (_, i) => (
<button type="button" className={styles.pg__b} key={i} ref={(el: HTMLButtonElement | null) => { btnRefs.current[i] = el; }}>{i + 1}</button>
))}
</nav>
<div className={styles.pg__read}>
<span className={styles.pg__k}>오버슛</span><b ref={overRef}>0</b><span className={styles.pg__u}>%</span>
</div>
<p className={styles.pg__sr} ref={srRef} aria-live="polite"></p>
</div>
</main>
);
}02 · 스프링으로 미끄러지는 활성 알약 — 측정 누적면적 7.7% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
var target = slotX(cur);
var left = dt;
while (left > 0) {
var h = Math.min(SUBSTEP, left);
v += (-PILL_STIFFNESS * (x - target) - PILL_DAMPING * v) * h;
x += v * h;
left -= h;
}
var span = target - fromX;
if (span !== 0) {
var over = ((x - target) * Math.sign(span)) / Math.abs(span);
if (over > peak) peak = over;
overEl.textContent = String(Math.round(peak * 100));
}
03방향을 아는 콘텐츠 전환
다음 쪽으로 가면 새 카드가 오른쪽에서, 이전 쪽으로 가면 왼쪽에서 들어오고, 이동 폭 slide·흐림 blur·시간 ms 는 건너뛴 쪽수 dist 가 클수록 상한까지 커진다. 한 쪽 이동은 42px 이고 7쪽 이상은 상한 120px 로 고정된다. 03 항목은 누적면적 19.4%·강도 평균 43.0·움직인 프레임 19/23 이다.

<main class="stage">
<div class="pg is-demo" id="pgRoot">
<div class="pg__view" id="pgView">
<div class="pg__card" id="pgCardA" aria-hidden="true"></div>
<div class="pg__card" id="pgCardB"></div>
</div>
<nav class="pg__nav" aria-label="페이지 이동">
<button type="button" class="pg__b pg__b--jump" id="pgM5" aria-label="−5">−5</button>
<button type="button" class="pg__b" id="pgPrev" aria-label="이전 쪽">‹</button>
<span class="pg__count"><b id="pgCur">1</b><span class="pg__sep">/</span><span id="pgTotal">12</span></span>
<button type="button" class="pg__b" id="pgNext" aria-label="다음 쪽">›</button>
<button type="button" class="pg__b pg__b--jump" id="pgP5" aria-label="+5">+5</button>
</nav>
<p class="pg__sr" id="pgSr" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 이동 방향(부호)과 건너뛴 쪽수(거리)가 슬라이드 폭·흐림·시간을 정한다 (JS 계산 → transform·opacity·blur).
@include stage($stage-ink);
.pg { display: grid; gap: $sp-3; justify-items: center; color: $stage-paper; width: min(100%, 280px); }
.pg__view { position: relative; width: 100%; height: 100px; overflow: hidden; border-radius: $r-card; background: rgba($stage-paper, .06); }
.pg__card {
position: absolute;
inset: 0;
box-sizing: border-box;
display: grid;
align-content: center;
gap: $sp-2;
padding: $sp-3 $sp-4;
border-radius: $r-card;
will-change: transform, opacity;
}
.pg__card.is-mint { background: rgba($subject-mint, .36); }
.pg__card.is-lilac { background: rgba($subject-lilac, .4); }
.pg__card.is-pink { background: rgba($subject-pink, .38); }
.pg__card.is-sky { background: rgba($subject-sky, .38); }
.pg__no { font-size: 24px; line-height: 28px; font-weight: 700; color: $stage-paper; font-variant-numeric: tabular-nums; }
.pg__row { display: block; height: 8px; border-radius: $r-xs; background: rgba($stage-paper, .5); }
.pg__nav { display: flex; align-items: center; gap: $sp-2; }
.pg__b {
box-sizing: border-box;
width: 36px;
height: 36px;
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: 16px;
line-height: 20px;
font-weight: 600;
cursor: pointer;
}
.pg__b--jump { width: 40px; font-size: 12px; color: rgba($stage-paper, .88); }
.pg__b:focus-visible { outline: 2px solid $subject-sky; outline-offset: 2px; }
.pg__count { display: flex; align-items: baseline; gap: $sp-1; min-width: 48px; justify-content: center; font-size: 13px; line-height: 16px; color: rgba($stage-paper, .82); font-variant-numeric: tabular-nums; }
.pg__count b { font-size: 16px; line-height: 20px; color: $subject-mint; }
.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 { width: 32px; height: 32px; }
.pg__b--jump { width: 36px; }
}
@media (prefers-reduced-motion: reduce) {
.pg__card { transition: 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;
color: #ffffff;
width: min(100%, 280px);
}
.pg__view {
position: relative;
width: 100%;
height: 100px;
overflow: hidden;
border-radius: 16px;
background: rgba(255, 255, 255, 0.06);
}
.pg__card {
position: absolute;
inset: 0;
box-sizing: border-box;
display: grid;
align-content: center;
gap: 8px;
padding: 12px 16px;
border-radius: 16px;
will-change: transform, opacity;
}
.pg__card.is-mint {
background: rgba(76, 212, 166, 0.36);
}
.pg__card.is-lilac {
background: rgba(185, 160, 255, 0.4);
}
.pg__card.is-pink {
background: rgba(255, 158, 187, 0.38);
}
.pg__card.is-sky {
background: rgba(110, 200, 255, 0.38);
}
.pg__no {
font-size: 24px;
line-height: 28px;
font-weight: 700;
color: #ffffff;
font-variant-numeric: tabular-nums;
}
.pg__row {
display: block;
height: 8px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.5);
}
.pg__nav {
display: flex;
align-items: center;
gap: 8px;
}
.pg__b {
box-sizing: border-box;
width: 36px;
height: 36px;
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: 16px;
line-height: 20px;
font-weight: 600;
cursor: pointer;
}
.pg__b--jump {
width: 40px;
font-size: 12px;
color: rgba(255, 255, 255, 0.88);
}
.pg__b:focus-visible {
outline: 2px solid #6ec8ff;
outline-offset: 2px;
}
.pg__count {
display: flex;
align-items: baseline;
gap: 4px;
min-width: 48px;
justify-content: center;
font-size: 13px;
line-height: 16px;
color: rgba(255, 255, 255, 0.82);
font-variant-numeric: tabular-nums;
}
.pg__count b {
font-size: 16px;
line-height: 20px;
color: #4cd4a6;
}
.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 {
width: 32px;
height: 32px;
}
.pg__b--jump {
width: 36px;
}
}
@media (prefers-reduced-motion: reduce) {
.pg__card {
transition: none;
}
}(function () {
// 기법: 이동한 방향(부호)과 건너뛴 쪽수(거리)로 슬라이드 폭·흐림·시간을 계산한다.
// 새 내용은 진행 방향 반대쪽에서 들어오고, 거리가 클수록 더 멀리·더 흐리게·더 오래 움직인다.
var TOTAL_PAGES = 12;
var BASE_SLIDE = 28;
var SLIDE_PER_PAGE = 14;
var SLIDE_MAX = 120;
var BLUR_PER_PAGE = 0.9;
var BLUR_MAX = 6;
var BASE_MS = 260;
var MS_PER_PAGE = 30;
var MS_MAX = 560;
var STEP_MS = 440;
var START_MS = 150;
var DEMO_MOVES = [1, 1, 6, -1, -7, 1, 8, -4];
var TONES = ['is-mint', 'is-lilac', 'is-pink', 'is-sky'];
var root = document.getElementById('pgRoot');
var cardA = document.getElementById('pgCardA');
var cardB = document.getElementById('pgCardB');
var curEl = document.getElementById('pgCur');
var sr = document.getElementById('pgSr');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
document.getElementById('pgTotal').textContent = String(TOTAL_PAGES);
function fillCard(el, page) {
el.className = 'pg__card ' + TONES[page % TONES.length];
var rows = '';
for (var r = 0; r < 3; r += 1) {
var w = 44 + ((page * 37 + r * 53) % 50);
rows += '<i class="pg__row" style="width:' + w + '%"></i>';
}
el.innerHTML = '<b class="pg__no">' + page + '</b>' + rows;
}
var cur = 1;
var shown = 1;
var anim = null;
var lastStep = -1;
var t0 = null;
var lastTs = null;
fillCard(cardB, 1);
function perfNow() { return t0 === null ? 0 : performance.now() - t0; }
function easeOut(p) { return 1 - Math.pow(1 - p, 3); }
function move(delta, byUser, now) {
if (byUser) root.classList.remove('is-demo');
var next = Math.max(1, Math.min(TOTAL_PAGES, cur + delta));
if (next === cur) return;
var dir = next > cur ? 1 : -1;
var dist = Math.abs(next - cur);
var prevPage = cur;
cur = next;
curEl.textContent = String(cur);
if (byUser) sr.textContent = '현재 ' + cur + ' / ' + TOTAL_PAGES;
if (reduce) {
fillCard(cardB, cur);
cardB.style.transform = '';
cardB.style.opacity = '1';
cardB.style.filter = '';
cardA.style.opacity = '0';
return;
}
fillCard(cardA, prevPage);
fillCard(cardB, cur);
anim = {
t: now,
dir: dir,
slide: Math.min(SLIDE_MAX, BASE_SLIDE + dist * SLIDE_PER_PAGE),
blur: Math.min(BLUR_MAX, dist * BLUR_PER_PAGE),
ms: Math.min(MS_MAX, BASE_MS + dist * MS_PER_PAGE)
};
}
function paintAnim(now) {
if (!anim) return;
var p = Math.min(1, (now - anim.t) / anim.ms);
var e = easeOut(p);
cardB.style.transform = 'translateX(' + (anim.dir * anim.slide * (1 - e)).toFixed(1) + 'px)';
cardB.style.opacity = e.toFixed(3);
cardB.style.filter = anim.blur * (1 - e) > 0.05 ? 'blur(' + (anim.blur * (1 - e)).toFixed(2) + 'px)' : '';
cardA.style.transform = 'translateX(' + (-anim.dir * anim.slide * 0.5 * e).toFixed(1) + 'px)';
cardA.style.opacity = (1 - e).toFixed(3);
cardA.style.filter = anim.blur * e > 0.05 ? 'blur(' + (anim.blur * e).toFixed(2) + 'px)' : '';
if (p >= 1) { anim = null; cardA.style.opacity = '0'; cardB.style.transform = ''; cardB.style.filter = ''; }
}
document.getElementById('pgPrev').addEventListener('click', function () { move(-1, true, perfNow()); });
document.getElementById('pgNext').addEventListener('click', function () { move(1, true, perfNow()); });
document.getElementById('pgM5').addEventListener('click', function () { move(-5, true, perfNow()); });
document.getElementById('pgP5').addEventListener('click', function () { move(5, true, perfNow()); });
root.addEventListener('keydown', function (e) {
if (e.key === 'ArrowRight') { e.preventDefault(); move(1, true, perfNow()); }
else if (e.key === 'ArrowLeft') { e.preventDefault(); move(-1, true, perfNow()); }
});
function tick(ts) {
if (t0 === null) t0 = ts;
var now = ts - t0;
lastTs = ts;
if (root.classList.contains('is-demo')) {
var step = now < START_MS ? -1 : Math.floor((now - START_MS) / STEP_MS);
if (step !== lastStep && step >= 0) {
lastStep = step;
var mv = DEMO_MOVES[step % DEMO_MOVES.length];
var dest = cur + mv;
if (dest < 1 || dest > TOTAL_PAGES) mv = -mv;
move(mv, false, now);
}
}
paintAnim(now);
requestAnimationFrame(tick);
}
cardA.style.opacity = '0';
if (reduce) {
root.classList.remove('is-demo');
} else {
requestAnimationFrame(tick);
}
})();// DirectionSlidePager — vanilla 판(03-pager-direction-aware-content-slide) 의 React 포트.
// 이동 방향(부호)과 건너뛴 쪽수(거리)로 슬라이드 폭·흐림·시간을 계산한다.
import { useEffect, useRef } from "react";
import styles from "./DirectionSlidePager.module.scss";
type Box<T> = { current: T };
type Anim = { t: number; dir: number; slide: number; blur: number; ms: number };
const TOTAL_PAGES = 12;
const BASE_SLIDE = 28;
const SLIDE_PER_PAGE = 14;
const SLIDE_MAX = 120;
const BLUR_PER_PAGE = 0.9;
const BLUR_MAX = 6;
const BASE_MS = 260;
const MS_PER_PAGE = 30;
const MS_MAX = 560;
const STEP_MS = 440;
const START_MS = 150;
const DEMO_MOVES = [1, 1, 6, -1, -7, 1, 8, -4];
const TONES = ["is-mint", "is-lilac", "is-pink", "is-sky"];
export default function DirectionSlidePager() {
const rootRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const cardARef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const cardBRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const curRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
const srRef = useRef<HTMLParagraphElement | null>(null) as Box<HTMLParagraphElement | null>;
const prevRef = useRef<HTMLButtonElement | null>(null) as Box<HTMLButtonElement | null>;
const nextRef = useRef<HTMLButtonElement | null>(null) as Box<HTMLButtonElement | null>;
const m5Ref = useRef<HTMLButtonElement | null>(null) as Box<HTMLButtonElement | null>;
const p5Ref = useRef<HTMLButtonElement | null>(null) as Box<HTMLButtonElement | null>;
useEffect(() => {
const root = rootRef.current;
const cardA = cardARef.current;
const cardB = cardBRef.current;
const curEl = curRef.current;
const sr = srRef.current;
const prevBtn = prevRef.current;
const nextBtn = nextRef.current;
const m5 = m5Ref.current;
const p5 = p5Ref.current;
if (!root || !cardA || !cardB || !curEl || !sr || !prevBtn || !nextBtn || !m5 || !p5) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function fillCard(el: HTMLElement, page: number): void {
el.className = [styles.pg__card, styles[TONES[page % TONES.length]]].join(" ");
let rows = "";
for (let r = 0; r < 3; r += 1) {
const w = 44 + ((page * 37 + r * 53) % 50);
rows += '<i class="' + styles.pg__row + '" style="width:' + w + '%"></i>';
}
el.innerHTML = '<b class="' + styles.pg__no + '">' + page + "</b>" + rows;
}
let demo = true;
let cur = 1;
let anim: Anim | null = null;
let lastStep = -1;
let t0: number | null = null;
let raf = 0;
fillCard(cardB, 1);
cardA.style.opacity = "0";
const perfNow = (): number => (t0 === null ? 0 : performance.now() - t0);
const easeOut = (p: number): number => 1 - Math.pow(1 - p, 3);
function move(delta: number, byUser: boolean, now: number): void {
if (byUser) demo = false;
const next = Math.max(1, Math.min(TOTAL_PAGES, cur + delta));
if (next === cur) return;
const dir = next > cur ? 1 : -1;
const dist = Math.abs(next - cur);
const prevPage = cur;
cur = next;
curEl!.textContent = String(cur);
if (byUser) sr!.textContent = "현재 " + cur + " / " + TOTAL_PAGES;
if (reduce) {
fillCard(cardB!, cur);
cardB!.style.transform = "";
cardB!.style.opacity = "1";
cardB!.style.filter = "";
cardA!.style.opacity = "0";
return;
}
fillCard(cardA!, prevPage);
fillCard(cardB!, cur);
anim = {
t: now,
dir,
slide: Math.min(SLIDE_MAX, BASE_SLIDE + dist * SLIDE_PER_PAGE),
blur: Math.min(BLUR_MAX, dist * BLUR_PER_PAGE),
ms: Math.min(MS_MAX, BASE_MS + dist * MS_PER_PAGE),
};
}
function paintAnim(now: number): void {
if (!anim) return;
const p = Math.min(1, (now - anim.t) / anim.ms);
const e = easeOut(p);
cardB!.style.transform = "translateX(" + (anim.dir * anim.slide * (1 - e)).toFixed(1) + "px)";
cardB!.style.opacity = e.toFixed(3);
cardB!.style.filter = anim.blur * (1 - e) > 0.05 ? "blur(" + (anim.blur * (1 - e)).toFixed(2) + "px)" : "";
cardA!.style.transform = "translateX(" + (-anim.dir * anim.slide * 0.5 * e).toFixed(1) + "px)";
cardA!.style.opacity = (1 - e).toFixed(3);
cardA!.style.filter = anim.blur * e > 0.05 ? "blur(" + (anim.blur * e).toFixed(2) + "px)" : "";
if (p >= 1) {
anim = null;
cardA!.style.opacity = "0";
cardB!.style.transform = "";
cardB!.style.filter = "";
}
}
const onPrev = (): void => move(-1, true, perfNow());
const onNext = (): void => move(1, true, perfNow());
const onM5 = (): void => move(-5, true, perfNow());
const onP5 = (): void => move(5, true, perfNow());
const onKey = (e: KeyboardEvent): void => {
if (e.key === "ArrowRight") { e.preventDefault(); move(1, true, perfNow()); }
else if (e.key === "ArrowLeft") { e.preventDefault(); move(-1, true, perfNow()); }
};
prevBtn.addEventListener("click", onPrev);
nextBtn.addEventListener("click", onNext);
m5.addEventListener("click", onM5);
p5.addEventListener("click", onP5);
root.addEventListener("keydown", onKey);
function tick(ts: number): void {
if (t0 === null) t0 = ts;
const now = ts - t0;
if (demo) {
const step = now < START_MS ? -1 : Math.floor((now - START_MS) / STEP_MS);
if (step !== lastStep && step >= 0) {
lastStep = step;
let mv = DEMO_MOVES[step % DEMO_MOVES.length];
const dest = cur + mv;
if (dest < 1 || dest > TOTAL_PAGES) mv = -mv;
move(mv, false, now);
}
}
paintAnim(now);
raf = requestAnimationFrame(tick);
}
if (reduce) demo = false; else raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
prevBtn.removeEventListener("click", onPrev);
nextBtn.removeEventListener("click", onNext);
m5.removeEventListener("click", onM5);
p5.removeEventListener("click", onP5);
root.removeEventListener("keydown", onKey);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.pg} ref={rootRef}>
<div className={styles.pg__view}>
<div className={styles.pg__card} ref={cardARef} aria-hidden="true"></div>
<div className={styles.pg__card} ref={cardBRef}></div>
</div>
<nav className={styles.pg__nav} aria-label="페이지 이동">
<button type="button" className={[styles.pg__b, styles["pg__b--jump"]].join(" ")} ref={m5Ref} aria-label="−5">−5</button>
<button type="button" className={styles.pg__b} ref={prevRef} aria-label="이전 쪽">‹</button>
<span className={styles.pg__count}><b ref={curRef}>1</b><span className={styles.pg__sep}>/</span><span>{TOTAL_PAGES}</span></span>
<button type="button" className={styles.pg__b} ref={nextRef} aria-label="다음 쪽">›</button>
<button type="button" className={[styles.pg__b, styles["pg__b--jump"]].join(" ")} ref={p5Ref} aria-label="+5">+5</button>
</nav>
<p className={styles.pg__sr} ref={srRef} aria-live="polite"></p>
</div>
</main>
);
}03 · 방향을 아는 콘텐츠 전환 — 측정 누적면적 19.364% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
fillCard(cardA, prevPage);
fillCard(cardB, cur);
anim = {
t: now,
dir: dir,
slide: Math.min(SLIDE_MAX, BASE_SLIDE + dist * SLIDE_PER_PAGE),
blur: Math.min(BLUR_MAX, dist * BLUR_PER_PAGE),
ms: Math.min(MS_MAX, BASE_MS + dist * MS_PER_PAGE)
};
}
04멀리 뛸 때 번호가 차례로 번쩍이는 궤적
1쪽에서 40쪽처럼 멀리 뛰면 setLabels 가 출발쪽과 도착쪽 사이를 같은 간격으로 나눠 아홉 개 칩에 번호를 붙이고, 칩마다 |쪽 - 출발| * STAGGER_STEP 밀리초 늦게 켜진다. 먼 번호일수록 늦게 켜지므로 눈이 출발에서 도착까지 길을 따라간다. 04 항목은 누적면적 14.0%·강도 평균 160.1·움직인 프레임 22/23 이다.

<main class="stage">
<div class="pg is-demo" id="pgRoot">
<nav class="pg__nav" id="pgNav" aria-label="페이지 이동"></nav>
<div class="pg__track" aria-hidden="true"><i class="pg__dot" id="pgDot"></i></div>
<div class="pg__read">
<b id="pgFrom">1</b><span class="pg__arrow">→</span><b id="pgTo">1</b>
<span class="pg__k">간격</span><b id="pgSpan">0</b>
</div>
<p class="pg__sr" id="pgSr" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 칩마다 (출발과의 쪽수 차 x STAGGER_STEP) ms 늦게 번쩍인다 — 멀리 뛸수록 궤적이 길다.
$chip-w: 28px;
$chip-gap: 4px;
@include stage($stage-ink);
.pg { display: grid; gap: $sp-3; justify-items: center; color: $stage-paper; }
.pg__nav { display: flex; gap: $chip-gap; }
.pg__b {
box-sizing: border-box;
width: $chip-w;
height: 48px;
padding: 0;
border: 1px solid rgba($stage-paper, .28);
border-radius: $r-sm;
background: rgba($stage-paper, .08);
color: $stage-paper;
font-family: inherit;
font-size: 12px;
line-height: 16px;
font-weight: 600;
font-variant-numeric: tabular-nums;
cursor: pointer;
will-change: transform;
}
.pg__b.is-lit { background: $subject-lilac; border-color: $subject-lilac; color: $subject-ink; }
.pg__b.is-cur { background: $subject-mint; border-color: $subject-mint; color: $subject-ink; }
.pg__b:focus-visible { outline: 2px solid $subject-sky; outline-offset: 2px; }
.pg__track { position: relative; height: 4px; border-radius: $r-xs; background: rgba($stage-paper, .14); width: 8 * ($chip-w + $chip-gap) + $chip-w; }
.pg__dot { position: absolute; left: 0; top: -4px; width: $chip-w; height: 12px; border-radius: $r-pill; background: $subject-pink; will-change: transform; }
.pg__read { display: flex; align-items: baseline; gap: $sp-2; font-size: 12px; line-height: 16px; }
.pg__read b { color: $subject-sky; font-variant-numeric: tabular-nums; }
.pg__arrow, .pg__k { color: rgba($stage-paper, .82); }
.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: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.pg__b { transition: 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;
color: #ffffff;
}
.pg__nav {
display: flex;
gap: 4px;
}
.pg__b {
box-sizing: border-box;
width: 28px;
height: 48px;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.28);
border-radius: 8px;
background: rgba(255, 255, 255, 0.08);
color: #ffffff;
font-family: inherit;
font-size: 12px;
line-height: 16px;
font-weight: 600;
font-variant-numeric: tabular-nums;
cursor: pointer;
will-change: transform;
}
.pg__b.is-lit {
background: #b9a0ff;
border-color: #b9a0ff;
color: #17141a;
}
.pg__b.is-cur {
background: #4cd4a6;
border-color: #4cd4a6;
color: #17141a;
}
.pg__b:focus-visible {
outline: 2px solid #6ec8ff;
outline-offset: 2px;
}
.pg__track {
position: relative;
height: 4px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.14);
width: 284px;
}
.pg__dot {
position: absolute;
left: 0;
top: -4px;
width: 28px;
height: 12px;
border-radius: 999px;
background: #ff9ebb;
will-change: transform;
}
.pg__read {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 12px;
line-height: 16px;
}
.pg__read b {
color: #6ec8ff;
font-variant-numeric: tabular-nums;
}
.pg__arrow, .pg__k {
color: rgba(255, 255, 255, 0.82);
}
.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: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.pg__b {
transition: none;
}
}(function () {
// 기법: 출발 쪽과 도착 쪽 사이를 칩 아홉 개로 나눠 보여 주고, 칩마다 (출발과의 쪽수 차 x STAGGER_STEP) 만큼 늦게 번쩍인다.
// 멀리 뛸수록 전체 궤적이 길어지고, 번쩍임은 attack 후 지수로 꺼지는 곡선이다.
var CHIP_COUNT = 9;
var TOTAL_PAGES = 40;
var STAGGER_STEP = 11;
var ATTACK_MS = 50;
var DECAY_MS = 220;
var SETTLE_MS = 160;
var START_MS = 100;
var DEMO_PAGES = [1, 40, 12, 33, 2, 25, 40];
var root = document.getElementById('pgRoot');
var nav = document.getElementById('pgNav');
var dot = document.getElementById('pgDot');
var fromEl = document.getElementById('pgFrom');
var toEl = document.getElementById('pgTo');
var spanEl = document.getElementById('pgSpan');
var sr = document.getElementById('pgSr');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var CHIP_W = 28;
var CHIP_GAP = 4;
var chips = [];
for (var i = 0; i < CHIP_COUNT; i += 1) {
var c = document.createElement('button');
c.type = 'button';
c.className = 'pg__b';
nav.appendChild(c);
chips.push(c);
}
var cur = 1;
var from = 1;
var to = 1;
var labels = [];
var delays = [];
var tStart = 0;
var totalMs = 0;
var running = false;
var t0 = null;
var demoIdx = 0;
var nextAt = START_MS;
function setLabels(a, b) {
labels = [];
delays = [];
for (var s = 0; s < CHIP_COUNT; s += 1) {
var page = Math.round(a + ((b - a) * s) / (CHIP_COUNT - 1));
labels.push(page);
delays.push(Math.abs(page - a) * STAGGER_STEP);
chips[s].textContent = String(page);
}
totalMs = Math.abs(b - a) * STAGGER_STEP + ATTACK_MS + DECAY_MS;
}
function paintRest() {
for (var s = 0; s < CHIP_COUNT; s += 1) {
var on = labels[s] === cur && (s === CHIP_COUNT - 1 || s === 0);
chips[s].className = 'pg__b' + (on ? ' is-cur' : '');
chips[s].style.transform = '';
if (on) chips[s].setAttribute('aria-current', 'page'); else chips[s].removeAttribute('aria-current');
}
fromEl.textContent = String(from);
toEl.textContent = String(to);
spanEl.textContent = String(Math.abs(to - from));
}
function jumpTo(page, byUser, now) {
if (byUser) root.classList.remove('is-demo');
if (page === cur || page < 1 || page > TOTAL_PAGES) return;
from = cur;
to = page;
setLabels(from, to);
cur = page;
fromEl.textContent = String(from);
toEl.textContent = String(to);
spanEl.textContent = String(Math.abs(to - from));
if (byUser) sr.textContent = '현재 ' + cur + ' / ' + TOTAL_PAGES;
if (reduce) { setLabels(cur, cur); paintRest(); return; }
tStart = now;
running = true;
}
function pulseAt(age) {
if (age < 0) return 0;
if (age < ATTACK_MS) return age / ATTACK_MS;
return Math.exp(-(age - ATTACK_MS) / (DECAY_MS * 0.45));
}
function tick(ts) {
if (t0 === null) t0 = ts;
var now = ts - t0;
if (root.classList.contains('is-demo') && now >= nextAt && !running) {
demoIdx = (demoIdx + 1) % DEMO_PAGES.length;
jumpTo(DEMO_PAGES[demoIdx], false, now);
nextAt = now + 1e9;
}
if (running) {
var elapsed = now - tStart;
for (var s = 0; s < CHIP_COUNT; s += 1) {
var p = pulseAt(elapsed - delays[s]);
var on = elapsed - delays[s] >= 0;
chips[s].className = 'pg__b' + (p > 0.02 ? ' is-lit' : '') + (on && s === CHIP_COUNT - 1 ? ' is-cur' : '');
chips[s].style.transform = p > 0.02 ? 'translateY(' + (-8 * p).toFixed(2) + 'px) scale(' + (1 + 0.22 * p).toFixed(3) + ')' : '';
chips[s].style.opacity = p > 0.02 ? '1' : (on ? '0.9' : '0.72');
}
var head = Math.min(1, elapsed / Math.max(1, totalMs - DECAY_MS));
dot.style.transform = 'translateX(' + (head * (CHIP_COUNT - 1) * (CHIP_W + CHIP_GAP)).toFixed(1) + 'px)';
if (elapsed >= totalMs + SETTLE_MS) {
running = false;
setLabels(cur, cur);
for (var q = 0; q < CHIP_COUNT; q += 1) chips[q].style.opacity = '';
dot.style.transform = 'translateX(0)';
paintRest();
if (root.classList.contains('is-demo')) nextAt = now + SETTLE_MS;
}
}
requestAnimationFrame(tick);
}
nav.addEventListener('click', function (e) {
var idx = chips.indexOf(e.target);
if (idx >= 0 && !running) jumpTo(labels[idx], true, t0 === null ? 0 : performance.now() - t0);
});
nav.addEventListener('keydown', function (e) {
var step = 0;
if (e.key === 'ArrowRight') step = 1;
else if (e.key === 'ArrowLeft') step = -1;
else if (e.key === 'PageDown') step = 10;
else if (e.key === 'PageUp') step = -10;
if (!step || running) return;
e.preventDefault();
jumpTo(Math.max(1, Math.min(TOTAL_PAGES, cur + step)), true, t0 === null ? 0 : performance.now() - t0);
});
setLabels(1, 1);
paintRest();
if (reduce) {
root.classList.remove('is-demo');
from = 1;
jumpTo(40, true, 0);
} else {
requestAnimationFrame(tick);
}
})();// JumpTrailPager — vanilla 판(04-pager-long-jump-stagger-trail-ripple) 의 React 포트.
// 출발·도착 사이를 칩 아홉 개로 나누고, 칩마다 (출발과의 쪽수 차 x STAGGER_STEP) ms 늦게 번쩍인다.
import { useEffect, useRef } from "react";
import styles from "./JumpTrailPager.module.scss";
type Box<T> = { current: T };
const CHIP_COUNT = 9;
const TOTAL_PAGES = 40;
const STAGGER_STEP = 11;
const ATTACK_MS = 50;
const DECAY_MS = 220;
const SETTLE_MS = 160;
const START_MS = 100;
const CHIP_W = 28;
const CHIP_GAP = 4;
const DEMO_PAGES = [1, 40, 12, 33, 2, 25, 40];
export default function JumpTrailPager() {
const rootRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const navRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
const dotRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
const fromRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
const toRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
const spanRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
const srRef = useRef<HTMLParagraphElement | null>(null) as Box<HTMLParagraphElement | null>;
const chipRefs = useRef<Array<HTMLButtonElement | null>>([]) as Box<Array<HTMLButtonElement | null>>;
useEffect(() => {
const nav = navRef.current;
const dot = dotRef.current;
const fromEl = fromRef.current;
const toEl = toRef.current;
const spanEl = spanRef.current;
const sr = srRef.current;
if (!nav || !dot || !fromEl || !toEl || !spanEl || !sr) return;
const chips = chipRefs.current;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo = true;
let cur = 1;
let from = 1;
let to = 1;
let labels: number[] = [];
let delays: number[] = [];
let tStart = 0;
let totalMs = 0;
let running = false;
let t0: number | null = null;
let demoIdx = 0;
let nextAt = START_MS;
let raf = 0;
const perfNow = (): number => (t0 === null ? 0 : performance.now() - t0);
function setLabels(a: number, b: number): void {
labels = [];
delays = [];
for (let s = 0; s < CHIP_COUNT; s += 1) {
const page = Math.round(a + ((b - a) * s) / (CHIP_COUNT - 1));
labels.push(page);
delays.push(Math.abs(page - a) * STAGGER_STEP);
const chip = chips[s];
if (chip) chip.textContent = String(page);
}
totalMs = Math.abs(b - a) * STAGGER_STEP + ATTACK_MS + DECAY_MS;
}
function paintRest(): void {
for (let s = 0; s < CHIP_COUNT; s += 1) {
const chip = chips[s];
if (!chip) continue;
const on = labels[s] === cur && (s === CHIP_COUNT - 1 || s === 0);
chip.className = [styles.pg__b, on ? styles["is-cur"] : ""].join(" ");
chip.style.transform = "";
if (on) chip.setAttribute("aria-current", "page"); else chip.removeAttribute("aria-current");
}
fromEl!.textContent = String(from);
toEl!.textContent = String(to);
spanEl!.textContent = String(Math.abs(to - from));
}
function jumpTo(page: number, byUser: boolean, now: number): void {
if (byUser) demo = false;
if (page === cur || page < 1 || page > TOTAL_PAGES) return;
from = cur;
to = page;
setLabels(from, to);
cur = page;
fromEl!.textContent = String(from);
toEl!.textContent = String(to);
spanEl!.textContent = String(Math.abs(to - from));
if (byUser) sr!.textContent = "현재 " + cur + " / " + TOTAL_PAGES;
if (reduce) { setLabels(cur, cur); paintRest(); return; }
tStart = now;
running = true;
}
function pulseAt(age: number): number {
if (age < 0) return 0;
if (age < ATTACK_MS) return age / ATTACK_MS;
return Math.exp(-(age - ATTACK_MS) / (DECAY_MS * 0.45));
}
function tick(ts: number): void {
if (t0 === null) t0 = ts;
const now = ts - t0;
if (demo && now >= nextAt && !running) {
demoIdx = (demoIdx + 1) % DEMO_PAGES.length;
jumpTo(DEMO_PAGES[demoIdx], false, now);
nextAt = now + 1e9;
}
if (running) {
const elapsed = now - tStart;
for (let s = 0; s < CHIP_COUNT; s += 1) {
const chip = chips[s];
if (!chip) continue;
const p = pulseAt(elapsed - delays[s]);
const on = elapsed - delays[s] >= 0;
chip.className = [styles.pg__b, p > 0.02 ? styles["is-lit"] : "", on && s === CHIP_COUNT - 1 ? styles["is-cur"] : ""].join(" ");
chip.style.transform = p > 0.02 ? "translateY(" + (-8 * p).toFixed(2) + "px) scale(" + (1 + 0.22 * p).toFixed(3) + ")" : "";
chip.style.opacity = p > 0.02 ? "1" : on ? "0.9" : "0.72";
}
const head = Math.min(1, elapsed / Math.max(1, totalMs - DECAY_MS));
dot!.style.transform = "translateX(" + (head * (CHIP_COUNT - 1) * (CHIP_W + CHIP_GAP)).toFixed(1) + "px)";
if (elapsed >= totalMs + SETTLE_MS) {
running = false;
setLabels(cur, cur);
for (let q = 0; q < CHIP_COUNT; q += 1) {
const chip = chips[q];
if (chip) chip.style.opacity = "";
}
dot!.style.transform = "translateX(0)";
paintRest();
if (demo) nextAt = now + SETTLE_MS;
}
}
raf = requestAnimationFrame(tick);
}
const onClick = (e: MouseEvent): void => {
const idx = chips.indexOf(e.target as HTMLButtonElement);
if (idx >= 0 && !running) jumpTo(labels[idx], true, perfNow());
};
const onKey = (e: KeyboardEvent): void => {
let step = 0;
if (e.key === "ArrowRight") step = 1;
else if (e.key === "ArrowLeft") step = -1;
else if (e.key === "PageDown") step = 10;
else if (e.key === "PageUp") step = -10;
if (!step || running) return;
e.preventDefault();
jumpTo(Math.max(1, Math.min(TOTAL_PAGES, cur + step)), true, perfNow());
};
nav.addEventListener("click", onClick);
nav.addEventListener("keydown", onKey);
setLabels(1, 1);
paintRest();
if (reduce) {
demo = false;
from = 1;
jumpTo(40, true, 0);
} else {
raf = requestAnimationFrame(tick);
}
return () => {
cancelAnimationFrame(raf);
nav.removeEventListener("click", onClick);
nav.removeEventListener("keydown", onKey);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.pg} ref={rootRef}>
<nav className={styles.pg__nav} ref={navRef} aria-label="페이지 이동">
{Array.from({ length: CHIP_COUNT }, (_, i) => (
<button type="button" className={styles.pg__b} key={i} ref={(el: HTMLButtonElement | null) => { chipRefs.current[i] = el; }}></button>
))}
</nav>
<div className={styles.pg__track} aria-hidden="true"><i className={styles.pg__dot} ref={dotRef}></i></div>
<div className={styles.pg__read}>
<b ref={fromRef}>1</b><span className={styles.pg__arrow}>→</span><b ref={toRef}>1</b>
<span className={styles.pg__k}>간격</span><b ref={spanRef}>0</b>
</div>
<p className={styles.pg__sr} ref={srRef} aria-live="polite"></p>
</div>
</main>
);
}04 · 멀리 뛸 때 번호가 차례로 번쩍이는 궤적 — 측정 누적면적 13.989% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function setLabels(a, b) {
labels = [];
delays = [];
for (var s = 0; s < CHIP_COUNT; s += 1) {
var page = Math.round(a + ((b - a) * s) / (CHIP_COUNT - 1));
labels.push(page);
delays.push(Math.abs(page - a) * STAGGER_STEP);
chips[s].textContent = String(page);
}
totalMs = Math.abs(b - a) * STAGGER_STEP + ATTACK_MS + DECAY_MS;
}
05끝에서 고무줄처럼 튕기는 이전·다음
끝 쪽에서 이전이나 다음을 누르면 쪽은 그대로 두고 PUSH_IMPULSE 만큼 버튼에 충격을 주며, 이미 밀려 있을수록 slack 이 줄어 충격도 작아진다. 밀림은 스프링으로 되돌아오고 가운데 라벨과 카드가 그 일부를 따라 움직인다. 05 항목은 누적면적 8.2%·강도 평균 75.7·움직인 프레임 22/23 이다.

<main class="stage">
<div class="pg is-demo" id="pgRoot">
<div class="pg__card is-mint" id="pgCard" aria-hidden="true"><b class="pg__no" id="pgNo">1</b><i class="pg__row"></i><i class="pg__row pg__row--s"></i></div>
<nav class="pg__nav" aria-label="페이지 이동">
<button type="button" class="pg__b" id="pgPrev" aria-label="이전 쪽">‹</button>
<span class="pg__count" id="pgCount"><b id="pgCur">1</b><span class="pg__sep">/</span><span id="pgTotal">6</span></span>
<button type="button" class="pg__b" id="pgNext" aria-label="다음 쪽">›</button>
</nav>
<div class="pg__tension" aria-hidden="true"><i class="pg__tl" id="pgTl"></i><i class="pg__tr" id="pgTr"></i></div>
<div class="pg__read"><span class="pg__k">장력</span><b id="pgForce">0</b><span class="pg__u">%</span></div>
<p class="pg__sr" id="pgSr" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 끝 쪽 이전·다음은 disabled 가 아니라 aria-disabled — 눌림이 스프링 충격량으로 들어가 고무줄처럼 밀렸다 돌아온다.
@include stage($stage-ink);
.pg { display: grid; gap: $sp-3; width: min(100%, 264px); justify-items: center; color: $stage-paper; }
.pg__card { display: grid; align-content: center; gap: $sp-2; height: 64px; box-sizing: border-box; padding: $sp-2 $sp-4; border-radius: $r-card; will-change: transform; }
.pg__card.is-mint { background: rgba($subject-mint, .4); }
.pg__card.is-lilac { background: rgba($subject-lilac, .44); }
.pg__card.is-pink { background: rgba($subject-pink, .42); }
.pg__card.is-sky { background: rgba($subject-sky, .42); }
.pg__no { font-size: 20px; line-height: 24px; font-weight: 700; color: $stage-paper; font-variant-numeric: tabular-nums; }
.pg__row { display: block; height: 6px; width: 70%; border-radius: $r-xs; background: rgba($stage-paper, .5); }
.pg__row--s { width: 44%; }
.pg__nav { display: flex; align-items: center; gap: $sp-4; }
.pg__b {
box-sizing: border-box;
width: 48px;
height: 48px;
padding: 0;
border: 1px solid rgba($stage-paper, .28);
border-radius: $r-control;
background: $subject-lilac;
color: $subject-ink;
font-family: inherit;
font-size: 22px;
line-height: 24px;
font-weight: 700;
cursor: pointer;
will-change: transform;
}
.pg__b.is-edge { opacity: .55; cursor: default; }
.pg__b:focus-visible { outline: 2px solid $subject-sky; outline-offset: 2px; }
.pg__count { display: flex; align-items: baseline; justify-content: center; gap: $sp-1; min-width: 64px; font-size: 14px; line-height: 20px; color: rgba($stage-paper, .82); font-variant-numeric: tabular-nums; will-change: transform; }
.pg__count b { font-size: 24px; line-height: 28px; color: $subject-mint; }
.pg__tension { position: relative; width: 160px; height: 4px; border-radius: $r-xs; background: rgba($stage-paper, .14); overflow: hidden; }
.pg__tl, .pg__tr { position: absolute; top: 0; width: 50%; height: 4px; background: $subject-pink; }
.pg__tl { right: 50%; transform-origin: 100% 50%; transform: scaleX(0); }
.pg__tr { left: 50%; transform-origin: 0 50%; transform: scaleX(0); }
.pg__read { display: flex; align-items: baseline; gap: $sp-1; font-size: 12px; line-height: 16px; }
.pg__k, .pg__u { color: rgba($stage-paper, .82); }
.pg__read b { color: $subject-pink; font-variant-numeric: tabular-nums; min-width: 24px; text-align: right; margin-left: $sp-1; }
.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__nav { gap: $sp-3; }
.pg__b { width: 44px; height: 44px; }
}
@media (prefers-reduced-motion: reduce) {
.pg__b, .pg__count, .pg__card { 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;
width: min(100%, 264px);
justify-items: center;
color: #ffffff;
}
.pg__card {
display: grid;
align-content: center;
gap: 8px;
height: 64px;
box-sizing: border-box;
padding: 8px 16px;
border-radius: 16px;
will-change: transform;
}
.pg__card.is-mint {
background: rgba(76, 212, 166, 0.4);
}
.pg__card.is-lilac {
background: rgba(185, 160, 255, 0.44);
}
.pg__card.is-pink {
background: rgba(255, 158, 187, 0.42);
}
.pg__card.is-sky {
background: rgba(110, 200, 255, 0.42);
}
.pg__no {
font-size: 20px;
line-height: 24px;
font-weight: 700;
color: #ffffff;
font-variant-numeric: tabular-nums;
}
.pg__row {
display: block;
height: 6px;
width: 70%;
border-radius: 4px;
background: rgba(255, 255, 255, 0.5);
}
.pg__row--s {
width: 44%;
}
.pg__nav {
display: flex;
align-items: center;
gap: 16px;
}
.pg__b {
box-sizing: border-box;
width: 48px;
height: 48px;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.28);
border-radius: 12px;
background: #b9a0ff;
color: #17141a;
font-family: inherit;
font-size: 22px;
line-height: 24px;
font-weight: 700;
cursor: pointer;
will-change: transform;
}
.pg__b.is-edge {
opacity: 0.55;
cursor: default;
}
.pg__b:focus-visible {
outline: 2px solid #6ec8ff;
outline-offset: 2px;
}
.pg__count {
display: flex;
align-items: baseline;
justify-content: center;
gap: 4px;
min-width: 64px;
font-size: 14px;
line-height: 20px;
color: rgba(255, 255, 255, 0.82);
font-variant-numeric: tabular-nums;
will-change: transform;
}
.pg__count b {
font-size: 24px;
line-height: 28px;
color: #4cd4a6;
}
.pg__tension {
position: relative;
width: 160px;
height: 4px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.14);
overflow: hidden;
}
.pg__tl, .pg__tr {
position: absolute;
top: 0;
width: 50%;
height: 4px;
background: #ff9ebb;
}
.pg__tl {
right: 50%;
transform-origin: 100% 50%;
transform: scaleX(0);
}
.pg__tr {
left: 50%;
transform-origin: 0 50%;
transform: scaleX(0);
}
.pg__read {
display: flex;
align-items: baseline;
gap: 4px;
font-size: 12px;
line-height: 16px;
}
.pg__k, .pg__u {
color: rgba(255, 255, 255, 0.82);
}
.pg__read b {
color: #ff9ebb;
font-variant-numeric: tabular-nums;
min-width: 24px;
text-align: right;
margin-left: 4px;
}
.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__nav {
gap: 12px;
}
.pg__b {
width: 44px;
height: 44px;
}
}
@media (prefers-reduced-motion: reduce) {
.pg__b, .pg__count, .pg__card {
transform: none;
}
}(function () {
// 기법: 끝 쪽에서 누르면 페이지는 안 바뀌고, 눌림이 스프링 속도(충격량)로 들어간다.
// 이미 많이 밀려 있을수록 충격량이 줄어(1 - 밀림/한계) 고무줄처럼 점점 뻑뻑해진다.
var TOTAL_PAGES = 6;
var PUSH_IMPULSE = 560;
var RUBBER_LIMIT = 26;
var EDGE_STIFFNESS = 260;
var EDGE_DAMPING = 15;
var LABEL_DRAG = 0.3;
var SUBSTEP = 1 / 240;
var CYCLE_MS = 2300;
// [시각 ms, 방향] 방향 -1 이전 · +1 다음
var DEMO_PRESSES = [[100, -1], [290, -1], [520, 1], [660, 1], [800, 1], [940, 1], [1080, 1], [1300, 1], [1460, 1], [1700, -1]];
var root = document.getElementById('pgRoot');
var prev = document.getElementById('pgPrev');
var next = document.getElementById('pgNext');
var count = document.getElementById('pgCount');
var curEl = document.getElementById('pgCur');
var forceEl = document.getElementById('pgForce');
var tl = document.getElementById('pgTl');
var tr = document.getElementById('pgTr');
var sr = document.getElementById('pgSr');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
document.getElementById('pgTotal').textContent = String(TOTAL_PAGES);
var TONES = ['is-mint', 'is-lilac', 'is-pink', 'is-sky'];
var CARD_DRAG = 0.7;
var card = document.getElementById('pgCard');
var noEl = document.getElementById('pgNo');
var cur = 1;
var x = [0, 0];
var v = [0, 0];
var lastTs = null;
var t0 = null;
var cycleBase = 0;
var evt = 0;
function syncEdges() {
prev.setAttribute('aria-disabled', cur === 1 ? 'true' : 'false');
next.setAttribute('aria-disabled', cur === TOTAL_PAGES ? 'true' : 'false');
prev.className = 'pg__b' + (cur === 1 ? ' is-edge' : '');
next.className = 'pg__b' + (cur === TOTAL_PAGES ? ' is-edge' : '');
curEl.textContent = String(cur);
noEl.textContent = String(cur);
card.className = 'pg__card ' + TONES[cur % TONES.length];
}
function press(dir, byUser) {
if (byUser) root.classList.remove('is-demo');
var side = dir < 0 ? 0 : 1;
var target = cur + dir;
if (target < 1 || target > TOTAL_PAGES) {
if (!reduce) {
var slack = Math.max(0, 1 - Math.abs(x[side]) / RUBBER_LIMIT);
v[side] += dir * PUSH_IMPULSE * slack;
}
if (byUser) sr.textContent = '현재 ' + cur + ' / ' + TOTAL_PAGES;
return;
}
cur = target;
syncEdges();
if (byUser) sr.textContent = '현재 ' + cur + ' / ' + TOTAL_PAGES;
}
prev.addEventListener('click', function () { press(-1, true); });
next.addEventListener('click', function () { press(1, true); });
root.addEventListener('keydown', function (e) {
if (e.key === 'ArrowLeft') { e.preventDefault(); press(-1, true); }
else if (e.key === 'ArrowRight') { e.preventDefault(); press(1, true); }
});
function step(side, dt) {
var left = dt;
while (left > 0) {
var h = Math.min(SUBSTEP, left);
v[side] += (-EDGE_STIFFNESS * x[side] - EDGE_DAMPING * v[side]) * h;
x[side] += v[side] * h;
left -= h;
}
}
function tick(ts) {
if (t0 === null) t0 = ts;
var now = ts - t0;
var dt = lastTs === null ? 0.016 : Math.min(0.05, (ts - lastTs) / 1000);
lastTs = ts;
if (root.classList.contains('is-demo')) {
while (evt < DEMO_PRESSES.length && now - cycleBase >= DEMO_PRESSES[evt][0]) {
press(DEMO_PRESSES[evt][1], false);
evt += 1;
}
if (now - cycleBase >= CYCLE_MS) { cycleBase = now; evt = 0; cur = 1; syncEdges(); }
}
step(0, dt);
step(1, dt);
prev.style.transform = 'translateX(' + x[0].toFixed(2) + 'px)';
next.style.transform = 'translateX(' + x[1].toFixed(2) + 'px)';
card.style.transform = 'translateX(' + ((x[0] + x[1]) * CARD_DRAG).toFixed(2) + 'px)';
count.style.transform = 'translateX(' + ((x[0] + x[1]) * LABEL_DRAG).toFixed(2) + 'px)';
tl.style.transform = 'scaleX(' + Math.min(1, Math.abs(x[0]) / RUBBER_LIMIT).toFixed(3) + ')';
tr.style.transform = 'scaleX(' + Math.min(1, Math.abs(x[1]) / RUBBER_LIMIT).toFixed(3) + ')';
forceEl.textContent = String(Math.round(Math.min(1, Math.max(Math.abs(x[0]), Math.abs(x[1])) / RUBBER_LIMIT) * 100));
requestAnimationFrame(tick);
}
syncEdges();
if (reduce) {
root.classList.remove('is-demo');
} else {
requestAnimationFrame(tick);
}
})();// RubberEdgePager — vanilla 판(05-pager-edge-rubber-band-nudge-disable) 의 React 포트.
// 끝 쪽에서 누르면 페이지는 안 바뀌고 눌림이 스프링 충격량으로 들어간다. 이미 밀려 있을수록 충격이 줄어든다.
import { useEffect, useRef } from "react";
import styles from "./RubberEdgePager.module.scss";
type Box<T> = { current: T };
const TOTAL_PAGES = 6;
const PUSH_IMPULSE = 560;
const RUBBER_LIMIT = 26;
const EDGE_STIFFNESS = 260;
const EDGE_DAMPING = 15;
const LABEL_DRAG = 0.3;
const CARD_DRAG = 0.7;
const SUBSTEP = 1 / 240;
const CYCLE_MS = 2300;
const TONES = ["is-mint", "is-lilac", "is-pink", "is-sky"];
// [시각 ms, 방향] 방향 -1 이전 · +1 다음
const DEMO_PRESSES: Array<[number, number]> = [[100, -1], [290, -1], [520, 1], [660, 1], [800, 1], [940, 1], [1080, 1], [1300, 1], [1460, 1], [1700, -1]];
export default function RubberEdgePager() {
const rootRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const cardRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const noRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
const prevRef = useRef<HTMLButtonElement | null>(null) as Box<HTMLButtonElement | null>;
const nextRef = useRef<HTMLButtonElement | null>(null) as Box<HTMLButtonElement | null>;
const countRef = useRef<HTMLSpanElement | null>(null) as Box<HTMLSpanElement | null>;
const curRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
const forceRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
const tlRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
const trRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
const srRef = useRef<HTMLParagraphElement | null>(null) as Box<HTMLParagraphElement | null>;
useEffect(() => {
const root = rootRef.current;
const card = cardRef.current;
const noEl = noRef.current;
const prev = prevRef.current;
const next = nextRef.current;
const count = countRef.current;
const curEl = curRef.current;
const forceEl = forceRef.current;
const tl = tlRef.current;
const tr = trRef.current;
const sr = srRef.current;
if (!root || !card || !noEl || !prev || !next || !count || !curEl || !forceEl || !tl || !tr || !sr) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo = true;
let cur = 1;
const x = [0, 0];
const v = [0, 0];
let lastTs: number | null = null;
let t0: number | null = null;
let cycleBase = 0;
let evt = 0;
let raf = 0;
function syncEdges(): void {
prev!.setAttribute("aria-disabled", cur === 1 ? "true" : "false");
next!.setAttribute("aria-disabled", cur === TOTAL_PAGES ? "true" : "false");
prev!.className = [styles.pg__b, cur === 1 ? styles["is-edge"] : ""].join(" ");
next!.className = [styles.pg__b, cur === TOTAL_PAGES ? styles["is-edge"] : ""].join(" ");
curEl!.textContent = String(cur);
noEl!.textContent = String(cur);
card!.className = [styles.pg__card, styles[TONES[cur % TONES.length]]].join(" ");
}
function press(dir: number, byUser: boolean): void {
if (byUser) demo = false;
const side = dir < 0 ? 0 : 1;
const target = cur + dir;
if (target < 1 || target > TOTAL_PAGES) {
if (!reduce) {
const slack = Math.max(0, 1 - Math.abs(x[side]) / RUBBER_LIMIT);
v[side] += dir * PUSH_IMPULSE * slack;
}
if (byUser) sr!.textContent = "현재 " + cur + " / " + TOTAL_PAGES;
return;
}
cur = target;
syncEdges();
if (byUser) sr!.textContent = "현재 " + cur + " / " + TOTAL_PAGES;
}
const onPrev = (): void => press(-1, true);
const onNext = (): void => press(1, true);
const onKey = (e: KeyboardEvent): void => {
if (e.key === "ArrowLeft") { e.preventDefault(); press(-1, true); }
else if (e.key === "ArrowRight") { e.preventDefault(); press(1, true); }
};
prev.addEventListener("click", onPrev);
next.addEventListener("click", onNext);
root.addEventListener("keydown", onKey);
function step(side: number, dt: number): void {
let left = dt;
while (left > 0) {
const h = Math.min(SUBSTEP, left);
v[side] += (-EDGE_STIFFNESS * x[side] - EDGE_DAMPING * v[side]) * h;
x[side] += v[side] * h;
left -= h;
}
}
function tick(ts: number): void {
if (t0 === null) t0 = ts;
const now = ts - t0;
const dt = lastTs === null ? 0.016 : Math.min(0.05, (ts - lastTs) / 1000);
lastTs = ts;
if (demo) {
while (evt < DEMO_PRESSES.length && now - cycleBase >= DEMO_PRESSES[evt][0]) {
press(DEMO_PRESSES[evt][1], false);
evt += 1;
}
if (now - cycleBase >= CYCLE_MS) { cycleBase = now; evt = 0; cur = 1; syncEdges(); }
}
step(0, dt);
step(1, dt);
prev!.style.transform = "translateX(" + x[0].toFixed(2) + "px)";
next!.style.transform = "translateX(" + x[1].toFixed(2) + "px)";
card!.style.transform = "translateX(" + ((x[0] + x[1]) * CARD_DRAG).toFixed(2) + "px)";
count!.style.transform = "translateX(" + ((x[0] + x[1]) * LABEL_DRAG).toFixed(2) + "px)";
tl!.style.transform = "scaleX(" + Math.min(1, Math.abs(x[0]) / RUBBER_LIMIT).toFixed(3) + ")";
tr!.style.transform = "scaleX(" + Math.min(1, Math.abs(x[1]) / RUBBER_LIMIT).toFixed(3) + ")";
forceEl!.textContent = String(Math.round(Math.min(1, Math.max(Math.abs(x[0]), Math.abs(x[1])) / RUBBER_LIMIT) * 100));
raf = requestAnimationFrame(tick);
}
syncEdges();
if (reduce) demo = false; else raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
prev.removeEventListener("click", onPrev);
next.removeEventListener("click", onNext);
root.removeEventListener("keydown", onKey);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.pg} ref={rootRef}>
<div className={[styles.pg__card, styles["is-mint"]].join(" ")} ref={cardRef} aria-hidden="true">
<b className={styles.pg__no} ref={noRef}>1</b>
<i className={styles.pg__row}></i>
<i className={[styles.pg__row, styles["pg__row--s"]].join(" ")}></i>
</div>
<nav className={styles.pg__nav} aria-label="페이지 이동">
<button type="button" className={styles.pg__b} ref={prevRef} aria-label="이전 쪽">‹</button>
<span className={styles.pg__count} ref={countRef}><b ref={curRef}>1</b><span className={styles.pg__sep}>/</span><span>{TOTAL_PAGES}</span></span>
<button type="button" className={styles.pg__b} ref={nextRef} aria-label="다음 쪽">›</button>
</nav>
<div className={styles.pg__tension} aria-hidden="true"><i className={styles.pg__tl} ref={tlRef}></i><i className={styles.pg__tr} ref={trRef}></i></div>
<div className={styles.pg__read}><span className={styles.pg__k}>장력</span><b ref={forceRef}>0</b><span className={styles.pg__u}>%</span></div>
<p className={styles.pg__sr} ref={srRef} aria-live="polite"></p>
</div>
</main>
);
}05 · 끝에서 고무줄처럼 튕기는 이전·다음 — 측정 누적면적 8.199% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function press(dir, byUser) {
if (byUser) root.classList.remove('is-demo');
var side = dir < 0 ? 0 : 1;
var target = cur + dir;
if (target < 1 || target > TOTAL_PAGES) {
if (!reduce) {
var slack = Math.max(0, 1 - Math.abs(x[side]) / RUBBER_LIMIT);
v[side] += dir * PUSH_IMPULSE * slack;
}
if (byUser) sr.textContent = '현재 ' + cur + ' / ' + TOTAL_PAGES;
return;
}
cur = target;
syncEdges();
if (byUser) sr.textContent = '현재 ' + cur + ' / ' + TOTAL_PAGES;
}
06점이 막대로 늘어나는 모바일 페이저
점마다 현재 쪽과의 거리 dist 로 목표 너비와 투명도를 정하고 layout 이 지수 보간으로 따라가서, 현재 쪽은 24px 막대가 되고 멀어질수록 10·8·6px 점이 된다. 4쪽 이상 떨어진 점은 투명해지므로 40쪽이어도 화면에는 막대 하나와 양옆 세 개씩 일곱 개만 남는다. 06 항목은 누적면적 14.1%·강도 평균 70.0·움직인 프레임 22/23 이다.

<main class="stage">
<div class="pg is-demo" id="pgRoot">
<div class="pg__card is-mint" id="pgCard" aria-hidden="true"><b class="pg__no" id="pgNo">1</b><i class="pg__row"></i><i class="pg__row pg__row--s"></i></div>
<nav class="pg__nav" aria-label="페이지 이동">
<button type="button" class="pg__b" id="pgPrev" aria-label="이전 쪽">‹</button>
<div class="pg__dots" aria-hidden="true"><div class="pg__strip" id="pgDots"></div></div>
<button type="button" class="pg__b" id="pgNext" aria-label="다음 쪽">›</button>
</nav>
<div class="pg__read"><b id="pgCur">1</b><span class="pg__sep">/</span><span id="pgTotal">40</span><span class="pg__k">점</span><b id="pgVis">7</b></div>
<p class="pg__sr" id="pgSr" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 점마다 현재 쪽과의 거리로 목표 폭·투명도를 계산하고 JS 가 지수 보간으로 따라간다.
// 현재 점만 막대(BAR_W)로 늘어나고, 거리 4 이상은 투명이라 보이는 점은 7개다.
@include stage($stage-ink);
.pg { display: grid; gap: $sp-4; justify-items: center; color: $stage-paper; }
.pg__card { display: grid; align-content: center; gap: $sp-2; width: 264px; height: 72px; box-sizing: border-box; padding: $sp-3 $sp-4; border-radius: $r-card; }
.pg__card.is-mint { background: rgba($subject-mint, .4); }
.pg__card.is-lilac { background: rgba($subject-lilac, .44); }
.pg__card.is-pink { background: rgba($subject-pink, .42); }
.pg__card.is-sky { background: rgba($subject-sky, .42); }
.pg__no { font-size: 20px; line-height: 24px; font-weight: 700; color: $stage-paper; font-variant-numeric: tabular-nums; }
.pg__row { display: block; height: 6px; width: 70%; border-radius: $r-xs; background: rgba($stage-paper, .5); }
.pg__row--s { width: 44%; }
.pg__nav { display: flex; align-items: center; gap: $sp-3; }
.pg__b {
box-sizing: border-box;
width: 40px;
height: 40px;
padding: 0;
border: 1px solid rgba($stage-paper, .28);
border-radius: $r-pill;
background: rgba($stage-paper, .08);
color: $stage-paper;
font-family: inherit;
font-size: 18px;
line-height: 20px;
font-weight: 700;
cursor: pointer;
}
.pg__b:focus-visible { outline: 2px solid $subject-sky; outline-offset: 2px; }
.pg__dots { position: relative; width: 120px; height: 12px; overflow: hidden; }
.pg__strip { position: absolute; left: 0; top: 0; width: 0; height: 12px; will-change: transform; }
.pg__dot { position: absolute; left: 0; top: 2px; height: 8px; width: 6px; border-radius: $r-pill; background: rgba($stage-paper, .86); }
.pg__dot.is-cur { background: $subject-pink; }
.pg__read { display: flex; align-items: baseline; gap: $sp-1; font-size: 12px; line-height: 16px; color: rgba($stage-paper, .82); font-variant-numeric: tabular-nums; }
.pg__read b { color: $subject-mint; }
.pg__k { margin-left: $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 { width: 36px; height: 36px; }
}
@media (prefers-reduced-motion: reduce) {
.pg__dot { transition: 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: 16px;
justify-items: center;
color: #ffffff;
}
.pg__card {
display: grid;
align-content: center;
gap: 8px;
width: 264px;
height: 72px;
box-sizing: border-box;
padding: 12px 16px;
border-radius: 16px;
}
.pg__card.is-mint {
background: rgba(76, 212, 166, 0.4);
}
.pg__card.is-lilac {
background: rgba(185, 160, 255, 0.44);
}
.pg__card.is-pink {
background: rgba(255, 158, 187, 0.42);
}
.pg__card.is-sky {
background: rgba(110, 200, 255, 0.42);
}
.pg__no {
font-size: 20px;
line-height: 24px;
font-weight: 700;
color: #ffffff;
font-variant-numeric: tabular-nums;
}
.pg__row {
display: block;
height: 6px;
width: 70%;
border-radius: 4px;
background: rgba(255, 255, 255, 0.5);
}
.pg__row--s {
width: 44%;
}
.pg__nav {
display: flex;
align-items: center;
gap: 12px;
}
.pg__b {
box-sizing: border-box;
width: 40px;
height: 40px;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.28);
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
color: #ffffff;
font-family: inherit;
font-size: 18px;
line-height: 20px;
font-weight: 700;
cursor: pointer;
}
.pg__b:focus-visible {
outline: 2px solid #6ec8ff;
outline-offset: 2px;
}
.pg__dots {
position: relative;
width: 120px;
height: 12px;
overflow: hidden;
}
.pg__strip {
position: absolute;
left: 0;
top: 0;
width: 0;
height: 12px;
will-change: transform;
}
.pg__dot {
position: absolute;
left: 0;
top: 2px;
height: 8px;
width: 6px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.86);
}
.pg__dot.is-cur {
background: #ff9ebb;
}
.pg__read {
display: flex;
align-items: baseline;
gap: 4px;
font-size: 12px;
line-height: 16px;
color: rgba(255, 255, 255, 0.82);
font-variant-numeric: tabular-nums;
}
.pg__read b {
color: #4cd4a6;
}
.pg__k {
margin-left: 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 {
width: 36px;
height: 36px;
}
}
@media (prefers-reduced-motion: reduce) {
.pg__dot {
transition: none;
}
}(function () {
// 기법: 모든 쪽에 점 하나씩을 두고, 현재 쪽과의 거리(d)로 목표 폭·투명도를 계산한다.
// d=0 은 막대, d=1·2·3 은 단계적으로 작아지고 d>=4 는 투명이라 어느 쪽에서도 보이는 점은 7개(= 2 x DOT_REACH + 1)다.
var TOTAL_PAGES = 40;
var DOT_REACH = 3;
var BAR_W = 24;
var DOT_W = [BAR_W, 10, 8, 6];
var DOT_ALPHA = [1, 0.9, 0.7, 0.5];
var DOT_H = 8;
var DOT_GAP = 6;
var VIEW_W = 120;
var MORPH_RATE = 14;
var SLIDE_RATE = 10;
var STEP_MS = 170;
var START_MS = 100;
var DEMO_PAGES = [1, 2, 3, 4, 9, 10, 11, 20, 21, 22, 23, 24];
var root = document.getElementById('pgRoot');
var dotsEl = document.getElementById('pgDots');
var curEl = document.getElementById('pgCur');
var sr = document.getElementById('pgSr');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
document.getElementById('pgTotal').textContent = String(TOTAL_PAGES);
document.getElementById('pgVis').textContent = String(DOT_REACH * 2 + 1);
var dots = [];
var w = [];
var a = [];
for (var i = 0; i < TOTAL_PAGES; i += 1) {
var d = document.createElement('i');
d.className = 'pg__dot';
dotsEl.appendChild(d);
dots.push(d);
w.push(DOT_W[3]);
a.push(0);
}
var TONES = ['is-mint', 'is-lilac', 'is-pink', 'is-sky'];
var card = document.getElementById('pgCard');
var noEl = document.getElementById('pgNo');
var cur = 1;
var offset = 0;
var t0 = null;
var lastTs = null;
var lastStep = -1;
function targetW(dist) { return dist < DOT_W.length ? DOT_W[dist] : DOT_W[DOT_W.length - 1]; }
function targetA(dist) { return dist < DOT_ALPHA.length ? DOT_ALPHA[dist] : 0; }
function layout(k) {
var x = 0;
var centerX = 0;
for (var s = 0; s < TOTAL_PAGES; s += 1) {
var dist = Math.abs(s + 1 - cur);
if (k >= 1) { w[s] = targetW(dist); a[s] = targetA(dist); } else {
w[s] += (targetW(dist) - w[s]) * k;
a[s] += (targetA(dist) - a[s]) * k;
}
dots[s].style.width = w[s].toFixed(2) + 'px';
dots[s].style.opacity = a[s].toFixed(3);
dots[s].className = 'pg__dot' + (s + 1 === cur ? ' is-cur' : '');
dots[s].style.transform = 'translateX(' + x.toFixed(2) + 'px)';
if (s + 1 === cur) centerX = x;
x += w[s] + DOT_GAP;
}
return centerX;
}
function setCur(n, byUser) {
if (byUser) root.classList.remove('is-demo');
var next = Math.max(1, Math.min(TOTAL_PAGES, n));
if (next === cur) return;
cur = next;
curEl.textContent = String(cur);
noEl.textContent = String(cur);
card.className = 'pg__card ' + TONES[cur % TONES.length];
if (byUser) sr.textContent = '현재 ' + cur + ' / ' + TOTAL_PAGES;
if (reduce) { var c = layout(1); offset = c; dotsEl.style.transform = 'translateX(' + (VIEW_W / 2 - c - BAR_W / 2).toFixed(2) + 'px)'; }
}
document.getElementById('pgPrev').addEventListener('click', function () { setCur(cur - 1, true); });
document.getElementById('pgNext').addEventListener('click', function () { setCur(cur + 1, true); });
root.addEventListener('keydown', function (e) {
if (e.key === 'ArrowLeft') { e.preventDefault(); setCur(cur - 1, true); }
else if (e.key === 'ArrowRight') { e.preventDefault(); setCur(cur + 1, true); }
});
function tick(ts) {
if (t0 === null) t0 = ts;
var now = ts - t0;
var dt = lastTs === null ? 0.016 : Math.min(0.05, (ts - lastTs) / 1000);
lastTs = ts;
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; setCur(DEMO_PAGES[step], false); }
}
var cx = layout(1 - Math.exp(-dt * MORPH_RATE));
offset += (cx - offset) * (1 - Math.exp(-dt * SLIDE_RATE));
dotsEl.style.transform = 'translateX(' + (VIEW_W / 2 - offset - BAR_W / 2).toFixed(2) + 'px)';
requestAnimationFrame(tick);
}
curEl.textContent = '1';
if (reduce) {
root.classList.remove('is-demo');
cur = 17;
curEl.textContent = '17';
noEl.textContent = '17';
card.className = 'pg__card ' + TONES[cur % TONES.length];
var c0 = layout(1);
offset = c0;
dotsEl.style.transform = 'translateX(' + (VIEW_W / 2 - c0 - BAR_W / 2).toFixed(2) + 'px)';
} else {
layout(1);
requestAnimationFrame(tick);
}
})();// DotBarPager — vanilla 판(06-pager-dots-to-bar-morph-compact) 의 React 포트.
// 점마다 현재 쪽과의 거리로 목표 폭·투명도를 계산하고 지수 보간으로 따라간다. 보이는 점은 항상 7개다.
import { useEffect, useRef } from "react";
import styles from "./DotBarPager.module.scss";
type Box<T> = { current: T };
const TOTAL_PAGES = 40;
const DOT_REACH = 3;
const BAR_W = 24;
const DOT_W = [BAR_W, 10, 8, 6];
const DOT_ALPHA = [1, 0.9, 0.7, 0.5];
const DOT_GAP = 6;
const VIEW_W = 120;
const MORPH_RATE = 14;
const SLIDE_RATE = 10;
const STEP_MS = 170;
const START_MS = 100;
const DEMO_PAGES = [1, 2, 3, 4, 9, 10, 11, 20, 21, 22, 23, 24];
const TONES = ["is-mint", "is-lilac", "is-pink", "is-sky"];
export default function DotBarPager() {
const rootRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const dotsRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const cardRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const noRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
const curRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
const prevRef = useRef<HTMLButtonElement | null>(null) as Box<HTMLButtonElement | null>;
const nextRef = useRef<HTMLButtonElement | null>(null) as Box<HTMLButtonElement | null>;
const srRef = useRef<HTMLParagraphElement | null>(null) as Box<HTMLParagraphElement | null>;
const dotRefs = useRef<Array<HTMLElement | null>>([]) as Box<Array<HTMLElement | null>>;
useEffect(() => {
const root = rootRef.current;
const dotsEl = dotsRef.current;
const card = cardRef.current;
const noEl = noRef.current;
const curEl = curRef.current;
const prev = prevRef.current;
const next = nextRef.current;
const sr = srRef.current;
if (!root || !dotsEl || !card || !noEl || !curEl || !prev || !next || !sr) return;
const dots = dotRefs.current;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const w: number[] = [];
const a: number[] = [];
for (let i = 0; i < TOTAL_PAGES; i += 1) { w.push(DOT_W[3]); a.push(0); }
let demo = true;
let cur = 1;
let offset = 0;
let t0: number | null = null;
let lastTs: number | null = null;
let lastStep = -1;
let raf = 0;
const targetW = (dist: number): number => (dist < DOT_W.length ? DOT_W[dist] : DOT_W[DOT_W.length - 1]);
const targetA = (dist: number): number => (dist < DOT_ALPHA.length ? DOT_ALPHA[dist] : 0);
function layout(k: number): number {
let x = 0;
let centerX = 0;
for (let s = 0; s < TOTAL_PAGES; s += 1) {
const dist = Math.abs(s + 1 - cur);
if (k >= 1) { w[s] = targetW(dist); a[s] = targetA(dist); } else {
w[s] += (targetW(dist) - w[s]) * k;
a[s] += (targetA(dist) - a[s]) * k;
}
const el = dots[s];
if (el) {
el.style.width = w[s].toFixed(2) + "px";
el.style.opacity = a[s].toFixed(3);
el.className = [styles.pg__dot, s + 1 === cur ? styles["is-cur"] : ""].join(" ");
el.style.transform = "translateX(" + x.toFixed(2) + "px)";
}
if (s + 1 === cur) centerX = x;
x += w[s] + DOT_GAP;
}
return centerX;
}
function place(): void {
dotsEl!.style.transform = "translateX(" + (VIEW_W / 2 - offset - BAR_W / 2).toFixed(2) + "px)";
}
function setCur(n: number, byUser: boolean): void {
if (byUser) demo = false;
const nxt = Math.max(1, Math.min(TOTAL_PAGES, n));
if (nxt === cur) return;
cur = nxt;
curEl!.textContent = String(cur);
noEl!.textContent = String(cur);
card!.className = [styles.pg__card, styles[TONES[cur % TONES.length]]].join(" ");
if (byUser) sr!.textContent = "현재 " + cur + " / " + TOTAL_PAGES;
if (reduce) { offset = layout(1); place(); }
}
const onPrev = (): void => setCur(cur - 1, true);
const onNext = (): void => setCur(cur + 1, true);
const onKey = (e: KeyboardEvent): void => {
if (e.key === "ArrowLeft") { e.preventDefault(); setCur(cur - 1, true); }
else if (e.key === "ArrowRight") { e.preventDefault(); setCur(cur + 1, true); }
};
prev.addEventListener("click", onPrev);
next.addEventListener("click", onNext);
root.addEventListener("keydown", onKey);
function tick(ts: number): void {
if (t0 === null) t0 = ts;
const now = ts - t0;
const dt = lastTs === null ? 0.016 : Math.min(0.05, (ts - lastTs) / 1000);
lastTs = ts;
if (demo) {
const step = now < START_MS ? 0 : Math.floor((now - START_MS) / STEP_MS) % DEMO_PAGES.length;
if (step !== lastStep) { lastStep = step; setCur(DEMO_PAGES[step], false); }
}
const cx = layout(1 - Math.exp(-dt * MORPH_RATE));
offset += (cx - offset) * (1 - Math.exp(-dt * SLIDE_RATE));
place();
raf = requestAnimationFrame(tick);
}
curEl.textContent = "1";
if (reduce) {
demo = false;
cur = 17;
curEl.textContent = "17";
noEl.textContent = "17";
card.className = [styles.pg__card, styles[TONES[cur % TONES.length]]].join(" ");
offset = layout(1);
place();
} else {
layout(1);
raf = requestAnimationFrame(tick);
}
return () => {
cancelAnimationFrame(raf);
prev.removeEventListener("click", onPrev);
next.removeEventListener("click", onNext);
root.removeEventListener("keydown", onKey);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.pg} ref={rootRef}>
<div className={[styles.pg__card, styles["is-mint"]].join(" ")} ref={cardRef} aria-hidden="true">
<b className={styles.pg__no} ref={noRef}>1</b>
<i className={styles.pg__row}></i>
<i className={[styles.pg__row, styles["pg__row--s"]].join(" ")}></i>
</div>
<nav className={styles.pg__nav} aria-label="페이지 이동">
<button type="button" className={styles.pg__b} ref={prevRef} aria-label="이전 쪽">‹</button>
<div className={styles.pg__dots} aria-hidden="true"><div className={styles.pg__strip} ref={dotsRef}>
{Array.from({ length: TOTAL_PAGES }, (_, i) => (
<i className={styles.pg__dot} key={i} ref={(el: HTMLElement | null) => { dotRefs.current[i] = el; }}></i>
))}
</div></div>
<button type="button" className={styles.pg__b} ref={nextRef} aria-label="다음 쪽">›</button>
</nav>
<div className={styles.pg__read}><b ref={curRef}>1</b><span className={styles.pg__sep}>/</span><span>{TOTAL_PAGES}</span><span className={styles.pg__k}>점</span><b>{DOT_REACH * 2 + 1}</b></div>
<p className={styles.pg__sr} ref={srRef} aria-live="polite"></p>
</div>
</main>
);
}06 · 점이 막대로 늘어나는 모바일 페이저 — 측정 누적면적 14.051% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function targetW(dist) { return dist < DOT_W.length ? DOT_W[dist] : DOT_W[DOT_W.length - 1]; }
function targetA(dist) { return dist < DOT_ALPHA.length ? DOT_ALPHA[dist] : 0; }
function layout(k) {
var x = 0;
var centerX = 0;
for (var s = 0; s < TOTAL_PAGES; s += 1) {
var dist = Math.abs(s + 1 - cur);
if (k >= 1) { w[s] = targetW(dist); a[s] = targetA(dist); } else {
w[s] += (targetW(dist) - w[s]) * k;
a[s] += (targetA(dist) - a[s]) * k;
}
dots[s].style.width = w[s].toFixed(2) + 'px';
dots[s].style.opacity = a[s].toFixed(3);
dots[s].className = 'pg__dot' + (s + 1 === cur ? ' is-cur' : '');
dots[s].style.transform = 'translateX(' + x.toFixed(2) + 'px)';
if (s + 1 === cur) centerX = x;
x += w[s] + DOT_GAP;
}
return centerX;
}
07레일을 끌어 쪽을 고르는 관성 스냅
pointerMove 가 포인터 순간 속도를 VELOCITY_BLEND 비율로 섞어 v 에 담고, 손을 놓으면 release 가 관성 미끄러짐으로 넘겨 느려졌을 때 가까운 눈금에 붙인다. 말풍선은 속도에 따라 기울고 손잡이는 role="slider" 라서 키보드로도 쪽을 고를 수 있다. 07 항목은 누적면적 15.1%·강도 평균 98.5·움직인 프레임 9/23 이다.

<main class="stage">
<div class="pg is-demo" id="pgRoot">
<div class="pg__card is-mint" id="pgCard" aria-hidden="true"><b class="pg__no" id="pgNo">1</b><i class="pg__row"></i><i class="pg__row pg__row--s"></i></div>
<div class="pg__rail" id="pgRail">
<div class="pg__ticks" id="pgTicks" aria-hidden="true"></div>
<div class="pg__fill" id="pgFill" aria-hidden="true"></div>
<div class="pg__thumb" id="pgThumb" role="slider" tabindex="0" aria-label="페이지 이동" aria-valuemin="1" aria-valuemax="24" aria-valuenow="1">
<span class="pg__bubble" id="pgBubble" aria-hidden="true">1</span>
</div>
</div>
<div class="pg__read"><span class="pg__k">속도</span><b id="pgSpeed">0</b></div>
<p class="pg__sr" id="pgSr" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 놓는 순간의 속도로 관성 미끄러짐(FRICTION)을 계산하고, 느려지면 가장 가까운 눈금으로 붙는다(SNAP_RATE).
// 말풍선은 속도에 비례해 기울어진다.
$rail-w: 240px;
@include stage($stage-ink);
.pg { display: grid; gap: $sp-3; justify-items: center; color: $stage-paper; }
.pg__card { display: grid; align-content: center; gap: $sp-2; width: $rail-w + 20px; height: 60px; box-sizing: border-box; padding: $sp-2 $sp-4; border-radius: $r-card; }
.pg__card.is-mint { background: rgba($subject-mint, .4); }
.pg__card.is-lilac { background: rgba($subject-lilac, .44); }
.pg__card.is-pink { background: rgba($subject-pink, .42); }
.pg__card.is-sky { background: rgba($subject-sky, .42); }
.pg__no { font-size: 20px; line-height: 24px; font-weight: 700; color: $stage-paper; font-variant-numeric: tabular-nums; }
.pg__row { display: block; height: 6px; width: 70%; border-radius: $r-xs; background: rgba($stage-paper, .5); }
.pg__row--s { width: 44%; }
.pg__rail { position: relative; width: $rail-w; height: 20px; margin-top: $sp-6; touch-action: none; cursor: grab; }
.pg__rail::before { content: ""; position: absolute; left: 0; right: 0; top: 8px; height: 4px; border-radius: $r-xs; background: rgba($stage-paper, .18); }
.pg__fill { position: absolute; left: 0; top: 8px; width: $rail-w; height: 4px; border-radius: $r-xs; background: $subject-lilac; transform-origin: 0 50%; transform: scaleX(0); }
.pg__ticks { position: absolute; inset: 0; }
.pg__tick { position: absolute; left: 0; top: 4px; width: 2px; height: 12px; margin-left: -1px; border-radius: $r-xs; background: rgba($stage-paper, .42); }
.pg__thumb {
position: absolute;
left: -10px;
top: 0;
width: 20px;
height: 20px;
border-radius: $r-pill;
background: $subject-mint;
will-change: transform;
}
.pg__thumb:focus-visible { outline: 2px solid $subject-sky; outline-offset: 2px; }
.pg__bubble {
position: absolute;
left: 50%;
bottom: 28px;
min-width: 28px;
box-sizing: border-box;
padding: $sp-1 $sp-2;
border-radius: $r-sm;
background: $stage-paper;
color: $subject-ink;
font-size: 12px;
line-height: 16px;
font-weight: 700;
text-align: center;
font-variant-numeric: tabular-nums;
transform: translateX(-50%);
transform-origin: 50% 100%;
}
.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-pink; font-variant-numeric: tabular-nums; min-width: 32px; text-align: right; }
.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__card { width: $rail-w; }
}
@media (prefers-reduced-motion: reduce) {
.pg__thumb, .pg__fill { transition: 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;
color: #ffffff;
}
.pg__card {
display: grid;
align-content: center;
gap: 8px;
width: 260px;
height: 60px;
box-sizing: border-box;
padding: 8px 16px;
border-radius: 16px;
}
.pg__card.is-mint {
background: rgba(76, 212, 166, 0.4);
}
.pg__card.is-lilac {
background: rgba(185, 160, 255, 0.44);
}
.pg__card.is-pink {
background: rgba(255, 158, 187, 0.42);
}
.pg__card.is-sky {
background: rgba(110, 200, 255, 0.42);
}
.pg__no {
font-size: 20px;
line-height: 24px;
font-weight: 700;
color: #ffffff;
font-variant-numeric: tabular-nums;
}
.pg__row {
display: block;
height: 6px;
width: 70%;
border-radius: 4px;
background: rgba(255, 255, 255, 0.5);
}
.pg__row--s {
width: 44%;
}
.pg__rail {
position: relative;
width: 240px;
height: 20px;
margin-top: 24px;
touch-action: none;
cursor: grab;
}
.pg__rail::before {
content: "";
position: absolute;
left: 0;
right: 0;
top: 8px;
height: 4px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.18);
}
.pg__fill {
position: absolute;
left: 0;
top: 8px;
width: 240px;
height: 4px;
border-radius: 4px;
background: #b9a0ff;
transform-origin: 0 50%;
transform: scaleX(0);
}
.pg__ticks {
position: absolute;
inset: 0;
}
.pg__tick {
position: absolute;
left: 0;
top: 4px;
width: 2px;
height: 12px;
margin-left: -1px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.42);
}
.pg__thumb {
position: absolute;
left: -10px;
top: 0;
width: 20px;
height: 20px;
border-radius: 999px;
background: #4cd4a6;
will-change: transform;
}
.pg__thumb:focus-visible {
outline: 2px solid #6ec8ff;
outline-offset: 2px;
}
.pg__bubble {
position: absolute;
left: 50%;
bottom: 28px;
min-width: 28px;
box-sizing: border-box;
padding: 4px 8px;
border-radius: 8px;
background: #ffffff;
color: #17141a;
font-size: 12px;
line-height: 16px;
font-weight: 700;
text-align: center;
font-variant-numeric: tabular-nums;
transform: translateX(-50%);
transform-origin: 50% 100%;
}
.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: #ff9ebb;
font-variant-numeric: tabular-nums;
min-width: 32px;
text-align: right;
}
.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__card {
width: 240px;
}
}
@media (prefers-reduced-motion: reduce) {
.pg__thumb, .pg__fill {
transition: none;
}
}(function () {
// 기법: 손잡이를 놓는 순간의 속도(최근 포인터 이동의 지수 평균)로 관성 미끄러짐을 계산하고,
// 속도가 SNAP_SPEED 아래로 떨어지면 가장 가까운 쪽 눈금으로 지수 보간해 붙는다.
var TOTAL_PAGES = 24;
var RAIL_W = 240;
var FRICTION = 3.4;
var SNAP_SPEED = 150;
var SNAP_RATE = 16;
var VELOCITY_BLEND = 0.4;
var TILT_GAIN = 0.014;
var TILT_MAX = 16;
var CYCLE_MS = 2700;
var TONES = ['is-mint', 'is-lilac', 'is-pink', 'is-sky'];
var root = document.getElementById('pgRoot');
var rail = document.getElementById('pgRail');
var ticksEl = document.getElementById('pgTicks');
var fill = document.getElementById('pgFill');
var thumb = document.getElementById('pgThumb');
var bubble = document.getElementById('pgBubble');
var card = document.getElementById('pgCard');
var noEl = document.getElementById('pgNo');
var speedEl = document.getElementById('pgSpeed');
var sr = document.getElementById('pgSr');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
thumb.setAttribute('aria-valuemax', String(TOTAL_PAGES));
function tickX(page) { return ((page - 1) / (TOTAL_PAGES - 1)) * RAIL_W; }
function nearestPage(px) { return Math.max(1, Math.min(TOTAL_PAGES, Math.round((px / RAIL_W) * (TOTAL_PAGES - 1)) + 1)); }
for (var i = 1; i <= TOTAL_PAGES; i += 1) {
var tk = document.createElement('i');
tk.className = 'pg__tick';
tk.style.transform = 'translateX(' + tickX(i).toFixed(2) + 'px)';
ticksEl.appendChild(tk);
}
var x = 0;
var v = 0;
var mode = 'idle';
var lastX = 0;
var lastMoveTs = 0;
var page = 1;
var snapTo = 0;
var t0 = null;
var lastTs = null;
var cycleBase = 0;
var demoStage = 0;
var dragFrom = 0;
function perfNow() { return t0 === null ? 0 : performance.now() - t0; }
function setPage(p, announce) {
if (p === page) return;
page = p;
noEl.textContent = String(p);
bubble.textContent = String(p);
thumb.setAttribute('aria-valuenow', String(p));
card.className = 'pg__card ' + TONES[p % TONES.length];
if (announce) sr.textContent = '현재 ' + p + ' / ' + TOTAL_PAGES;
}
function pointerMove(px, now) {
var nx = Math.max(0, Math.min(RAIL_W, px));
var dt = Math.max(0.004, (now - lastMoveTs) / 1000);
var inst = (nx - lastX) / dt;
v = v * (1 - VELOCITY_BLEND) + inst * VELOCITY_BLEND;
lastX = nx;
lastMoveTs = now;
x = nx;
mode = 'drag';
}
function release() {
if (mode !== 'drag') return;
if (reduce) { x = tickX(nearestPage(x)); v = 0; mode = 'idle'; return; }
mode = 'glide';
}
function localX(e) { return e.clientX - rail.getBoundingClientRect().left; }
rail.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
try { rail.setPointerCapture(e.pointerId); } catch (err) { /* 무시 */ }
lastX = x;
lastMoveTs = perfNow();
v = 0;
pointerMove(localX(e), perfNow());
});
rail.addEventListener('pointermove', function (e) { if (mode === 'drag') pointerMove(localX(e), perfNow()); });
rail.addEventListener('pointerup', release);
rail.addEventListener('pointercancel', release);
thumb.addEventListener('keydown', function (e) {
var p = page;
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') p += 1;
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') p -= 1;
else if (e.key === 'PageUp') p += 5;
else if (e.key === 'PageDown') p -= 5;
else if (e.key === 'Home') p = 1;
else if (e.key === 'End') p = TOTAL_PAGES;
else return;
e.preventDefault();
root.classList.remove('is-demo');
p = Math.max(1, Math.min(TOTAL_PAGES, p));
v = 0;
snapTo = tickX(p);
mode = reduce ? 'idle' : 'snap';
if (reduce) x = snapTo;
setPage(p, true);
});
function demoDrive(now) {
var t = now - cycleBase;
if (t >= CYCLE_MS) { cycleBase = now; demoStage = 0; x = 0; v = 0; mode = 'idle'; setPage(1, false); return; }
if (t >= 100 && t < 450) {
var p1 = (t - 100) / 350;
if (mode !== 'drag') { lastX = x; lastMoveTs = now; v = 0; }
pointerMove(80 * p1 * p1, now);
demoStage = 1;
} else if (demoStage === 1 && t >= 450) {
release();
demoStage = 2;
} else if (t >= 900 && t < 1240) {
if (demoStage < 3) { demoStage = 3; lastX = x; lastMoveTs = now; v = 0; mode = 'drag'; dragFrom = x; }
var p2 = (t - 900) / 340;
pointerMove(dragFrom - 120 * p2 * p2, now);
} else if (demoStage === 3 && t >= 1240) {
release();
demoStage = 4;
}
}
function tick(ts) {
if (t0 === null) t0 = ts;
var now = ts - t0;
var dt = lastTs === null ? 0.016 : Math.min(0.05, (ts - lastTs) / 1000);
lastTs = ts;
if (root.classList.contains('is-demo')) demoDrive(now);
if (mode === 'glide') {
x += v * dt;
v *= Math.exp(-FRICTION * dt);
if (x <= 0) { x = 0; v = 0; }
if (x >= RAIL_W) { x = RAIL_W; v = 0; }
if (Math.abs(v) < SNAP_SPEED) { snapTo = tickX(nearestPage(x)); mode = 'snap'; }
}
if (mode === 'snap') {
v *= Math.exp(-FRICTION * 3 * dt);
x += (snapTo - x) * (1 - Math.exp(-SNAP_RATE * dt));
if (Math.abs(snapTo - x) < 0.25) { x = snapTo; v = 0; mode = 'idle'; }
}
if (mode === 'drag') v *= Math.exp(-6 * dt);
setPage(nearestPage(x), mode === 'idle' && !root.classList.contains('is-demo'));
thumb.style.transform = 'translateX(' + x.toFixed(2) + 'px)';
fill.style.transform = 'scaleX(' + (x / RAIL_W).toFixed(4) + ')';
var tilt = Math.max(-TILT_MAX, Math.min(TILT_MAX, v * TILT_GAIN));
bubble.style.transform = 'translateX(-50%) rotate(' + tilt.toFixed(2) + 'deg)';
speedEl.textContent = String(Math.round(Math.abs(v)));
requestAnimationFrame(tick);
}
if (reduce) {
root.classList.remove('is-demo');
x = tickX(9);
setPage(9, false);
thumb.style.transform = 'translateX(' + x.toFixed(2) + 'px)';
fill.style.transform = 'scaleX(' + (x / RAIL_W).toFixed(4) + ')';
bubble.style.transform = 'translateX(-50%)';
// reduce 에서도 눌러 끌면 바로 따라가고 놓으면 가장 가까운 눈금에 붙는다
var follow = function () {
setPage(nearestPage(x), true);
thumb.style.transform = 'translateX(' + x.toFixed(2) + 'px)';
fill.style.transform = 'scaleX(' + (x / RAIL_W).toFixed(4) + ')';
};
rail.addEventListener('pointermove', follow);
rail.addEventListener('pointerup', follow);
thumb.addEventListener('keydown', follow);
rail.addEventListener('pointerdown', follow);
} else {
requestAnimationFrame(tick);
}
})();// RailScrubPager — vanilla 판(07-pager-progress-rail-scrub-inertia-snap) 의 React 포트.
// 놓는 순간의 속도로 관성 미끄러짐(FRICTION)을 계산하고, 느려지면 가장 가까운 쪽 눈금으로 지수 보간해 붙는다.
import { useEffect, useRef } from "react";
import styles from "./RailScrubPager.module.scss";
type Box<T> = { current: T };
type Mode = "idle" | "drag" | "glide" | "snap";
const TOTAL_PAGES = 24;
const RAIL_W = 240;
const FRICTION = 3.4;
const SNAP_SPEED = 150;
const SNAP_RATE = 16;
const VELOCITY_BLEND = 0.4;
const TILT_GAIN = 0.014;
const TILT_MAX = 16;
const CYCLE_MS = 2700;
const TONES = ["is-mint", "is-lilac", "is-pink", "is-sky"];
const tickX = (page: number): number => ((page - 1) / (TOTAL_PAGES - 1)) * RAIL_W;
const nearestPage = (px: number): number => Math.max(1, Math.min(TOTAL_PAGES, Math.round((px / RAIL_W) * (TOTAL_PAGES - 1)) + 1));
export default function RailScrubPager() {
const rootRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const railRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const fillRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const thumbRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const bubbleRef = useRef<HTMLSpanElement | null>(null) as Box<HTMLSpanElement | null>;
const cardRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const noRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
const speedRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
const srRef = useRef<HTMLParagraphElement | null>(null) as Box<HTMLParagraphElement | null>;
useEffect(() => {
const root = rootRef.current;
const rail = railRef.current;
const fill = fillRef.current;
const thumb = thumbRef.current;
const bubble = bubbleRef.current;
const card = cardRef.current;
const noEl = noRef.current;
const speedEl = speedRef.current;
const sr = srRef.current;
if (!root || !rail || !fill || !thumb || !bubble || !card || !noEl || !speedEl || !sr) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo = true;
let x = 0;
let v = 0;
let mode: Mode = "idle";
let lastX = 0;
let lastMoveTs = 0;
let page = 1;
let snapTo = 0;
let t0: number | null = null;
let lastTs: number | null = null;
let cycleBase = 0;
let demoStage = 0;
let dragFrom = 0;
let raf = 0;
const perfNow = (): number => (t0 === null ? 0 : performance.now() - t0);
function setPage(p: number, announce: boolean): void {
if (p === page) return;
page = p;
noEl!.textContent = String(p);
bubble!.textContent = String(p);
thumb!.setAttribute("aria-valuenow", String(p));
card!.className = [styles.pg__card, styles[TONES[p % TONES.length]]].join(" ");
if (announce) sr!.textContent = "현재 " + p + " / " + TOTAL_PAGES;
}
function pointerMove(px: number, now: number): void {
const nx = Math.max(0, Math.min(RAIL_W, px));
const dt = Math.max(0.004, (now - lastMoveTs) / 1000);
const inst = (nx - lastX) / dt;
v = v * (1 - VELOCITY_BLEND) + inst * VELOCITY_BLEND;
lastX = nx;
lastMoveTs = now;
x = nx;
mode = "drag";
}
function release(): void {
if (mode !== "drag") return;
if (reduce) { x = tickX(nearestPage(x)); v = 0; mode = "idle"; return; }
mode = "glide";
}
const localX = (e: PointerEvent): number => e.clientX - rail.getBoundingClientRect().left;
const onDown = (e: PointerEvent): void => {
demo = false;
try { rail.setPointerCapture(e.pointerId); } catch (err) { /* 무시 */ }
lastX = x;
lastMoveTs = perfNow();
v = 0;
pointerMove(localX(e), perfNow());
};
const onMove = (e: PointerEvent): void => { if (mode === "drag") pointerMove(localX(e), perfNow()); };
const onKey = (e: KeyboardEvent): void => {
let p = page;
if (e.key === "ArrowRight" || e.key === "ArrowUp") p += 1;
else if (e.key === "ArrowLeft" || e.key === "ArrowDown") p -= 1;
else if (e.key === "PageUp") p += 5;
else if (e.key === "PageDown") p -= 5;
else if (e.key === "Home") p = 1;
else if (e.key === "End") p = TOTAL_PAGES;
else return;
e.preventDefault();
demo = false;
p = Math.max(1, Math.min(TOTAL_PAGES, p));
v = 0;
snapTo = tickX(p);
mode = reduce ? "idle" : "snap";
if (reduce) x = snapTo;
setPage(p, true);
};
rail.addEventListener("pointerdown", onDown);
rail.addEventListener("pointermove", onMove);
rail.addEventListener("pointerup", release);
rail.addEventListener("pointercancel", release);
thumb.addEventListener("keydown", onKey);
function demoDrive(now: number): void {
const t = now - cycleBase;
if (t >= CYCLE_MS) { cycleBase = now; demoStage = 0; x = 0; v = 0; mode = "idle"; setPage(1, false); return; }
if (t >= 100 && t < 450) {
const p1 = (t - 100) / 350;
if (mode !== "drag") { lastX = x; lastMoveTs = now; v = 0; }
pointerMove(80 * p1 * p1, now);
demoStage = 1;
} else if (demoStage === 1 && t >= 450) {
release();
demoStage = 2;
} else if (t >= 900 && t < 1240) {
if (demoStage < 3) { demoStage = 3; lastX = x; lastMoveTs = now; v = 0; mode = "drag"; dragFrom = x; }
const p2 = (t - 900) / 340;
pointerMove(dragFrom - 120 * p2 * p2, now);
} else if (demoStage === 3 && t >= 1240) {
release();
demoStage = 4;
}
}
function paintStatic(): void {
thumb!.style.transform = "translateX(" + x.toFixed(2) + "px)";
fill!.style.transform = "scaleX(" + (x / RAIL_W).toFixed(4) + ")";
}
function tick(ts: number): void {
if (t0 === null) t0 = ts;
const now = ts - t0;
const dt = lastTs === null ? 0.016 : Math.min(0.05, (ts - lastTs) / 1000);
lastTs = ts;
if (demo) demoDrive(now);
if (mode === "glide") {
x += v * dt;
v *= Math.exp(-FRICTION * dt);
if (x <= 0) { x = 0; v = 0; }
if (x >= RAIL_W) { x = RAIL_W; v = 0; }
if (Math.abs(v) < SNAP_SPEED) { snapTo = tickX(nearestPage(x)); mode = "snap"; }
}
if (mode === "snap") {
v *= Math.exp(-FRICTION * 3 * dt);
x += (snapTo - x) * (1 - Math.exp(-SNAP_RATE * dt));
if (Math.abs(snapTo - x) < 0.25) { x = snapTo; v = 0; mode = "idle"; }
}
if (mode === "drag") v *= Math.exp(-6 * dt);
setPage(nearestPage(x), mode === "idle" && !demo);
paintStatic();
const tilt = Math.max(-TILT_MAX, Math.min(TILT_MAX, v * TILT_GAIN));
bubble!.style.transform = "translateX(-50%) rotate(" + tilt.toFixed(2) + "deg)";
speedEl!.textContent = String(Math.round(Math.abs(v)));
raf = requestAnimationFrame(tick);
}
let follow: (() => void) | null = null;
if (reduce) {
demo = false;
x = tickX(9);
setPage(9, false);
paintStatic();
bubble.style.transform = "translateX(-50%)";
// reduce 에서도 눌러 끌면 바로 따라가고 놓으면 가장 가까운 눈금에 붙는다
const f = (): void => { setPage(nearestPage(x), true); paintStatic(); };
follow = f;
rail.addEventListener("pointermove", f);
rail.addEventListener("pointerup", f);
rail.addEventListener("pointerdown", f);
thumb.addEventListener("keydown", f);
} else {
raf = requestAnimationFrame(tick);
}
return () => {
cancelAnimationFrame(raf);
rail.removeEventListener("pointerdown", onDown);
rail.removeEventListener("pointermove", onMove);
rail.removeEventListener("pointerup", release);
rail.removeEventListener("pointercancel", release);
thumb.removeEventListener("keydown", onKey);
if (follow) {
rail.removeEventListener("pointermove", follow);
rail.removeEventListener("pointerup", follow);
rail.removeEventListener("pointerdown", follow);
thumb.removeEventListener("keydown", follow);
}
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.pg} ref={rootRef}>
<div className={[styles.pg__card, styles["is-mint"]].join(" ")} ref={cardRef} aria-hidden="true">
<b className={styles.pg__no} ref={noRef}>1</b>
<i className={styles.pg__row}></i>
<i className={[styles.pg__row, styles["pg__row--s"]].join(" ")}></i>
</div>
<div className={styles.pg__rail} ref={railRef}>
<div className={styles.pg__ticks} aria-hidden="true">
{Array.from({ length: TOTAL_PAGES }, (_, i) => (
<i className={styles.pg__tick} key={i} style={{ transform: "translateX(" + tickX(i + 1).toFixed(2) + "px)" }}></i>
))}
</div>
<div className={styles.pg__fill} ref={fillRef} aria-hidden="true"></div>
<div className={styles.pg__thumb} ref={thumbRef} role="slider" tabIndex={0} aria-label="페이지 이동" aria-valuemin={1} aria-valuemax={TOTAL_PAGES} aria-valuenow={1}>
<span className={styles.pg__bubble} ref={bubbleRef} aria-hidden="true">1</span>
</div>
</div>
<div className={styles.pg__read}><span className={styles.pg__k}>속도</span><b ref={speedRef}>0</b></div>
<p className={styles.pg__sr} ref={srRef} aria-live="polite"></p>
</div>
</main>
);
}07 · 레일을 끌어 쪽을 고르는 관성 스냅 — 측정 누적면적 15.103% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function pointerMove(px, now) {
var nx = Math.max(0, Math.min(RAIL_W, px));
var dt = Math.max(0.004, (now - lastMoveTs) / 1000);
var inst = (nx - lastX) / dt;
v = v * (1 - VELOCITY_BLEND) + inst * VELOCITY_BLEND;
lastX = nx;
lastMoveTs = now;
x = nx;
mode = 'drag';
}
function release() {
if (mode !== 'drag') return;
if (reduce) { x = tickX(nearestPage(x)); v = 0; mode = 'idle'; return; }
mode = 'glide';
}
08다음만 있는 커서 페이저와 지나온 길
goNext 는 reached 보다 멀리 가면 지나온 점을 하나 늘리고, 점을 누르면 goBack 이 그 쪽으로 돌아온다. 쪽 번호 대신 cursorOf 가 만든 네 글자 문자열이 커서 자리를 맡고, 끝에 닿으면 다음 버튼이 aria-disabled 가 된다. 08 항목은 누적면적 14.5%·강도 평균 72.4·움직인 프레임 18/23 이다.

<main class="stage">
<div class="pg is-demo" id="pgRoot">
<div class="pg__card is-mint" id="pgCard" aria-hidden="true">
<b class="pg__no" id="pgNo">1</b>
<span class="pg__cursor" id="pgCursor">0000</span>
</div>
<nav class="pg__nav" aria-label="페이지 이동">
<button type="button" class="pg__next" id="pgNext">다음</button>
</nav>
<div class="pg__trail" id="pgTrail" aria-label="지나온 쪽">
<i class="pg__line" id="pgLine" aria-hidden="true"></i>
</div>
<p class="pg__sr" id="pgSr" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 전체 쪽수를 모르니 다음 커서만 받는다. 지나온 쪽은 점으로 남고, 점이 늘면 간격이 줄어 길이는 TRAIL_W 로 일정하다.
$trail-w: 232px;
$dot: 12px;
@include stage($stage-ink);
.pg { display: grid; gap: $sp-4; justify-items: center; color: $stage-paper; width: 264px; max-width: 100%; }
.pg__card { display: flex; align-items: center; justify-content: space-between; box-sizing: border-box; width: 100%; height: 56px; padding: $sp-2 $sp-4; border-radius: $r-card; }
.pg__card.is-mint { background: rgba($subject-mint, .4); }
.pg__card.is-lilac { background: rgba($subject-lilac, .44); }
.pg__card.is-pink { background: rgba($subject-pink, .42); }
.pg__card.is-sky { background: rgba($subject-sky, .42); }
.pg__no { font-size: 24px; line-height: 28px; font-weight: 700; color: $stage-paper; font-variant-numeric: tabular-nums; }
.pg__cursor { font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; font-size: 12px; line-height: 16px; padding: $sp-1 $sp-2; border-radius: $r-xs; background: rgba($stage-ink, .6); color: $stage-paper; }
.pg__next {
box-sizing: border-box;
min-width: 96px;
height: 40px;
padding: 0 $sp-5;
border: 0;
border-radius: $r-pill;
background: $subject-lilac;
color: $subject-ink;
font-family: inherit;
font-size: 14px;
line-height: 20px;
font-weight: 700;
cursor: pointer;
}
.pg__next.is-end { opacity: .55; cursor: default; }
.pg__next:focus-visible, .pg__dot:focus-visible { outline: 2px solid $subject-sky; outline-offset: 2px; }
.pg__trail { position: relative; width: $trail-w; height: $dot; }
.pg__line { position: absolute; left: 0; top: 4px; width: $trail-w; height: 4px; border-radius: $r-xs; background: rgba($subject-pink, .8); transform-origin: 0 50%; transform: scaleX(0); }
.pg__dot {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
width: $dot;
height: $dot;
padding: 0;
border: 0;
border-radius: $r-pill;
background: rgba($stage-paper, .86);
transform: scale(0);
will-change: transform;
}
.pg__dot.is-cur { background: $subject-pink; }
.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__next { min-width: 88px; }
}
@media (prefers-reduced-motion: reduce) {
.pg__dot { transition: 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: 16px;
justify-items: center;
color: #ffffff;
width: 264px;
max-width: 100%;
}
.pg__card {
display: flex;
align-items: center;
justify-content: space-between;
box-sizing: border-box;
width: 100%;
height: 56px;
padding: 8px 16px;
border-radius: 16px;
}
.pg__card.is-mint {
background: rgba(76, 212, 166, 0.4);
}
.pg__card.is-lilac {
background: rgba(185, 160, 255, 0.44);
}
.pg__card.is-pink {
background: rgba(255, 158, 187, 0.42);
}
.pg__card.is-sky {
background: rgba(110, 200, 255, 0.42);
}
.pg__no {
font-size: 24px;
line-height: 28px;
font-weight: 700;
color: #ffffff;
font-variant-numeric: tabular-nums;
}
.pg__cursor {
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 12px;
line-height: 16px;
padding: 4px 8px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.6);
color: #ffffff;
}
.pg__next {
box-sizing: border-box;
min-width: 96px;
height: 40px;
padding: 0 20px;
border: 0;
border-radius: 999px;
background: #b9a0ff;
color: #17141a;
font-family: inherit;
font-size: 14px;
line-height: 20px;
font-weight: 700;
cursor: pointer;
}
.pg__next.is-end {
opacity: 0.55;
cursor: default;
}
.pg__next:focus-visible, .pg__dot:focus-visible {
outline: 2px solid #6ec8ff;
outline-offset: 2px;
}
.pg__trail {
position: relative;
width: 232px;
height: 12px;
}
.pg__line {
position: absolute;
left: 0;
top: 4px;
width: 232px;
height: 4px;
border-radius: 4px;
background: rgba(255, 158, 187, 0.8);
transform-origin: 0 50%;
transform: scaleX(0);
}
.pg__dot {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
width: 12px;
height: 12px;
padding: 0;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.86);
transform: scale(0);
will-change: transform;
}
.pg__dot.is-cur {
background: #ff9ebb;
}
.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__next {
min-width: 88px;
}
}
@media (prefers-reduced-motion: reduce) {
.pg__dot {
transition: none;
}
}(function () {
// 기법: 전체 쪽수를 모르는 목록은 다음 커서만 받는다. 지나온 쪽을 점으로 남기고,
// 점이 늘어날수록 간격을 TRAIL_W / (점 수 - 1) 로 줄여 길이가 일정하게 유지된다. 새 점은 스프링으로 튄다.
var END_AT = 9;
var TRAIL_W = 232;
var MAX_GAP = 36;
var DOT_SIZE = 12;
var GAP_RATE = 12;
var POP_MS = 420;
var CYCLE_MS = 2700;
var PRESS_START = 140;
var PRESS_EVERY = 180;
var BACK_AT = 1980;
var BACK_TO = 3;
var TONES = ['is-mint', 'is-lilac', 'is-pink', 'is-sky'];
var root = document.getElementById('pgRoot');
var trail = document.getElementById('pgTrail');
var line = document.getElementById('pgLine');
var nextBtn = document.getElementById('pgNext');
var card = document.getElementById('pgCard');
var noEl = document.getElementById('pgNo');
var cursorEl = document.getElementById('pgCursor');
var sr = document.getElementById('pgSr');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function cursorOf(n) { return (Math.imul(n, 2654435761) >>> 0).toString(16).slice(-4); }
var dots = [];
var born = [];
for (var i = 0; i < END_AT; i += 1) {
var d = document.createElement('button');
d.type = 'button';
d.className = 'pg__dot';
d.setAttribute('aria-label', String(i + 1));
d.tabIndex = -1;
trail.appendChild(d);
dots.push(d);
born.push(-1);
}
var cur = 1;
var reached = 1;
var gapShown = 0;
var t0 = null;
var lastTs = null;
var cycleBase = 0;
var pressed = 0;
var wentBack = false;
function perfNow() { return t0 === null ? 0 : performance.now() - t0; }
function sync(now, announce) {
noEl.textContent = String(cur);
cursorEl.textContent = cursorOf(cur);
card.className = 'pg__card ' + TONES[cur % TONES.length];
var atEnd = cur === END_AT;
nextBtn.setAttribute('aria-disabled', atEnd ? 'true' : 'false');
nextBtn.textContent = atEnd ? '끝' : '다음';
nextBtn.className = 'pg__next' + (atEnd ? ' is-end' : '');
for (var s = 0; s < END_AT; s += 1) {
var seen = s + 1 <= reached;
dots[s].className = 'pg__dot' + (seen ? ' is-seen' : '') + (s + 1 === cur ? ' is-cur' : '');
dots[s].tabIndex = seen ? 0 : -1;
dots[s].setAttribute('aria-hidden', seen ? 'false' : 'true');
if (s + 1 === cur) dots[s].setAttribute('aria-current', 'page'); else dots[s].removeAttribute('aria-current');
}
if (announce) sr.textContent = '현재 ' + cur;
}
function goNext(byUser, now) {
if (byUser) root.classList.remove('is-demo');
if (cur >= END_AT) return;
cur += 1;
if (cur > reached) { reached = cur; if (!reduce) born[cur - 1] = now; }
sync(now, byUser);
}
function goBack(page, byUser, now) {
if (byUser) root.classList.remove('is-demo');
if (page < 1 || page > reached || page === cur) return;
cur = page;
sync(now, byUser);
}
nextBtn.addEventListener('click', function () { goNext(true, perfNow()); });
trail.addEventListener('click', function (e) { var idx = dots.indexOf(e.target); if (idx >= 0) goBack(idx + 1, true, perfNow()); });
root.addEventListener('keydown', function (e) {
if (e.key === 'ArrowRight') { e.preventDefault(); goNext(true, perfNow()); }
else if (e.key === 'ArrowLeft') { e.preventDefault(); goBack(cur - 1, true, perfNow()); }
});
function popScale(age) {
if (age < 0 || age >= POP_MS) return 1;
return 1 - Math.exp(-age / 65) * Math.cos(age / 50);
}
function paint(now, dt) {
var n = Math.max(2, reached);
var targetGap = Math.min(MAX_GAP, TRAIL_W / (n - 1));
gapShown = reduce ? targetGap : gapShown + (targetGap - gapShown) * (1 - Math.exp(-dt * GAP_RATE));
for (var s = 0; s < END_AT; s += 1) {
var seen = s + 1 <= reached;
var sc = seen ? popScale(now - born[s]) : 0;
if (seen && sc === 1) born[s] = -1;
dots[s].style.opacity = seen ? '1' : '0';
dots[s].style.transform = 'translateX(' + (s * gapShown).toFixed(2) + 'px) scale(' + sc.toFixed(3) + ')';
}
line.style.transform = 'scaleX(' + (((reached - 1) * gapShown) / TRAIL_W).toFixed(4) + ')';
}
function tick(ts) {
if (t0 === null) t0 = ts;
var now = ts - t0;
var dt = lastTs === null ? 0.016 : Math.min(0.05, (ts - lastTs) / 1000);
lastTs = ts;
if (root.classList.contains('is-demo')) {
var t = now - cycleBase;
while (pressed < END_AT - 1 && t >= PRESS_START + pressed * PRESS_EVERY) { goNext(false, now); pressed += 1; }
if (!wentBack && t >= BACK_AT) { wentBack = true; goBack(BACK_TO, false, now); }
if (t >= CYCLE_MS) {
cycleBase = now; pressed = 0; wentBack = false;
cur = 1; reached = 1; gapShown = 0;
for (var q = 0; q < END_AT; q += 1) born[q] = -1;
sync(now, false);
}
}
paint(now, dt);
requestAnimationFrame(tick);
}
sync(0, false);
if (reduce) {
root.classList.remove('is-demo');
paint(0, 1);
} else {
requestAnimationFrame(tick);
}
if (reduce) {
nextBtn.addEventListener('click', function () { paint(0, 1); });
trail.addEventListener('click', function () { paint(0, 1); });
}
})();// CursorTrailPager — vanilla 판(08-pager-cursor-next-only-breadcrumb-trail) 의 React 포트.
// 전체 쪽수를 모르는 목록은 다음 커서만 받는다. 지나온 쪽은 점으로 남고, 점이 늘면 간격이 줄어 길이가 일정하다.
import { useEffect, useRef } from "react";
import styles from "./CursorTrailPager.module.scss";
type Box<T> = { current: T };
const END_AT = 9;
const TRAIL_W = 232;
const MAX_GAP = 36;
const GAP_RATE = 12;
const POP_MS = 420;
const CYCLE_MS = 2700;
const PRESS_START = 140;
const PRESS_EVERY = 180;
const BACK_AT = 1980;
const BACK_TO = 3;
const TONES = ["is-mint", "is-lilac", "is-pink", "is-sky"];
const cursorOf = (n: number): string => (Math.imul(n, 2654435761) >>> 0).toString(16).slice(-4);
export default function CursorTrailPager() {
const rootRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const trailRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const lineRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
const nextRef = useRef<HTMLButtonElement | null>(null) as Box<HTMLButtonElement | null>;
const cardRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const noRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
const cursorRef = useRef<HTMLSpanElement | null>(null) as Box<HTMLSpanElement | null>;
const srRef = useRef<HTMLParagraphElement | null>(null) as Box<HTMLParagraphElement | null>;
const dotRefs = useRef<Array<HTMLButtonElement | null>>([]) as Box<Array<HTMLButtonElement | null>>;
useEffect(() => {
const root = rootRef.current;
const trail = trailRef.current;
const line = lineRef.current;
const nextBtn = nextRef.current;
const card = cardRef.current;
const noEl = noRef.current;
const cursorEl = cursorRef.current;
const sr = srRef.current;
if (!root || !trail || !line || !nextBtn || !card || !noEl || !cursorEl || !sr) return;
const dots = dotRefs.current;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const born: number[] = [];
for (let i = 0; i < END_AT; i += 1) born.push(-1);
let demo = true;
let cur = 1;
let reached = 1;
let gapShown = 0;
let t0: number | null = null;
let lastTs: number | null = null;
let cycleBase = 0;
let pressed = 0;
let wentBack = false;
let raf = 0;
const perfNow = (): number => (t0 === null ? 0 : performance.now() - t0);
function sync(announce: boolean): void {
noEl!.textContent = String(cur);
cursorEl!.textContent = cursorOf(cur);
card!.className = [styles.pg__card, styles[TONES[cur % TONES.length]]].join(" ");
const atEnd = cur === END_AT;
nextBtn!.setAttribute("aria-disabled", atEnd ? "true" : "false");
nextBtn!.textContent = atEnd ? "끝" : "다음";
nextBtn!.className = [styles.pg__next, atEnd ? styles["is-end"] : ""].join(" ");
for (let s = 0; s < END_AT; s += 1) {
const el = dots[s];
if (!el) continue;
const seen = s + 1 <= reached;
el.className = [styles.pg__dot, seen ? styles["is-seen"] : "", s + 1 === cur ? styles["is-cur"] : ""].join(" ");
el.tabIndex = seen ? 0 : -1;
el.setAttribute("aria-hidden", seen ? "false" : "true");
if (s + 1 === cur) el.setAttribute("aria-current", "page"); else el.removeAttribute("aria-current");
}
if (announce) sr!.textContent = "현재 " + cur;
}
function goNext(byUser: boolean, now: number): void {
if (byUser) demo = false;
if (cur >= END_AT) return;
cur += 1;
if (cur > reached) { reached = cur; if (!reduce) born[cur - 1] = now; }
sync(byUser);
}
function goBack(p: number, byUser: boolean): void {
if (byUser) demo = false;
if (p < 1 || p > reached || p === cur) return;
cur = p;
sync(byUser);
}
const popScale = (age: number): number => (age < 0 || age >= POP_MS ? 1 : 1 - Math.exp(-age / 65) * Math.cos(age / 50));
function paint(now: number, dt: number): void {
const n = Math.max(2, reached);
const targetGap = Math.min(MAX_GAP, TRAIL_W / (n - 1));
gapShown = reduce ? targetGap : gapShown + (targetGap - gapShown) * (1 - Math.exp(-dt * GAP_RATE));
for (let s = 0; s < END_AT; s += 1) {
const el = dots[s];
if (!el) continue;
const seen = s + 1 <= reached;
const sc = seen ? popScale(now - born[s]) : 0;
if (seen && sc === 1) born[s] = -1;
el.style.opacity = seen ? "1" : "0";
el.style.transform = "translateX(" + (s * gapShown).toFixed(2) + "px) scale(" + sc.toFixed(3) + ")";
}
line!.style.transform = "scaleX(" + (((reached - 1) * gapShown) / TRAIL_W).toFixed(4) + ")";
}
const onNext = (): void => goNext(true, perfNow());
const onTrail = (e: MouseEvent): void => {
const idx = dots.indexOf(e.target as HTMLButtonElement);
if (idx >= 0) goBack(idx + 1, true);
};
const onKey = (e: KeyboardEvent): void => {
if (e.key === "ArrowRight") { e.preventDefault(); goNext(true, perfNow()); }
else if (e.key === "ArrowLeft") { e.preventDefault(); goBack(cur - 1, true); }
};
const onReduceClick = (): void => paint(0, 1);
nextBtn.addEventListener("click", onNext);
trail.addEventListener("click", onTrail);
root.addEventListener("keydown", onKey);
function tick(ts: number): void {
if (t0 === null) t0 = ts;
const now = ts - t0;
const dt = lastTs === null ? 0.016 : Math.min(0.05, (ts - lastTs) / 1000);
lastTs = ts;
if (demo) {
const t = now - cycleBase;
while (pressed < END_AT - 1 && t >= PRESS_START + pressed * PRESS_EVERY) { goNext(false, now); pressed += 1; }
if (!wentBack && t >= BACK_AT) { wentBack = true; goBack(BACK_TO, false); }
if (t >= CYCLE_MS) {
cycleBase = now; pressed = 0; wentBack = false;
cur = 1; reached = 1; gapShown = 0;
for (let q = 0; q < END_AT; q += 1) born[q] = -1;
sync(false);
}
}
paint(now, dt);
raf = requestAnimationFrame(tick);
}
sync(false);
if (reduce) {
demo = false;
paint(0, 1);
nextBtn.addEventListener("click", onReduceClick);
trail.addEventListener("click", onReduceClick);
} else {
raf = requestAnimationFrame(tick);
}
return () => {
cancelAnimationFrame(raf);
nextBtn.removeEventListener("click", onNext);
trail.removeEventListener("click", onTrail);
root.removeEventListener("keydown", onKey);
nextBtn.removeEventListener("click", onReduceClick);
trail.removeEventListener("click", onReduceClick);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.pg} ref={rootRef}>
<div className={[styles.pg__card, styles["is-mint"]].join(" ")} ref={cardRef} aria-hidden="true">
<b className={styles.pg__no} ref={noRef}>1</b>
<span className={styles.pg__cursor} ref={cursorRef}>0000</span>
</div>
<nav className={styles.pg__nav} aria-label="페이지 이동">
<button type="button" className={styles.pg__next} ref={nextRef}>다음</button>
</nav>
<div className={styles.pg__trail} ref={trailRef} aria-label="지나온 쪽">
<i className={styles.pg__line} ref={lineRef} aria-hidden="true"></i>
{Array.from({ length: END_AT }, (_, i) => (
<button type="button" className={styles.pg__dot} key={i} tabIndex={-1} aria-label={String(i + 1)} ref={(el: HTMLButtonElement | null) => { dotRefs.current[i] = el; }}></button>
))}
</div>
<p className={styles.pg__sr} ref={srRef} aria-live="polite"></p>
</div>
</main>
);
}08 · 다음만 있는 커서 페이저와 지나온 길 — 측정 누적면적 14.542% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function goNext(byUser, now) {
if (byUser) root.classList.remove('is-demo');
if (cur >= END_AT) return;
cur += 1;
if (cur > reached) { reached = cur; if (!reduce) born[cur - 1] = now; }
sync(now, byUser);
}
function goBack(page, byUser, now) {
if (byUser) root.classList.remove('is-demo');
if (page < 1 || page > reached || page === cur) return;
cur = page;
sync(now, byUser);
}
09남은 개수를 링으로 보여 주는 더 보기
링은 loaded / TOTAL_ITEMS 를 목표로 스프링처럼 차오르고, 가운데 숫자는 남은 개수를 지수 보간으로 굴려 내린다. 링은 불러온 비율이고 남은 개수는 숫자가 맡으며, 0 이 되면 숫자 대신 체크 표시와 완료 라벨이 나온다. 09 항목은 누적면적 11.6%·강도 평균 113.3·움직인 프레임 23/23 이다.

<main class="stage">
<div class="pg is-demo" id="pgRoot">
<button type="button" class="pg__more" id="pgMore" aria-describedby="pgSr">
<svg class="pg__ring" viewBox="0 0 96 96" aria-hidden="true" focusable="false">
<circle class="pg__track" cx="48" cy="48" r="42"></circle>
<circle class="pg__arc" id="pgArc" cx="48" cy="48" r="42"></circle>
<circle class="pg__sweep" id="pgSweep" cx="48" cy="48" r="42"></circle>
</svg>
<span class="pg__num" id="pgNum">36</span>
<span class="pg__label" id="pgLabel">더 보기</span>
</button>
<div class="pg__grid" id="pgGrid" aria-hidden="true"></div>
<p class="pg__sr" id="pgSr" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 링은 stroke-dashoffset 을 스프링으로 채우고, 남은 개수는 지수 보간으로 굴러 내려간다.
// 불러오는 동안(LOAD_MS) 호가 링을 돌고, 새로 들어온 칸은 순서대로 튄다.
$ring: 96px;
$cell: 18px;
$cell-gap: 4px;
@include stage($stage-ink);
.pg { display: flex; align-items: center; gap: $sp-4; color: $stage-paper; }
.pg__more {
position: relative;
box-sizing: border-box;
display: grid;
place-items: center;
align-content: center;
width: $ring;
height: $ring;
padding: 0;
border: 0;
border-radius: $r-pill;
background: rgba($stage-paper, .08);
color: $stage-paper;
font-family: inherit;
cursor: pointer;
}
.pg__more:focus-visible { outline: 2px solid $subject-sky; outline-offset: 2px; }
.pg__more.is-done { cursor: default; }
.pg__ring { position: absolute; inset: 0; width: $ring; height: $ring; }
.pg__ring circle { fill: none; stroke-width: 6; stroke-linecap: round; transform-origin: 48px 48px; }
.pg__track { stroke: rgba($stage-paper, .16); }
.pg__arc { stroke: $subject-lilac; transform: rotate(-90deg); }
.pg__more.is-done .pg__arc { stroke: $subject-mint; }
.pg__sweep { stroke: $subject-pink; opacity: 0; }
.pg__num { position: relative; font-size: 28px; line-height: 32px; font-weight: 700; color: $stage-paper; font-variant-numeric: tabular-nums; }
.pg__label { position: relative; font-size: 12px; line-height: 16px; font-weight: 600; color: rgba($stage-paper, .86); }
.pg__grid { display: grid; grid-template-columns: repeat(8, $cell); gap: $cell-gap; }
.pg__cell { display: block; width: $cell; height: $cell; border-radius: $r-xs; background: rgba($stage-paper, .1); }
.pg__cell.is-on.is-sky { background: $subject-sky; }
.pg__cell.is-on.is-mint { background: $subject-mint; }
.pg__cell.is-on.is-lilac { background: $subject-lilac; }
.pg__cell.is-on.is-pink { background: $subject-pink; }
.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 { gap: $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.pg__arc { transition: 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: flex;
align-items: center;
gap: 16px;
color: #ffffff;
}
.pg__more {
position: relative;
box-sizing: border-box;
display: grid;
place-items: center;
align-content: center;
width: 96px;
height: 96px;
padding: 0;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
color: #ffffff;
font-family: inherit;
cursor: pointer;
}
.pg__more:focus-visible {
outline: 2px solid #6ec8ff;
outline-offset: 2px;
}
.pg__more.is-done {
cursor: default;
}
.pg__ring {
position: absolute;
inset: 0;
width: 96px;
height: 96px;
}
.pg__ring circle {
fill: none;
stroke-width: 6;
stroke-linecap: round;
transform-origin: 48px 48px;
}
.pg__track {
stroke: rgba(255, 255, 255, 0.16);
}
.pg__arc {
stroke: #b9a0ff;
transform: rotate(-90deg);
}
.pg__more.is-done .pg__arc {
stroke: #4cd4a6;
}
.pg__sweep {
stroke: #ff9ebb;
opacity: 0;
}
.pg__num {
position: relative;
font-size: 28px;
line-height: 32px;
font-weight: 700;
color: #ffffff;
font-variant-numeric: tabular-nums;
}
.pg__label {
position: relative;
font-size: 12px;
line-height: 16px;
font-weight: 600;
color: rgba(255, 255, 255, 0.86);
}
.pg__grid {
display: grid;
grid-template-columns: repeat(8, 18px);
gap: 4px;
}
.pg__cell {
display: block;
width: 18px;
height: 18px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.1);
}
.pg__cell.is-on.is-sky {
background: #6ec8ff;
}
.pg__cell.is-on.is-mint {
background: #4cd4a6;
}
.pg__cell.is-on.is-lilac {
background: #b9a0ff;
}
.pg__cell.is-on.is-pink {
background: #ff9ebb;
}
.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 {
gap: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.pg__arc {
transition: none;
}
}(function () {
// 기법: 링은 SVG 원의 stroke-dashoffset 으로 채운다. 채움 비율은 스프링으로 따라가 목표를 살짝 넘었다 돌아오고,
// 남은 개수 숫자는 지수 보간으로 굴러 내려간다. 불러오는 동안(LOAD_MS)에는 호가 링을 한 바퀴씩 돈다.
var TOTAL_ITEMS = 48;
var PAGE_SIZE = 12;
var START_LOADED = 12;
var RING_R = 42;
var RING_STIFFNESS = 220;
var RING_DAMPING = 15;
var NUM_RATE = 12;
var LOAD_MS = 240;
var CELL_STAGGER = 24;
var POP_MS = 380;
var SUBSTEP = 1 / 240;
var CYCLE_MS = 1800;
var PRESS_AT = [100, 420, 740, 1060];
var TONES = ['is-sky', 'is-mint', 'is-lilac', 'is-pink'];
var CIRC = 2 * Math.PI * RING_R;
var root = document.getElementById('pgRoot');
var more = document.getElementById('pgMore');
var arc = document.getElementById('pgArc');
var sweep = document.getElementById('pgSweep');
var numEl = document.getElementById('pgNum');
var labelEl = document.getElementById('pgLabel');
var grid = document.getElementById('pgGrid');
var sr = document.getElementById('pgSr');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
arc.style.strokeDasharray = CIRC.toFixed(2);
sweep.style.strokeDasharray = (CIRC * 0.16).toFixed(2) + ' ' + CIRC.toFixed(2);
var cells = [];
var born = [];
for (var i = 0; i < TOTAL_ITEMS; i += 1) {
var c = document.createElement('i');
c.className = 'pg__cell';
grid.appendChild(c);
cells.push(c);
born.push(-1);
}
var loaded = 0;
var pending = null;
var frac = 0;
var fv = 0;
var numShown = TOTAL_ITEMS;
var t0 = null;
var lastTs = null;
var cycleBase = 0;
var pi = 0;
function perfNow() { return t0 === null ? 0 : performance.now() - t0; }
function paintCells() {
for (var s = 0; s < TOTAL_ITEMS; s += 1) {
cells[s].className = 'pg__cell' + (s < loaded ? ' is-on ' + TONES[Math.floor(s / PAGE_SIZE) % TONES.length] : '');
}
}
function finishLoad(now) {
var from = loaded;
loaded = Math.min(TOTAL_ITEMS, loaded + PAGE_SIZE);
for (var s = from; s < loaded; s += 1) born[s] = reduce ? -1 : now + (s - from) * CELL_STAGGER;
pending = null;
sweep.style.opacity = '0';
paintCells();
var remain = TOTAL_ITEMS - loaded;
var done = remain === 0;
labelEl.textContent = done ? '완료' : '더 보기';
more.setAttribute('aria-disabled', done ? 'true' : 'false');
more.className = 'pg__more' + (done ? ' is-done' : '');
sr.textContent = done ? '완료' : '남은 ' + remain + '개';
if (reduce) { frac = loaded / TOTAL_ITEMS; numShown = remain; paintStatic(); }
}
function paintStatic() {
arc.style.strokeDashoffset = (CIRC * (1 - Math.min(1, frac))).toFixed(2);
numEl.textContent = loaded >= TOTAL_ITEMS ? '✓' : String(Math.round(numShown));
}
function press(byUser, now) {
if (byUser) root.classList.remove('is-demo');
if (pending !== null || loaded >= TOTAL_ITEMS) return;
if (reduce) { finishLoad(now); return; }
pending = now;
sweep.style.opacity = '1';
}
more.addEventListener('click', function () { press(true, perfNow()); });
function tick(ts) {
if (t0 === null) t0 = ts;
var now = ts - t0;
var dt = lastTs === null ? 0.016 : Math.min(0.05, (ts - lastTs) / 1000);
lastTs = ts;
if (root.classList.contains('is-demo')) {
var t = now - cycleBase;
while (pi < PRESS_AT.length && t >= PRESS_AT[pi]) { press(false, now); pi += 1; }
if (t >= CYCLE_MS) {
cycleBase = now; pi = 0; loaded = START_LOADED; frac = START_LOADED / TOTAL_ITEMS; fv = 0;
numShown = TOTAL_ITEMS - START_LOADED;
for (var q = 0; q < TOTAL_ITEMS; q += 1) born[q] = -1;
paintCells();
labelEl.textContent = '더 보기';
more.setAttribute('aria-disabled', 'false');
more.className = 'pg__more';
}
}
if (pending !== null) {
var ang = ((now - pending) / LOAD_MS) * 360;
sweep.style.transform = 'rotate(' + (ang - 90).toFixed(1) + 'deg)';
if (now - pending >= LOAD_MS) finishLoad(now);
}
var target = loaded / TOTAL_ITEMS;
var left = dt;
while (left > 0) {
var h = Math.min(SUBSTEP, left);
fv += (-RING_STIFFNESS * (frac - target) - RING_DAMPING * fv) * h;
frac += fv * h;
left -= h;
}
numShown += ((TOTAL_ITEMS - loaded) - numShown) * (1 - Math.exp(-dt * NUM_RATE));
paintStatic();
for (var s = 0; s < TOTAL_ITEMS; s += 1) {
if (born[s] < 0) continue;
var age = now - born[s];
var sc = age < 0 ? 0 : age >= POP_MS ? 1 : 1 - Math.exp(-age / 70) * Math.cos(age / 55);
cells[s].style.transform = sc === 1 ? '' : 'scale(' + sc.toFixed(3) + ')';
if (sc === 1) born[s] = -1;
}
requestAnimationFrame(tick);
}
loaded = START_LOADED;
frac = START_LOADED / TOTAL_ITEMS;
numShown = TOTAL_ITEMS - START_LOADED;
sweep.style.opacity = '0';
paintCells();
paintStatic();
if (reduce) {
root.classList.remove('is-demo');
} else {
requestAnimationFrame(tick);
}
})();// LoadMoreRing — vanilla 판(09-pager-load-more-remaining-count-ring) 의 React 포트.
// 링은 stroke-dashoffset 을 스프링으로 채우고, 남은 개수는 지수 보간으로 굴러 내려간다.
import { useEffect, useRef } from "react";
import styles from "./LoadMoreRing.module.scss";
type Box<T> = { current: T };
const TOTAL_ITEMS = 48;
const PAGE_SIZE = 12;
const START_LOADED = 12;
const RING_R = 42;
const RING_STIFFNESS = 220;
const RING_DAMPING = 15;
const NUM_RATE = 12;
const LOAD_MS = 240;
const CELL_STAGGER = 24;
const POP_MS = 380;
const SUBSTEP = 1 / 240;
const CYCLE_MS = 1800;
const PRESS_AT = [100, 420, 740, 1060];
const TONES = ["is-sky", "is-mint", "is-lilac", "is-pink"];
const CIRC = 2 * Math.PI * RING_R;
export default function LoadMoreRing() {
const rootRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const moreRef = useRef<HTMLButtonElement | null>(null) as Box<HTMLButtonElement | null>;
const arcRef = useRef<SVGCircleElement | null>(null) as Box<SVGCircleElement | null>;
const sweepRef = useRef<SVGCircleElement | null>(null) as Box<SVGCircleElement | null>;
const numRef = useRef<HTMLSpanElement | null>(null) as Box<HTMLSpanElement | null>;
const labelRef = useRef<HTMLSpanElement | null>(null) as Box<HTMLSpanElement | null>;
const srRef = useRef<HTMLParagraphElement | null>(null) as Box<HTMLParagraphElement | null>;
const cellRefs = useRef<Array<HTMLElement | null>>([]) as Box<Array<HTMLElement | null>>;
useEffect(() => {
const more = moreRef.current;
const arc = arcRef.current;
const sweep = sweepRef.current;
const numEl = numRef.current;
const labelEl = labelRef.current;
const sr = srRef.current;
if (!more || !arc || !sweep || !numEl || !labelEl || !sr) return;
const cells = cellRefs.current;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
arc.style.strokeDasharray = CIRC.toFixed(2);
sweep.style.strokeDasharray = (CIRC * 0.16).toFixed(2) + " " + CIRC.toFixed(2);
const born: number[] = [];
for (let i = 0; i < TOTAL_ITEMS; i += 1) born.push(-1);
let demo = true;
let loaded = START_LOADED;
let pending: number | null = null;
let frac = START_LOADED / TOTAL_ITEMS;
let fv = 0;
let numShown = TOTAL_ITEMS - START_LOADED;
let t0: number | null = null;
let lastTs: number | null = null;
let cycleBase = 0;
let pi = 0;
let raf = 0;
const perfNow = (): number => (t0 === null ? 0 : performance.now() - t0);
function paintCells(): void {
for (let s = 0; s < TOTAL_ITEMS; s += 1) {
const el = cells[s];
if (!el) continue;
el.className = s < loaded
? [styles.pg__cell, styles["is-on"], styles[TONES[Math.floor(s / PAGE_SIZE) % TONES.length]]].join(" ")
: styles.pg__cell;
}
}
function paintStatic(): void {
arc!.style.strokeDashoffset = (CIRC * (1 - Math.min(1, frac))).toFixed(2);
numEl!.textContent = loaded >= TOTAL_ITEMS ? "✓" : String(Math.round(numShown));
}
function finishLoad(now: number): void {
const from = loaded;
loaded = Math.min(TOTAL_ITEMS, loaded + PAGE_SIZE);
for (let s = from; s < loaded; s += 1) born[s] = reduce ? -1 : now + (s - from) * CELL_STAGGER;
pending = null;
sweep!.style.opacity = "0";
paintCells();
const remain = TOTAL_ITEMS - loaded;
const done = remain === 0;
labelEl!.textContent = done ? "완료" : "더 보기";
more!.setAttribute("aria-disabled", done ? "true" : "false");
more!.className = [styles.pg__more, done ? styles["is-done"] : ""].join(" ");
sr!.textContent = done ? "완료" : "남은 " + remain + "개";
if (reduce) { frac = loaded / TOTAL_ITEMS; numShown = remain; paintStatic(); }
}
function press(byUser: boolean, now: number): void {
if (byUser) demo = false;
if (pending !== null || loaded >= TOTAL_ITEMS) return;
if (reduce) { finishLoad(now); return; }
pending = now;
sweep!.style.opacity = "1";
}
const onClick = (): void => press(true, perfNow());
more.addEventListener("click", onClick);
function tick(ts: number): void {
if (t0 === null) t0 = ts;
const now = ts - t0;
const dt = lastTs === null ? 0.016 : Math.min(0.05, (ts - lastTs) / 1000);
lastTs = ts;
if (demo) {
const t = now - cycleBase;
while (pi < PRESS_AT.length && t >= PRESS_AT[pi]) { press(false, now); pi += 1; }
if (t >= CYCLE_MS) {
cycleBase = now; pi = 0; loaded = START_LOADED; frac = START_LOADED / TOTAL_ITEMS; fv = 0;
numShown = TOTAL_ITEMS - START_LOADED;
for (let q = 0; q < TOTAL_ITEMS; q += 1) born[q] = -1;
paintCells();
labelEl!.textContent = "더 보기";
more!.setAttribute("aria-disabled", "false");
more!.className = styles.pg__more;
}
}
if (pending !== null) {
const ang = ((now - pending) / LOAD_MS) * 360;
sweep!.style.transform = "rotate(" + (ang - 90).toFixed(1) + "deg)";
if (now - pending >= LOAD_MS) finishLoad(now);
}
const target = loaded / TOTAL_ITEMS;
let left = dt;
while (left > 0) {
const h = Math.min(SUBSTEP, left);
fv += (-RING_STIFFNESS * (frac - target) - RING_DAMPING * fv) * h;
frac += fv * h;
left -= h;
}
numShown += (TOTAL_ITEMS - loaded - numShown) * (1 - Math.exp(-dt * NUM_RATE));
paintStatic();
for (let s = 0; s < TOTAL_ITEMS; s += 1) {
if (born[s] < 0) continue;
const age = now - born[s];
const sc = age < 0 ? 0 : age >= POP_MS ? 1 : 1 - Math.exp(-age / 70) * Math.cos(age / 55);
const el = cells[s];
if (el) el.style.transform = sc === 1 ? "" : "scale(" + sc.toFixed(3) + ")";
if (sc === 1) born[s] = -1;
}
raf = requestAnimationFrame(tick);
}
sweep.style.opacity = "0";
paintCells();
paintStatic();
if (reduce) demo = false; else raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
more.removeEventListener("click", onClick);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.pg} ref={rootRef}>
<button type="button" className={styles.pg__more} ref={moreRef}>
<svg className={styles.pg__ring} viewBox="0 0 96 96" aria-hidden="true" focusable="false">
<circle className={styles.pg__track} cx="48" cy="48" r="42"></circle>
<circle className={styles.pg__arc} cx="48" cy="48" r="42" ref={arcRef}></circle>
<circle className={styles.pg__sweep} cx="48" cy="48" r="42" ref={sweepRef}></circle>
</svg>
<span className={styles.pg__num} ref={numRef}>36</span>
<span className={styles.pg__label} ref={labelRef}>더 보기</span>
</button>
<div className={styles.pg__grid} aria-hidden="true">
{Array.from({ length: TOTAL_ITEMS }, (_, i) => (
<i className={styles.pg__cell} key={i} ref={(el: HTMLElement | null) => { cellRefs.current[i] = el; }}></i>
))}
</div>
<p className={styles.pg__sr} ref={srRef} aria-live="polite"></p>
</div>
</main>
);
}09 · 남은 개수를 링으로 보여 주는 더 보기 — 측정 누적면적 11.583% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
var target = loaded / TOTAL_ITEMS;
var left = dt;
while (left > 0) {
var h = Math.min(SUBSTEP, left);
fv += (-RING_STIFFNESS * (frac - target) - RING_DAMPING * fv) * h;
frac += fv * h;
left -= h;
}
numShown += ((TOTAL_ITEMS - loaded) - numShown) * (1 - Math.exp(-dt * NUM_RATE));
paintStatic();
값이 장면을 바꾸는 곳 — 창 크기·스프링 강성·점프 지연
세 상수가 줄의 모양과 이동의 느낌을 거의 다 정한다. 아래 표의 수치는 항목마다 렌더한 23개 프레임 간격에서 잰 값이다.
| 항목 | 누적면적 % | 강도 평균 | 움직인 프레임 |
|---|---|---|---|
| 01 고정 칸 창 | 5.6% | 71.5 | 20/23 |
| 02 스프링 알약 | 7.7% | 180.0 | 21/23 |
| 03 방향 전환 | 19.4% | 43.0 | 19/23 |
| 04 번호 궤적 | 14.0% | 160.1 | 22/23 |
| 05 끝 고무줄 | 8.2% | 75.7 | 22/23 |
| 06 점에서 막대 | 14.1% | 70.0 | 22/23 |
| 07 관성 레일 | 15.1% | 98.5 | 9/23 |
| 08 커서 길 | 14.5% | 72.4 | 18/23 |
| 09 링 더 보기 | 11.6% | 113.3 | 23/23 |
누적면적이 10% 아래인 01·02·05 는 번호 몇 개와 알약 하나가 움직일 뿐이라 컴포넌트 편에서는 정상이다. 07 이 9/23 으로 낮은 것은 손잡이가 정착해 멈추는 구간이 길기 때문이고, 끌고 있는 동안에는 움직인다.
칸 수가 줄 너비를 정한다
01 의 칸은 flex: 0 1 32px 라서 칸이 9개로 늘어도 320px 폭의 폰에서 줄어들며 한 줄에 들어간다. 말줄임 칸이 숨기는 쪽은 최소 2개이고, 그 칸을 누르면 숨은 구간의 가운데 쪽으로 이동한다.
감쇠비가 오버슛을 정한다
02 는 PILL_DAMPING 17 과 PILL_STIFFNESS 320 에서 감쇠비가 약 0.48 이라 이론 오버슛이 약 18% 이고, 09 는 감쇠 15 와 강성 220 에서 약 0.51 로 오버슛이 약 16% 다. 05 의 끝 버튼 스프링은 강성 260 에 감쇠 15 라서 감쇠비 약 0.47 이다.
점프 지연이 걸리는 시간을 정한다
04 의 총 시간은 |도착 - 출발| * 11 + 270 밀리초이므로 1쪽에서 40쪽은 약 0.7초이고 한 쪽 이동은 거의 순간이다. 03 도 같은 원리로 건너뛴 쪽수가 클수록 이동 시간이 한 쪽 이동의 290ms 에서 최대 560ms 까지 늘어난다.
어디서 깨지는가 — 쪽수 급증·빠른 연타·좁은 화면
칸 수를 5 아래로 줄이면
01 은 SLOT_COUNT 가 5 미만이면 inner 가 0 이하가 되어 현재 쪽이 창에서 빠지고, 짝수이면 가운데 구간이 좌우 비대칭이 된다. 칸 수는 홀수로 5 이상을 유지하는 것이 이 계산의 전제다.
점프 거리가 8쪽보다 짧으면
04 는 거리가 8쪽 미만이면 아홉 칩에 같은 번호가 겹친다. 1쪽에서 3쪽으로 가면 1 이 두 칸, 2 가 네 칸, 3 이 세 칸에 찍히므로, 짧은 이동에서는 궤적을 끄거나 칩 수를 줄이는 쪽이 낫다.
먼 점프와 손가락 선택
06 은 먼 점프에서 새 영역의 점이 투명에서 올라오느라 한두 프레임 비어 보이고, 점 자체는 터치 대상이 아니라 이전·다음 말고는 직접 이동 수단이 없다. 07 은 눈금 간격이 240/23 으로 약 10.4px 라서 20px 손잡이로 인접한 쪽을 손가락으로 고르기 어려워 키보드 조작을 함께 둔다. 아홉 개를 통째로 열어 직접 값을 바꿔 보려면 내려받은 압축 파일의 비밀번호 sj7cvb6m 을 입력해 푼다.
변형 세 가지와 접근성
| 항목 | 상수 | 값 | 느낌 |
|---|---|---|---|
| 01 | SLOT_COUNT | 7 → 9 | 가운데 구간이 번호 5개로 넓어져 17쪽에서 15·16·17·18·19 가 보이고, 앞쪽은 1쪽부터 7쪽까지 말줄임 없이 이어지며 칸 수는 어느 쪽에서나 같다 |
| 02 | PILL_STIFFNESS | 320 → 160 | 감쇠비가 약 0.48 에서 약 0.67 로 올라 오버슛 이론값이 약 18% 에서 약 6% 로 줄고 알약이 번호를 따라오는 시간은 길어진다 |
| 04 | STAGGER_STEP | 11 → 22 | 1쪽에서 40쪽으로 뛰는 궤적이 약 0.7초에서 약 1.1초로 길어지고 번호가 하나씩 또렷하게 켜진다 |
아홉 항목 모두 숨은 aria-live="polite" 문단이 현재 위치를 읽어 주고, 07 의 손잡이는 role="slider" 에 aria-valuenow 를 갱신한다. 01·02·03·04·05·06·08 은 nav 에 aria-label 을 달아 이동 영역임을 알리고, aria-current="page" 는 01·02·04·08 의 현재 쪽 칸에 붙는다. 03·05·06 은 쪽 번호 칸이 없어 현재 쪽을 문단과 라벨로 알린다. prefers-reduced-motion 이 reduce 이면 자동 시연을 끄고 최종 상태를 바로 그린다. 01 은 17쪽에 고정되고 02 의 알약은 즉시 도착하며 03 의 카드는 즉시 교체된다. 04 는 40쪽을 바로 보이고 05 는 끝에서 눌러도 밀리지 않으며 06 은 보간 없이 17쪽에 고정된다. 07 은 9쪽에 고정되고 끌면 바로 따라가서 놓으면 가까운 눈금에 붙는다. 08 은 팝과 간격 보간 없이 바로 바뀌고 09 는 호와 팝 없이 한 번에 갱신된다. 07 의 포인터 이벤트 는 마우스와 터치를 같은 경로로 받는다.
페이지네이션 디자인은 번호를 몇 개까지 보여 줘야 하는가?
이 편의 기본값은 칸 7개이고, 폭이 넉넉하면 9개로 늘려도 01 의 계산이 그대로 맞는다. 번호를 몇 개 보일지보다 중요한 것은 어느 쪽에서도 칸 수가 같다는 점이라서, 보이는 번호 수는 화면 폭에서 거꾸로 정하면 된다.
번호 줄 대신 다른 이동 방식이 필요하면 이웃 편을 이어 읽자. 끝없이 이어지는 목록은 무한 스크롤 로딩 UI 패턴 9종 — 스피너부터 가상 윈도우까지, 당겨서 갱신하는 동작은 당겨서 새로고침 9종 — 당긴 거리 값이 새로고침을 만드는 방식이 맡는다. 행이 펼쳐지는 표는 아코디언 테이블 9종 — 행이 펼쳐지고 접히는 아홉 가지 방식, 쪽 단위로 나뉘는 댓글 목록은 댓글 ui 9가지 — 대댓글 깊이부터 삭제 뒤 빈 자리까지 계산하는 목록을 열면 된다.
페이지네이션을 쓰기 전에 자주 묻는 질문
페이지네이션 구현에서 칸 수를 고정하면 무엇이 좋은가요?
현재 쪽이 어디든 줄이 같은 너비를 차지해서 쪽을 넘길 때 버튼 위치가 움직이지 않고, 눌러 둔 손가락이 다음 번호를 그대로 누를 수 있다. 01 의 windowSlots 한 함수만 있으면 되고 칸 수는 SLOT_COUNT 상수 하나로 바꾼다.
서버 커서 방식도 같은 컴포넌트로 만들 수 있나요?
총 쪽수를 모르면 번호 줄 대신 08 처럼 다음 버튼 하나와 지나온 점으로 만든다. 데모의 END_AT 는 서버가 끝을 알려 주는 신호를 흉내 낸 상수이고, 실제 커서 API 는 이전으로 돌아갈 커서를 따로 저장해야 한다.
더 보기 버튼과 번호 줄은 어떻게 고르나요?
사용자가 특정 쪽으로 돌아가야 하는 목록이면 번호 줄이, 끝까지 훑는 목록이면 09 같은 더 보기가 맞다. 09 는 링과 남은 개수를 함께 보여 줘서 얼마나 더 남았는지 알려 주지만 쪽을 바로 지정하지는 못한다.









