01Thumbnail Rail That Syncs to the Current Page by Calculating Scroll Ratio
The overlap ratio between each page and the viewport is calculated every frame to find the page shown the most, and the thumbnail rail highlights and auto-scrolls that page into the center.
Best forSidebar of a long-contract document viewer
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 () {
// 기법: 각 Page 요소가 문서 뷰포트와 겹치는 비율을 매 프레임 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', 'Page ' + pages[i].n + ' thumbnail');
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 Thumbnail Rail That Syncs to the Current Page by Calculating Scroll Ratio — Measured area 59.608% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · PDF Viewer Implementation: 9 UI Patterns
The post this demo lives in
PDF Viewer Implementation: 9 UI Patternspdf viewer implementation: Does scroll decide the page number, or the reverse? Item 02's zoom center uses pointer coordi See all demos in this post
If you like this effect - similar demos
Embeds01 YouTube responsive embed — a video frame that recomputes its ratio from the available width9 YouTube Embed Code UI Patterns (Copy-Paste)
Embeds02 Tabs that swap the same example per language9 Code Block UI Patterns — No Highlighter Library
Embeds04 Social mention cards that roll in from the side9 Activity Feed UI Widgets — Sidebar-Ready Lists