03向きを知るコンテンツ切り替え
次のページへ進むと新しい内容が右から、前のページへ戻ると左から入り、飛んだページ数が大きいほど移動幅とぼかしが大きくなる。
使いどころリスト本体が切り替わる瞬間
SLIDE_PER_PAGESLIDE_MAXBLUR_PER_PAGEMS_PER_PAGE

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


