07残りスクロール距離を計算して滑らかに移動するキーボードページ移動
方向キーを押すと次ページ上端までの残り距離を計算し、その距離を複数フレームに分けて減速しながら移動するため一気に切り替わらない。
使いどころキーボードのみでの文書レビュー
easeScrollTonavigatepageTops

<main class="stage">
<div class="pvk is-demo" id="pvkRoot" tabindex="0" aria-label="文書ビュー、方向キーまたはPage Up・Downでページ移動">
<div class="pvk__doc" id="pvkDoc"></div>
<p class="pvk__badge" id="pvkBadge">1 / 5</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-orange);
.pvk {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-cream;
outline: none;
}
.pvk__doc {
position: absolute;
inset: 0;
overflow-y: auto;
padding: $sp-3;
box-sizing: border-box;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
.pvk__page {
background: #fff;
border-radius: $r-sm;
padding: $sp-3;
margin-bottom: $sp-3;
box-shadow: 0 2px 6px rgba(23, 20, 26, .1);
min-height: 140px;
box-sizing: border-box;
}
.pvk__title { height: 9px; border-radius: $r-xs; background: rgba(23, 20, 26, .4); margin-bottom: $sp-2; }
.pvk__line { height: 5px; border-radius: $r-xs; background: rgba(23, 20, 26, .18); margin-top: $sp-1; }
.pvk__fig { height: 34%; margin-top: $sp-2; border-radius: $r-sm; background: rgba(47, 109, 246, .22); }
.pvk__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: 700;
pointer-events: none;
}
@media (max-width: 340px) {
.pvk__page { min-height: 110px; }
}
@media (prefers-reduced-motion: reduce) {
.pvk__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: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pvk {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #fff7e6;
outline: none;
}
.pvk__doc {
position: absolute;
inset: 0;
overflow-y: auto;
padding: 12px;
box-sizing: border-box;
scrollbar-width: none;
}
.pvk__doc::-webkit-scrollbar {
display: none;
}
.pvk__page {
background: #fff;
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
box-shadow: 0 2px 6px rgba(23, 20, 26, 0.1);
min-height: 140px;
box-sizing: border-box;
}
.pvk__title {
height: 9px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.4);
margin-bottom: 8px;
}
.pvk__line {
height: 5px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.18);
margin-top: 4px;
}
.pvk__fig {
height: 34%;
margin-top: 8px;
border-radius: 8px;
background: rgba(47, 109, 246, 0.22);
}
.pvk__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: 700;
pointer-events: none;
}
@media (max-width: 340px) {
.pvk__page {
min-height: 110px;
}
}
@media (prefers-reduced-motion: reduce) {
.pvk__doc {
scroll-behavior: auto;
}
}(function () {
// 기법: 현재 스크롤 위치에서 목표 ページ 상단까지 남은 거리를 구하고, 그 거리를
// 이징(ease-out) 곡선에 따라 여러 프레임으로 나눠 옮긴다. jump 나 scrollIntoView 처럼
// 한 번에 끊기지 않고, 감속하며 도착한다.
var root = document.getElementById('pvkRoot');
var doc = document.getElementById('pvkDoc');
var badge = document.getElementById('pvkBadge');
var LOOP_MS = 2000;
var N = 5;
function seedRand(seed) { var s = seed >>> 0; return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; }; }
var rnd = seedRand(430071);
var pageTops = [];
for (var i = 0; i < N; i += 1) {
var page = document.createElement('div');
page.className = 'pvk__page';
var html = '<div class="pvk__title" style="width:' + (44 + rnd() * 18) + '%"></div>';
for (var j = 0; j < 5; j += 1) html += '<div class="pvk__line" style="width:' + (36 + rnd() * 56) + '%"></div>';
if (rnd() > 0.4) html += '<div class="pvk__fig"></div>';
page.innerHTML = html;
doc.appendChild(page);
}
Array.prototype.forEach.call(doc.children, function (el) { pageTops.push(el.offsetTop); });
var currentIndex = 0, scrollAnim = null;
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
function easeScrollTo(target, dur, nowT) { scrollAnim = { from: doc.scrollTop, to: target, start: nowT, dur: dur }; }
function navigate(dir, nowT) {
var idx = Math.max(0, Math.min(N - 1, currentIndex + dir));
if (idx === currentIndex) return;
currentIndex = idx;
easeScrollTo(pageTops[idx], 520, nowT);
badge.textContent = (idx + 1) + ' / ' + N;
}
var userActive = false;
function markActive() { if (!userActive) { userActive = true; root.classList.remove('is-demo'); } }
root.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown' || e.key === 'PageDown') { e.preventDefault(); markActive(); navigate(1, lastTs || 0); }
else if (e.key === 'ArrowUp' || e.key === 'PageUp') { e.preventDefault(); markActive(); navigate(-1, lastTs || 0); }
});
doc.addEventListener('wheel', function () { markActive(); }, { passive: true });
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { root.classList.remove('is-demo'); userActive = true; }
var fired = {};
var SCHEDULE = [
{ t: 150, run: function (t) { navigate(1, t); } },
{ t: 650, run: function (t) { navigate(1, t); } },
{ t: 1150, run: function (t) { navigate(-1, t); } },
{ t: 1650, run: function (t) { navigate(-1, t); } }
];
var lastTs = null, originTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
lastTs = ts;
if (!userActive) {
if (originTs === null) originTs = ts;
var elapsed = ts - originTs;
var loopIdx = Math.floor(elapsed / LOOP_MS);
var phase = elapsed % LOOP_MS;
SCHEDULE.forEach(function (a, i) {
var key = loopIdx + ':' + i;
if (phase >= a.t && !fired[key]) { fired[key] = true; a.run(ts); }
});
}
if (scrollAnim) {
var p = Math.max(0, Math.min(1, (ts - scrollAnim.start) / scrollAnim.dur));
doc.scrollTop = scrollAnim.from + (scrollAnim.to - scrollAnim.from) * easeOutCubic(p);
if (p >= 1) scrollAnim = null;
}
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./PdfViewerKeyboardPageEaseScroll.module.scss";
type KeyEvt = { key: string; preventDefault(): void };
export default function PdfViewerKeyboardPageEaseScroll() {
const rootRef = useRef<HTMLDivElement>(null);
const docRef = useRef<HTMLDivElement>(null);
const badgeRef = useRef<HTMLParagraphElement>(null);
useEffect(() => {
const root = rootRef.current, doc = docRef.current, badge = badgeRef.current;
if (!root || !doc || !badge) return;
const LOOP_MS = 2000;
const N = 5;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rnd = seedRand(430071);
const pageTops: number[] = [];
for (let i = 0; i < N; i += 1) {
const page = document.createElement("div");
page.className = styles.page;
let html = '<div class="' + styles.title + '" style="width:' + (44 + rnd() * 18) + '%"></div>';
for (let j = 0; j < 5; j += 1) html += '<div class="' + styles.line + '" style="width:' + (36 + rnd() * 56) + '%"></div>';
if (rnd() > 0.4) html += '<div class="' + styles.fig + '"></div>';
page.innerHTML = html;
doc.appendChild(page);
}
Array.prototype.forEach.call(doc.children, (el: HTMLElement) => { pageTops.push(el.offsetTop); });
let currentIndex = 0;
let scrollAnim: { from: number; to: number; start: number; dur: number } | null = null;
function easeOutCubic(t: number) { return 1 - Math.pow(1 - t, 3); }
function easeScrollTo(target: number, dur: number, nowT: number) { scrollAnim = { from: doc!.scrollTop, to: target, start: nowT, dur }; }
function navigate(dir: number, nowT: number) {
const idx = Math.max(0, Math.min(N - 1, currentIndex + dir));
if (idx === currentIndex) return;
currentIndex = idx;
easeScrollTo(pageTops[idx], 520, nowT);
badge!.textContent = (idx + 1) + " / " + N;
}
let userActive = false;
function markActive() { if (!userActive) { userActive = true; root!.classList.remove("is-demo"); } }
function onKeydown(e: KeyEvt) {
if (e.key === "ArrowDown" || e.key === "PageDown") { e.preventDefault(); markActive(); navigate(1, lastTs || 0); }
else if (e.key === "ArrowUp" || e.key === "PageUp") { e.preventDefault(); markActive(); navigate(-1, lastTs || 0); }
}
function onWheel() { markActive(); }
root.addEventListener("keydown", onKeydown as unknown as EventListener);
doc.addEventListener("wheel", onWheel, { passive: true });
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) { root.classList.remove("is-demo"); userActive = true; }
const fired: Record<string, boolean> = {};
const SCHEDULE = [
{ t: 150, run: (t: number) => { navigate(1, t); } },
{ t: 650, run: (t: number) => { navigate(1, t); } },
{ t: 1150, run: (t: number) => { navigate(-1, t); } },
{ t: 1650, run: (t: number) => { navigate(-1, t); } }
];
let lastTs: number | null = null, originTs: number | null = null, raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
lastTs = ts;
if (!userActive) {
if (originTs === null) originTs = ts;
const elapsed = ts - originTs;
const loopIdx = Math.floor(elapsed / LOOP_MS);
const phase = elapsed % LOOP_MS;
SCHEDULE.forEach((a, i) => {
const key = loopIdx + ":" + i;
if (phase >= a.t && !fired[key]) { fired[key] = true; a.run(ts); }
});
}
if (scrollAnim) {
const p = Math.max(0, Math.min(1, (ts - scrollAnim.start) / scrollAnim.dur));
doc!.scrollTop = scrollAnim.from + (scrollAnim.to - scrollAnim.from) * easeOutCubic(p);
if (p >= 1) scrollAnim = null;
}
raf = requestAnimationFrame(tick);
}
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
root.removeEventListener("keydown", onKeydown as unknown as EventListener);
doc.removeEventListener("wheel", onWheel);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.pvk} is-demo`} ref={rootRef} tabIndex={0} aria-label="문서 보기, 방향키나 Page Up·Down 으로 페이지 이동">
<div className={styles.doc} ref={docRef} />
<p className={styles.badge} ref={badgeRef}>1 / 5</p>
</div>
</main>
);
}07 残りスクロール距離を計算して滑らかに移動するキーボードページ移動 — 計測 累積面積 67.574% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · pdf 表示 js 実装 9パターン — スクロールでページを計算するビューア
このデモが属する記事
pdf 表示 js 実装 9パターン — スクロールでページを計算するビューア「pdf 表示 js」の作り方は9通りあります。スクロール値がページ番号を作るのか、ページ番号がスクロール値を作るのか — 02番の拡大中心はポインター座標で、04番の次ページは指を離す速度で、08番のダウンロードリングは受信バイト数で答え この記事のデモをすべて見る


