08다음만 있는 커서 페이저와 지나온 길
전체 쪽수를 모를 때 다음 버튼만 두고, 지나온 쪽을 작은 이어진 점으로 남겨 돌아갈 곳을 보여 주며 새 쪽이 붙을 때마다 점이 하나씩 자란다.
쓰임끝이 정해지지 않은 피드
END_ATTRAIL_WMAX_GAPPOP_MS

<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 포함 · 페이지네이션 ui 9가지 — 창 계산부터 스프링 알약까지 고르는 쪽 이동
이 데모가 속한 글
페이지네이션 ui 9가지 — 창 계산부터 스프링 알약까지 고르는 쪽 이동페이지네이션은 번호 열 개만 늘어놓으면 끝날 것 같지만, 40쪽쯤 되면 칸 수가 흔들리고 현재 위치가 눈에서 사라진다 — 01번이 그 두 군데를 막는다. 페이지네이션 ui 는 긴 목록을 쪽으로 나눠 지금 어디이고 어 이 글의 데모 전부 보기


