06ドットが棒に伸びるモバイル用ページャー
ドットの列で現在のものだけが長い棒に伸び、隣のドットは段階的に小さくなる。ページ数が多くても見えるドットは七つに固定される。
使いどころ狭い画面のページ表示
DOT_REACHBAR_WDOT_WMORPH_RATE

<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 オリジナル · SCSS と React は zip に同梱 · ページネーション デザイン 9パターン — 窓の計算からばねのピルまで
このデモが属する記事
ページネーション デザイン 9パターン — 窓の計算からばねのピルまでページネーションは番号を十個並べれば終わりに見えるが、40ページ付近で枠の数が揺れ現在地が見えなくなる — 01番がその二か所を塞ぐ。ページネーション デザインは、長い一覧をページに分けて現在地と行き先を示す番号列の見せ方です。 この記事のデモをすべて見る


