07Keyboard Page Navigation That Calculates the Remaining Distance and Eases Into It
Pressing an arrow key calculates the remaining distance to the top of the next page and moves through it over several frames with easing, so the jump never lands abruptly.
Best forReviewing a document with only a keyboard
easeScrollTonavigatepageTops

<main class="stage">
<div class="pvk is-demo" id="pvkRoot" tabindex="0" aria-label="Document view — move pages with arrow keys or 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 () {
// 기법: 현재 스크롤 위치에서 목표 Page 상단까지 남은 거리를 구하고, 그 거리를
// 이징(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 Keyboard Page Navigation That Calculates the Remaining Distance and Eases Into It — Measured area 67.574% · 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
Embeds07 Precipitation bars that grow, and a compass that points into the windWeather Widget Design — 9 Widgets, No Library
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