01本文スクロール比率から現在ページを計算するサムネイルレール同期
本文の各ページとビューポートの重なり比率を毎フレーム計算して最も多く見えているページを探し、サムネイルレールでそのページ枠が自動でハイライトされ中央へスクロールする。
使いどころ長い契約書ドキュメントビューアのサイドバー
RAIL_LERPcomputeCurrentvisibleRatio
<main class="stage">
<div class="pvt is-demo" id="pvtRoot" tabindex="0">
<div class="pvt__rail" id="pvtRail"><div class="pvt__track" id="pvtTrack"></div></div>
<div class="pvt__doc" id="pvtDoc"></div>
<p class="pvt__badge" id="pvtBadge">1 / 6</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
$doc-bg: $subject-cream;
$bar: rgba(23, 20, 26, .16);
$bar-strong: rgba(23, 20, 26, .32);
.pvt {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
display: flex;
flex-direction: column;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $doc-bg;
}
.pvt__rail {
flex: 0 0 auto;
height: 17%;
overflow: hidden;
background: $stage-ink;
padding: $sp-1 0;
box-sizing: border-box;
}
.pvt__track {
display: flex;
gap: $sp-2;
padding: 0 $sp-3;
height: 100%;
will-change: transform;
}
.pvt__thumb {
flex: 0 0 auto;
width: 15%;
min-width: 34px;
height: 100%;
border: 2px solid rgba(255, 255, 255, .18);
border-radius: $r-sm;
background: $doc-bg;
padding: $sp-1;
box-sizing: border-box;
cursor: pointer;
&--active { border-color: $subject-blue-light; }
}
.pvt__miniTitle { height: 3px; border-radius: $r-xs; background: $bar-strong; margin-bottom: 4px; }
.pvt__miniLine { height: 2px; border-radius: $r-xs; background: $bar; margin-top: 4px; }
.pvt__doc {
flex: 1 1 auto;
overflow-y: auto;
padding: $sp-3;
box-sizing: border-box;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
.doc__page {
background: #fff;
border-radius: $r-sm;
padding: $sp-2 $sp-3;
margin-bottom: $sp-2;
box-shadow: 0 2px 6px rgba(23, 20, 26, .1);
}
.doc__title { height: 8px; border-radius: $r-xs; background: $bar-strong; margin-bottom: $sp-1; }
.doc__line { height: 5px; border-radius: $r-xs; background: $bar; margin-top: 4px; }
.doc__fig { height: 30px; border-radius: $r-sm; margin-top: $sp-1; background: rgba(47, 109, 246, .18); }
.pvt__badge {
position: absolute;
right: $sp-2;
bottom: $sp-2;
margin: 0;
padding: $sp-1 $sp-2;
border-radius: $r-pill;
background: rgba(23, 20, 26, .78);
color: #fff;
font-size: 11px;
font-weight: 600;
pointer-events: none;
}
@media (max-width: 340px) {
.pvt__badge { font-size: 9px; padding: 4px $sp-2; }
.doc__page { padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.pvt__doc { scroll-behavior: auto; }
}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);
}
.pvt {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
display: flex;
flex-direction: column;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #fff7e6;
}
.pvt__rail {
flex: 0 0 auto;
height: 17%;
overflow: hidden;
background: #17141a;
padding: 4px 0;
box-sizing: border-box;
}
.pvt__track {
display: flex;
gap: 8px;
padding: 0 12px;
height: 100%;
will-change: transform;
}
.pvt__thumb {
flex: 0 0 auto;
width: 15%;
min-width: 34px;
height: 100%;
border: 2px solid rgba(255, 255, 255, 0.18);
border-radius: 8px;
background: #fff7e6;
padding: 4px;
box-sizing: border-box;
cursor: pointer;
}
.pvt__thumb--active {
border-color: #5b8cf8;
}
.pvt__miniTitle {
height: 3px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.32);
margin-bottom: 4px;
}
.pvt__miniLine {
height: 2px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.16);
margin-top: 4px;
}
.pvt__doc {
flex: 1 1 auto;
overflow-y: auto;
padding: 12px;
box-sizing: border-box;
scrollbar-width: none;
}
.pvt__doc::-webkit-scrollbar {
display: none;
}
.doc__page {
background: #fff;
border-radius: 8px;
padding: 8px 12px;
margin-bottom: 8px;
box-shadow: 0 2px 6px rgba(23, 20, 26, 0.1);
}
.doc__title {
height: 8px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.32);
margin-bottom: 4px;
}
.doc__line {
height: 5px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.16);
margin-top: 4px;
}
.doc__fig {
height: 30px;
border-radius: 8px;
margin-top: 4px;
background: rgba(47, 109, 246, 0.18);
}
.pvt__badge {
position: absolute;
right: 8px;
bottom: 8px;
margin: 0;
padding: 4px 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.78);
color: #fff;
font-size: 11px;
font-weight: 600;
pointer-events: none;
}
@media (max-width: 340px) {
.pvt__badge {
font-size: 9px;
padding: 4px 8px;
}
.doc__page {
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.pvt__doc {
scroll-behavior: auto;
}
}(function () {
// 기법: 각 ページ 요소가 문서 뷰포트와 겹치는 비율을 매 프레임 getBoundingClientRect 로 계산해
// 가장 많이 보이는 페이지를 찾는다(IntersectionObserver 대신 직접 계산). 그 페이지의 썸네일
// 위치를 레일 가운데로 lerp(관성 보간)로 이동시켜, 활성 칸이 부드럽게 따라온다.
var root = document.getElementById('pvtRoot');
var rail = document.getElementById('pvtRail');
var track = document.getElementById('pvtTrack');
var doc = document.getElementById('pvtDoc');
var badge = document.getElementById('pvtBadge');
var PAGE_COUNT = 6;
var RAIL_LERP = 0.18;
var LOOP_MS = 2000;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function makePages(seed, n) {
var rnd = seedRand(seed), pages = [];
for (var i = 0; i < n; i += 1) {
var lineCount = 4 + Math.floor(rnd() * 3), lines = [];
for (var j = 0; j < lineCount; j += 1) lines.push(46 + rnd() * 44);
pages.push({ n: i + 1, titleW: 34 + rnd() * 26, lines: lines, hasFig: rnd() < 0.4 });
}
return pages;
}
var pages = makePages(430011, PAGE_COUNT);
function pageHTML(p, mini) {
var tcls = mini ? 'pvt__miniTitle' : 'doc__title';
var lcls = mini ? 'pvt__miniLine' : 'doc__line';
var html = '<div class="' + tcls + '" style="width:' + p.titleW + '%"></div>';
var lines = mini ? p.lines.slice(0, 2) : p.lines;
for (var i = 0; i < lines.length; i += 1) html += '<div class="' + lcls + '" style="width:' + lines[i] + '%"></div>';
if (!mini && p.hasFig) html += '<div class="doc__fig"></div>';
return html;
}
var docEls = [], thumbEls = [];
for (var i = 0; i < pages.length; i += 1) {
var page = document.createElement('div');
page.className = 'doc__page';
page.innerHTML = pageHTML(pages[i], false);
doc.appendChild(page);
docEls.push(page);
var thumb = document.createElement('button');
thumb.type = 'button';
thumb.className = 'pvt__thumb';
thumb.setAttribute('aria-label', 'ページ ' + pages[i].n + 'へ移動');
thumb.innerHTML = pageHTML(pages[i], true);
thumb.addEventListener('click', (function (idx) {
return function () { userActive = true; root.classList.remove('is-demo'); doc.scrollTop = docEls[idx].offsetTop - 6; };
})(i));
track.appendChild(thumb);
thumbEls.push(thumb);
}
var current = 0;
function visibleRatio(el, viewTop, viewH) {
var top = el.offsetTop, h = el.offsetHeight;
var lo = Math.max(top, viewTop), hi = Math.min(top + h, viewTop + viewH);
return Math.max(0, hi - lo) / h;
}
function computeCurrent() {
var viewTop = doc.scrollTop, viewH = doc.clientHeight, best = 0, bestR = -1;
for (var i = 0; i < docEls.length; i += 1) {
var r = visibleRatio(docEls[i], viewTop, viewH);
if (r > bestR) { bestR = r; best = i; }
}
if (best !== current) {
current = best;
for (var j = 0; j < thumbEls.length; j += 1) thumbEls[j].classList.toggle('pvt__thumb--active', j === current);
badge.textContent = (current + 1) + ' / ' + pages.length;
}
}
var userActive = false;
function markActive() { if (!userActive) { userActive = true; root.classList.remove('is-demo'); } }
doc.addEventListener('scroll', computeCurrent);
doc.addEventListener('wheel', function () { markActive(); }, { passive: true });
doc.addEventListener('pointerdown', function () { markActive(); });
root.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'PageDown' || e.key === 'PageUp') {
e.preventDefault(); markActive();
var next = e.key === 'ArrowDown' || e.key === 'PageDown' ? Math.min(current + 1, docEls.length - 1) : Math.max(current - 1, 0);
doc.scrollTop = docEls[next].offsetTop - 6;
computeCurrent();
}
});
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { root.classList.remove('is-demo'); userActive = true; }
function railTarget() {
var t = thumbEls[current];
return (rail.clientWidth / 2) - (t.offsetLeft + t.offsetWidth / 2);
}
function clampRail(v) {
var min = Math.min(0, rail.clientWidth - track.scrollWidth);
return Math.min(0, Math.max(min, v));
}
function autoScroll(elapsed) {
var max = doc.scrollHeight - doc.clientHeight;
var phase = (elapsed % LOOP_MS) / LOOP_MS;
var wave = phase < 0.5 ? phase * 2 : (1 - phase) * 2;
var eased = wave * wave * (3 - 2 * wave);
doc.scrollTop = max * eased;
computeCurrent();
}
var railOffset = 0, lastTs = null, elapsed = 0;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!userActive) { elapsed += dt; autoScroll(elapsed); }
var target = clampRail(railTarget());
railOffset += (target - railOffset) * (userActive ? 1 : RAIL_LERP);
track.style.transform = 'translateX(' + railOffset.toFixed(1) + 'px)';
requestAnimationFrame(tick);
}
computeCurrent();
thumbEls[0].classList.add('pvt__thumb--active');
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./PdfViewerThumbnailRailSyncScroll.module.scss";
type Page = { n: number; titleW: number; lines: number[]; hasFig: boolean };
type KeyEvt = { key: string; preventDefault(): void };
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function makePages(seed: number, n: number): Page[] {
const rnd = seedRand(seed), pages: Page[] = [];
for (let i = 0; i < n; i += 1) {
const lineCount = 4 + Math.floor(rnd() * 3), lines: number[] = [];
for (let j = 0; j < lineCount; j += 1) lines.push(46 + rnd() * 44);
pages.push({ n: i + 1, titleW: 34 + rnd() * 26, lines, hasFig: rnd() < 0.4 });
}
return pages;
}
export default function PdfViewerThumbnailRailSyncScroll() {
const rootRef = useRef<HTMLDivElement>(null);
const railRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const docRef = useRef<HTMLDivElement>(null);
const badgeRef = useRef<HTMLParagraphElement>(null);
useEffect(() => {
const root = rootRef.current, rail = railRef.current, track = trackRef.current;
const doc = docRef.current, badge = badgeRef.current;
if (!root || !rail || !track || !doc || !badge) return;
const PAGE_COUNT = 6;
const RAIL_LERP = 0.18;
const LOOP_MS = 2000;
const pages = makePages(430011, PAGE_COUNT);
function pageHTML(p: Page, mini: boolean) {
const tcls = mini ? styles.miniTitle : styles.docTitle;
const lcls = mini ? styles.miniLine : styles.docLine;
let html = '<div class="' + tcls + '" style="width:' + p.titleW + '%"></div>';
const lines = mini ? p.lines.slice(0, 2) : p.lines;
for (let i = 0; i < lines.length; i += 1) html += '<div class="' + lcls + '" style="width:' + lines[i] + '%"></div>';
if (!mini && p.hasFig) html += '<div class="' + styles.docFig + '"></div>';
return html;
}
const docEls: HTMLDivElement[] = [], thumbEls: HTMLButtonElement[] = [];
let userActive = false;
for (let i = 0; i < pages.length; i += 1) {
const page = document.createElement("div");
page.className = styles.docPage;
page.innerHTML = pageHTML(pages[i], false);
doc.appendChild(page);
docEls.push(page);
const thumb = document.createElement("button");
thumb.type = "button";
thumb.className = styles.thumb;
thumb.setAttribute("aria-label", "페이지 " + pages[i].n + "로 이동");
thumb.innerHTML = pageHTML(pages[i], true);
thumb.addEventListener("click", (function (idx) {
return function () { userActive = true; root.classList.remove("is-demo"); doc.scrollTop = docEls[idx].offsetTop - 6; };
})(i));
track.appendChild(thumb);
thumbEls.push(thumb);
}
let current = 0;
function visibleRatio(el: HTMLElement, viewTop: number, viewH: number) {
const top = el.offsetTop, h = el.offsetHeight;
const lo = Math.max(top, viewTop), hi = Math.min(top + h, viewTop + viewH);
return Math.max(0, hi - lo) / h;
}
function computeCurrent() {
const viewTop = doc!.scrollTop, viewH = doc!.clientHeight;
let best = 0, bestR = -1;
for (let i = 0; i < docEls.length; i += 1) {
const r = visibleRatio(docEls[i], viewTop, viewH);
if (r > bestR) { bestR = r; best = i; }
}
if (best !== current) {
current = best;
for (let j = 0; j < thumbEls.length; j += 1) thumbEls[j].classList.toggle(styles.thumbActive, j === current);
badge!.textContent = (current + 1) + " / " + pages.length;
}
}
doc.addEventListener("scroll", computeCurrent);
const markActive = () => { userActive = true; root.classList.remove("is-demo"); };
doc.addEventListener("wheel", markActive, { passive: true });
doc.addEventListener("pointerdown", markActive);
const onKeydown = (e: KeyEvt) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "PageDown" || e.key === "PageUp") {
e.preventDefault(); markActive();
const next = (e.key === "ArrowDown" || e.key === "PageDown")
? Math.min(current + 1, docEls.length - 1) : Math.max(current - 1, 0);
doc!.scrollTop = docEls[next].offsetTop - 6;
computeCurrent();
}
};
root.addEventListener("keydown", onKeydown as unknown as EventListener);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) { root.classList.remove("is-demo"); userActive = true; }
function railTarget() {
const t = thumbEls[current];
return (rail!.clientWidth / 2) - (t.offsetLeft + t.offsetWidth / 2);
}
function clampRail(v: number) {
const min = Math.min(0, rail!.clientWidth - track!.scrollWidth);
return Math.min(0, Math.max(min, v));
}
function autoScroll(elapsed: number) {
const max = doc!.scrollHeight - doc!.clientHeight;
const phase = (elapsed % LOOP_MS) / LOOP_MS;
const wave = phase < 0.5 ? phase * 2 : (1 - phase) * 2;
const eased = wave * wave * (3 - 2 * wave);
doc!.scrollTop = max * eased;
computeCurrent();
}
let railOffset = 0, lastTs: number | null = null, elapsed = 0, raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!userActive) { elapsed += dt; autoScroll(elapsed); }
const target = clampRail(railTarget());
railOffset += (target - railOffset) * (userActive ? 1 : RAIL_LERP);
track!.style.transform = "translateX(" + railOffset.toFixed(1) + "px)";
raf = requestAnimationFrame(tick);
}
computeCurrent();
thumbEls[0].classList.add(styles.thumbActive);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
doc.removeEventListener("scroll", computeCurrent);
doc.removeEventListener("wheel", markActive);
doc.removeEventListener("pointerdown", markActive);
root.removeEventListener("keydown", onKeydown as unknown as EventListener);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.pvt} is-demo`} ref={rootRef} tabIndex={0}>
<div className={styles.rail} ref={railRef}><div className={styles.track} ref={trackRef} /></div>
<div className={styles.doc} ref={docRef} />
<p className={styles.badge} ref={badgeRef}>1 / 6</p>
</div>
</main>
);
}01 本文スクロール比率から現在ページを計算するサムネイルレール同期 — 計測 累積面積 59.608% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · pdf 表示 js 実装 9パターン — スクロールでページを計算するビューア
このデモが属する記事
pdf 表示 js 実装 9パターン — スクロールでページを計算するビューア「pdf 表示 js」の作り方は9通りあります。スクロール値がページ番号を作るのか、ページ番号がスクロール値を作るのか — 02番の拡大中心はポインター座標で、04番の次ページは指を離す速度で、08番のダウンロードリングは受信バイト数で答え この記事のデモをすべて見る


