09Hero Scene Combining Several Calculations Into One Viewer Toolbar
Item 01's thumbnail sync, item 02's pointer focal zoom, and item 08's download progress ring all run together on one toolbar where a document is browsed, zoomed, and downloaded.
Best forEmbedded widget for a document collaboration tool
HERO_ZOOM_STEPHERO_RAIL_LERPHERO_TOTAL_BYTES

<main class="stage">
<div class="pht is-demo" id="phtRoot" tabindex="0" aria-label="A scene combining document skimming, zoom, and download — arrow keys move pages, +/- zooms">
<div class="pht__toolbar">
<div class="pht__rail" id="phtRail">
<div class="pht__railTrack" id="phtRailTrack"></div>
</div>
<button class="pht__ringBtn" id="phtRingBtn" type="button" aria-label="Download">
<svg class="pht__ring" viewBox="0 0 40 40" width="26" height="26" aria-hidden="true">
<circle class="pht__ringTrack" cx="20" cy="20" r="15"></circle>
<circle class="pht__ringFill" id="phtRingFill" cx="20" cy="20" r="15" transform="rotate(-90 20 20)"></circle>
<line class="pht__ringCheck" id="phtCheckA" x1="12" y1="21" x2="17" y2="27"></line>
<line class="pht__ringCheck" id="phtCheckB" x1="12" y1="21" x2="28" y2="12"></line>
</svg>
</button>
</div>
<div class="pht__stage" id="phtStage">
<div class="pht__doc" id="phtDoc">
<div class="pht__docInner" id="phtDocInner"></div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.pht {
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: $subject-cream;
outline: none;
}
.pht__toolbar {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-2;
background: $subject-ink;
}
.pht__rail {
flex: 1 1 auto;
overflow: hidden;
min-width: 0;
}
.pht__railTrack {
display: flex;
gap: $sp-2;
will-change: transform;
}
.pht__thumb {
flex: 0 0 auto;
width: 40px;
height: 52px;
border: 2px solid transparent;
border-radius: $r-sm;
background: $subject-cream;
padding: 4px;
box-sizing: border-box;
cursor: pointer;
&--active { border-color: $subject-blue-light; }
}
.pht__miniTitle { height: 4px; border-radius: $r-xs; background: rgba(23, 20, 26, .4); margin-bottom: 4px; }
.pht__miniLine { height: 2px; border-radius: $r-xs; background: rgba(23, 20, 26, .22); margin-top: 4px; }
.pht__miniFig { height: 28%; margin-top: 4px; border-radius: $r-xs; background: rgba(47, 109, 246, .22); }
.pht__ringBtn {
flex: 0 0 auto;
border: none;
border-radius: $r-pill;
width: 34px;
height: 34px;
display: grid;
place-items: center;
background: rgba(255, 255, 255, .12);
cursor: pointer;
}
.pht__ring { display: block; }
.pht__ringTrack { fill: none; stroke: rgba(255, 255, 255, .25); stroke-width: 4; }
.pht__ringFill { fill: none; stroke: $subject-blue-light; stroke-width: 4; stroke-linecap: round; transition: opacity $dur-base ease; }
.pht__ringCheck { stroke: #fff; stroke-width: 4; stroke-linecap: round; fill: none; opacity: 0; transition: opacity $dur-base ease; }
.pht--done .pht__ringFill { opacity: 0; }
.pht--done .pht__ringCheck { opacity: 1; }
.pht__stage {
flex: 1 1 auto;
position: relative;
overflow: hidden;
}
.pht__doc {
position: absolute;
inset: 0;
overflow-y: auto;
padding: $sp-3;
box-sizing: border-box;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
.pht__docInner { will-change: transform; }
.pht__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: 120px;
box-sizing: border-box;
}
.pht__docTitle { height: 8px; border-radius: $r-xs; background: rgba(23, 20, 26, .4); margin-bottom: $sp-2; }
.pht__docLine { height: 5px; border-radius: $r-xs; background: rgba(23, 20, 26, .18); margin-top: $sp-1; }
.pht__docFig { height: 30%; margin-top: $sp-2; border-radius: $r-sm; background: rgba(47, 109, 246, .22); }
@media (max-width: 340px) {
.pht__thumb { width: 32px; height: 42px; }
}
@media (prefers-reduced-motion: reduce) {
.pht__docInner, .pht__ringFill, .pht__ringCheck { 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);
}
.pht {
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;
outline: none;
}
.pht__toolbar {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
background: #17141a;
}
.pht__rail {
flex: 1 1 auto;
overflow: hidden;
min-width: 0;
}
.pht__railTrack {
display: flex;
gap: 8px;
will-change: transform;
}
.pht__thumb {
flex: 0 0 auto;
width: 40px;
height: 52px;
border: 2px solid transparent;
border-radius: 8px;
background: #fff7e6;
padding: 4px;
box-sizing: border-box;
cursor: pointer;
}
.pht__thumb--active {
border-color: #5b8cf8;
}
.pht__miniTitle {
height: 4px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.4);
margin-bottom: 4px;
}
.pht__miniLine {
height: 2px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.22);
margin-top: 4px;
}
.pht__miniFig {
height: 28%;
margin-top: 4px;
border-radius: 4px;
background: rgba(47, 109, 246, 0.22);
}
.pht__ringBtn {
flex: 0 0 auto;
border: none;
border-radius: 999px;
width: 34px;
height: 34px;
display: grid;
place-items: center;
background: rgba(255, 255, 255, 0.12);
cursor: pointer;
}
.pht__ring {
display: block;
}
.pht__ringTrack {
fill: none;
stroke: rgba(255, 255, 255, 0.25);
stroke-width: 4;
}
.pht__ringFill {
fill: none;
stroke: #5b8cf8;
stroke-width: 4;
stroke-linecap: round;
transition: opacity 300ms ease;
}
.pht__ringCheck {
stroke: #fff;
stroke-width: 4;
stroke-linecap: round;
fill: none;
opacity: 0;
transition: opacity 300ms ease;
}
.pht--done .pht__ringFill {
opacity: 0;
}
.pht--done .pht__ringCheck {
opacity: 1;
}
.pht__stage {
flex: 1 1 auto;
position: relative;
overflow: hidden;
}
.pht__doc {
position: absolute;
inset: 0;
overflow-y: auto;
padding: 12px;
box-sizing: border-box;
scrollbar-width: none;
}
.pht__doc::-webkit-scrollbar {
display: none;
}
.pht__docInner {
will-change: transform;
}
.pht__page {
background: #fff;
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
box-shadow: 0 2px 6px rgba(23, 20, 26, 0.1);
min-height: 120px;
box-sizing: border-box;
}
.pht__docTitle {
height: 8px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.4);
margin-bottom: 8px;
}
.pht__docLine {
height: 5px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.18);
margin-top: 4px;
}
.pht__docFig {
height: 30%;
margin-top: 8px;
border-radius: 8px;
background: rgba(47, 109, 246, 0.22);
}
@media (max-width: 340px) {
.pht__thumb {
width: 32px;
height: 42px;
}
}
@media (prefers-reduced-motion: reduce) {
.pht__docInner, .pht__ringFill, .pht__ringCheck {
transition: none;
}
}(function () {
// 09 = 01(썸네일 레일 동기)+02(포인터 초점 줌)+08(Download 링) 결합. 각 장면의 계산을
// 그대로 가져와 한 무대에 얹는다: 스크롤 겹침 비율로 레일을 맞추고, 포인터 좌표비로
// 확대 원점을 계산하고, 받은 바이트 비율로 링을 채운다.
var root = document.getElementById('phtRoot');
var rail = document.getElementById('phtRail');
var railTrack = document.getElementById('phtRailTrack');
var stage = document.getElementById('phtStage');
var doc = document.getElementById('phtDoc');
var docInner = document.getElementById('phtDocInner');
var ringBtn = document.getElementById('phtRingBtn');
var ringFill = document.getElementById('phtRingFill');
var checkA = document.getElementById('phtCheckA');
var checkB = document.getElementById('phtCheckB');
var HERO_ZOOM_STEP = 1.2;
var HERO_RAIL_LERP = 0.18;
var HERO_TOTAL_BYTES = 200000;
var HERO_SPEED = 230000;
var LOOP_MS = 2000;
var N = 4;
function seedRand(seed) { var s = seed >>> 0; return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; }; }
var rnd = seedRand(430091);
function pageHTML(prefix) {
var h = '<div class="' + prefix + 'Title" style="width:' + (46 + rnd() * 14) + '%"></div>';
for (var i = 0; i < 4; i += 1) h += '<div class="' + prefix + 'Line" style="width:' + (36 + rnd() * 56) + '%"></div>';
if (rnd() > 0.4) h += '<div class="' + prefix + 'Fig"></div>';
return h;
}
for (var p = 0; p < N; p += 1) {
var page = document.createElement('div');
page.className = 'pht__page';
page.innerHTML = pageHTML('pht__doc');
docInner.appendChild(page);
var thumb = document.createElement('button');
thumb.type = 'button';
thumb.className = 'pht__thumb';
thumb.setAttribute('aria-label', 'Page ' + (p + 1) + ' thumbnail');
thumb.innerHTML = pageHTML('pht__mini');
railTrack.appendChild(thumb);
}
var pages = Array.prototype.slice.call(docInner.children);
var thumbs = Array.prototype.slice.call(railTrack.children);
function computeCurrent() {
var top = doc.scrollTop, h = doc.clientHeight, best = 0, bestRatio = -1;
pages.forEach(function (pg, i) {
var pTop = pg.offsetTop, pBot = pTop + pg.offsetHeight;
var visTop = Math.max(top, pTop), visBot = Math.min(top + h, pBot);
var ratio = Math.max(0, visBot - visTop) / pg.offsetHeight;
if (ratio > bestRatio) { bestRatio = ratio; best = i; }
});
return best;
}
var activeIdx = -1;
function markRail(idx) {
if (idx === activeIdx) return;
activeIdx = idx;
thumbs.forEach(function (t, i) { t.classList.toggle('pht__thumb--active', i === idx); });
}
var railX = 0;
function railTarget(idx) {
var t = thumbs[idx]; if (!t) return 0;
var want = t.offsetLeft + t.offsetWidth / 2 - rail.clientWidth / 2;
return -Math.max(0, Math.min(railTrack.scrollWidth - rail.clientWidth, want));
}
var zoomManual = { s: 1, x: .5, y: .5 };
function applyZoom(scale, ox, oy) {
docInner.style.transformOrigin = (ox * 100) + '% ' + (oy * 100) + '%';
docInner.style.transform = 'scale(' + scale.toFixed(3) + ')';
}
var phase = 'idle', received = 0, doneAt = 0, checkAnim = null;
var R = 15, CIRC = 2 * Math.PI * R;
ringFill.style.strokeDasharray = CIRC.toFixed(2);
function lineLen(el) { var x1 = +el.getAttribute('x1'), y1 = +el.getAttribute('y1'), x2 = +el.getAttribute('x2'), y2 = +el.getAttribute('y2'); return Math.hypot(x2 - x1, y2 - y1); }
var lenA = lineLen(checkA), lenB = lineLen(checkB);
checkA.style.strokeDasharray = lenA.toFixed(2); checkB.style.strokeDasharray = lenB.toFixed(2);
checkA.style.strokeDashoffset = lenA.toFixed(2); checkB.style.strokeDashoffset = lenB.toFixed(2);
function renderRing(ratio) { ringFill.style.strokeDashoffset = (CIRC * (1 - ratio)).toFixed(2); }
function startDownload() {
if (phase === 'loading') return;
phase = 'loading'; received = 0;
root.classList.remove('pht--done');
checkA.style.strokeDashoffset = lenA.toFixed(2); checkB.style.strokeDashoffset = lenB.toFixed(2);
renderRing(0);
}
function finishDownload(nowT) { phase = 'done'; doneAt = nowT; root.classList.add('pht--done'); checkAnim = { start: nowT }; }
function resetDownload() { phase = 'idle'; received = 0; root.classList.remove('pht--done'); renderRing(0); }
var userActive = false;
function markActive() { if (!userActive) { userActive = true; root.classList.remove('is-demo'); } }
doc.addEventListener('scroll', function () { markRail(computeCurrent()); });
stage.addEventListener('wheel', function (e) {
e.preventDefault(); markActive();
var r = stage.getBoundingClientRect();
var ox = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
var oy = Math.max(0, Math.min(1, (e.clientY - r.top) / r.height));
zoomManual.s = Math.max(1, Math.min(HERO_ZOOM_STEP * HERO_ZOOM_STEP * 1.4, zoomManual.s * Math.pow(HERO_ZOOM_STEP, (e.deltaY < 0 ? 1 : -1) * 0.6)));
zoomManual.x = ox; zoomManual.y = oy;
applyZoom(zoomManual.s, ox, oy);
}, { passive: false });
thumbs.forEach(function (t, i) {
t.addEventListener('click', function () {
markActive(); markRail(i);
doc.scrollTop = pages[i].offsetTop;
});
});
ringBtn.addEventListener('click', function () { markActive(); if (phase !== 'loading') { resetDownload(); startDownload(); } });
root.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault(); markActive();
var next = Math.max(0, Math.min(N - 1, activeIdx + (e.key === 'ArrowDown' ? 1 : -1)));
doc.scrollTop = pages[next].offsetTop;
} else if (e.key === '+' || e.key === '=' || e.key === '-') {
e.preventDefault(); markActive();
zoomManual.s = Math.max(1, Math.min(HERO_ZOOM_STEP * HERO_ZOOM_STEP * 1.4, zoomManual.s * Math.pow(HERO_ZOOM_STEP, e.key === '-' ? -0.6 : 0.6)));
applyZoom(zoomManual.s, zoomManual.x, zoomManual.y);
}
});
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { root.classList.remove('is-demo'); userActive = true; markRail(0); applyZoom(1, .5, .5); renderRing(0.4); }
var fired = {};
var autoScale = { s: 1 };
var SCHEDULE = [
{ t: 0, run: function () { doc.scrollTop = 0; } },
{ t: 200, run: function () { doc.scrollTop = pages[1] ? pages[1].offsetTop : 0; } },
{ t: 550, run: function () { autoScale.target = HERO_ZOOM_STEP; autoScale.ox = .3; autoScale.oy = .4; } },
{ t: 900, run: function () { autoScale.target = 1; } },
{ t: 1050, run: function () { doc.scrollTop = pages[2] ? pages[2].offsetTop : 0; } },
{ t: 1300, run: function () { if (phase === 'idle') startDownload(); } },
{ t: 1750, run: function () { doc.scrollTop = pages[3] ? pages[3].offsetTop : 0; } }
];
var lastTs = null, originTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!userActive) {
if (originTs === null) originTs = ts;
var elapsed = ts - originTs;
var loopIdx = Math.floor(elapsed / LOOP_MS);
var phaseT = elapsed % LOOP_MS;
SCHEDULE.forEach(function (a, i) {
var key = loopIdx + ':' + i;
if (phaseT >= a.t && !fired[key]) { fired[key] = true; a.run(); }
});
if (autoScale.target !== undefined) {
zoomManual.s += (autoScale.target - zoomManual.s) * 0.14;
applyZoom(zoomManual.s, autoScale.ox || .5, autoScale.oy || .5);
}
markRail(computeCurrent());
}
railX += (railTarget(activeIdx < 0 ? 0 : activeIdx) - railX) * HERO_RAIL_LERP;
railTrack.style.transform = 'translateX(' + railX.toFixed(1) + 'px)';
if (phase === 'loading') {
received = Math.min(HERO_TOTAL_BYTES, received + HERO_SPEED * (dt / 1000));
var ratio = received / HERO_TOTAL_BYTES;
renderRing(ratio);
if (ratio >= 1) finishDownload(ts);
} else if (phase === 'done') {
if (checkAnim) {
var pA = Math.max(0, Math.min(1, (ts - checkAnim.start) / 180));
var pB = Math.max(0, Math.min(1, (ts - checkAnim.start - 110) / 220));
checkA.style.strokeDashoffset = (lenA * (1 - pA)).toFixed(2);
checkB.style.strokeDashoffset = (lenB * (1 - pB)).toFixed(2);
if (pB >= 1) checkAnim = null;
}
if (ts - doneAt > 560 && !userActive) resetDownload();
}
requestAnimationFrame(tick);
}
markRail(0);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./PdfViewerHeroToolbarScene.module.scss";
type KeyEvt = { key: string; preventDefault(): void };
type WheelEvt = { deltaY: number; clientX: number; clientY: number; preventDefault(): void };
export default function PdfViewerHeroToolbarScene() {
const rootRef = useRef<HTMLDivElement>(null);
const railRef = useRef<HTMLDivElement>(null);
const railTrackRef = useRef<HTMLDivElement>(null);
const stageRef = useRef<HTMLDivElement>(null);
const docRef = useRef<HTMLDivElement>(null);
const docInnerRef = useRef<HTMLDivElement>(null);
const ringBtnRef = useRef<HTMLButtonElement>(null);
const ringFillRef = useRef<SVGCircleElement>(null);
const checkARef = useRef<SVGLineElement>(null);
const checkBRef = useRef<SVGLineElement>(null);
useEffect(() => {
const root = rootRef.current, rail = railRef.current, railTrack = railTrackRef.current;
const stage = stageRef.current, doc = docRef.current, docInner = docInnerRef.current;
const ringBtn = ringBtnRef.current, ringFill = ringFillRef.current;
const checkA = checkARef.current, checkB = checkBRef.current;
if (!root || !rail || !railTrack || !stage || !doc || !docInner || !ringBtn || !ringFill || !checkA || !checkB) return;
const HERO_ZOOM_STEP = 1.2;
const HERO_RAIL_LERP = 0.18;
const HERO_TOTAL_BYTES = 200000;
const HERO_SPEED = 230000;
const LOOP_MS = 2000;
const N = 4;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rnd = seedRand(430091);
function pageHTML(titleCls: string, lineCls: string, figCls: string) {
let h = '<div class="' + titleCls + '" style="width:' + (46 + rnd() * 14) + '%"></div>';
for (let i = 0; i < 4; i += 1) h += '<div class="' + lineCls + '" style="width:' + (36 + rnd() * 56) + '%"></div>';
if (rnd() > 0.4) h += '<div class="' + figCls + '"></div>';
return h;
}
for (let p = 0; p < N; p += 1) {
const page = document.createElement("div");
page.className = styles.page;
page.innerHTML = pageHTML(styles.docTitle, styles.docLine, styles.docFig);
docInner.appendChild(page);
const thumb = document.createElement("button");
thumb.type = "button";
thumb.className = styles.thumb;
thumb.setAttribute("aria-label", "페이지 " + (p + 1) + "로 이동");
thumb.innerHTML = pageHTML(styles.miniTitle, styles.miniLine, styles.miniFig);
railTrack.appendChild(thumb);
}
const pages = Array.prototype.slice.call(docInner.children) as HTMLElement[];
const thumbs = Array.prototype.slice.call(railTrack.children) as HTMLElement[];
function computeCurrent() {
const top = doc!.scrollTop, h = doc!.clientHeight;
let best = 0, bestRatio = -1;
pages.forEach((pg, i) => {
const pTop = pg.offsetTop, pBot = pTop + pg.offsetHeight;
const visTop = Math.max(top, pTop), visBot = Math.min(top + h, pBot);
const ratio = Math.max(0, visBot - visTop) / pg.offsetHeight;
if (ratio > bestRatio) { bestRatio = ratio; best = i; }
});
return best;
}
let activeIdx = -1;
function markRail(idx: number) {
if (idx === activeIdx) return;
activeIdx = idx;
thumbs.forEach((t, i) => { t.classList.toggle(styles.thumbActive, i === idx); });
}
let railX = 0;
function railTarget(idx: number) {
const t = thumbs[idx]; if (!t) return 0;
const want = t.offsetLeft + t.offsetWidth / 2 - rail!.clientWidth / 2;
return -Math.max(0, Math.min(railTrack!.scrollWidth - rail!.clientWidth, want));
}
const zoomManual = { s: 1, x: .5, y: .5 };
function applyZoom(scale: number, ox: number, oy: number) {
docInner!.style.transformOrigin = (ox * 100) + "% " + (oy * 100) + "%";
docInner!.style.transform = "scale(" + scale.toFixed(3) + ")";
}
let phase: "idle" | "loading" | "done" = "idle", received = 0, doneAt = 0;
let checkAnim: { start: number } | null = null;
const R = 15, CIRC = 2 * Math.PI * R;
ringFill.style.strokeDasharray = CIRC.toFixed(2);
function lineLen(el: SVGLineElement) {
const x1 = +el.getAttribute("x1")!, y1 = +el.getAttribute("y1")!;
const x2 = +el.getAttribute("x2")!, y2 = +el.getAttribute("y2")!;
return Math.hypot(x2 - x1, y2 - y1);
}
const lenA = lineLen(checkA), lenB = lineLen(checkB);
checkA.style.strokeDasharray = lenA.toFixed(2); checkB.style.strokeDasharray = lenB.toFixed(2);
checkA.style.strokeDashoffset = lenA.toFixed(2); checkB.style.strokeDashoffset = lenB.toFixed(2);
function renderRing(ratio: number) { ringFill!.style.strokeDashoffset = (CIRC * (1 - ratio)).toFixed(2); }
function startDownload() {
if (phase === "loading") return;
phase = "loading"; received = 0;
root!.classList.remove(styles.done);
checkA!.style.strokeDashoffset = lenA.toFixed(2); checkB!.style.strokeDashoffset = lenB.toFixed(2);
renderRing(0);
}
function finishDownload(nowT: number) { phase = "done"; doneAt = nowT; root!.classList.add(styles.done); checkAnim = { start: nowT }; }
function resetDownload() { phase = "idle"; received = 0; root!.classList.remove(styles.done); renderRing(0); }
let userActive = false;
function markActive() { if (!userActive) { userActive = true; root!.classList.remove("is-demo"); } }
function onScroll() { markRail(computeCurrent()); }
function onWheel(e: WheelEvt) {
e.preventDefault(); markActive();
const r = stage!.getBoundingClientRect();
const ox = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
const oy = Math.max(0, Math.min(1, (e.clientY - r.top) / r.height));
zoomManual.s = Math.max(1, Math.min(HERO_ZOOM_STEP * HERO_ZOOM_STEP * 1.4, zoomManual.s * Math.pow(HERO_ZOOM_STEP, (e.deltaY < 0 ? 1 : -1) * 0.6)));
zoomManual.x = ox; zoomManual.y = oy;
applyZoom(zoomManual.s, ox, oy);
}
doc.addEventListener("scroll", onScroll);
stage.addEventListener("wheel", onWheel as unknown as EventListener, { passive: false });
const thumbHandlers: Array<() => void> = [];
thumbs.forEach((t, i) => {
const h = () => { markActive(); markRail(i); doc!.scrollTop = pages[i].offsetTop; };
thumbHandlers.push(h);
t.addEventListener("click", h);
});
function onRingClick() { markActive(); if (phase !== "loading") { resetDownload(); startDownload(); } }
ringBtn.addEventListener("click", onRingClick);
function onKeydown(e: KeyEvt) {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault(); markActive();
const next = Math.max(0, Math.min(N - 1, activeIdx + (e.key === "ArrowDown" ? 1 : -1)));
doc!.scrollTop = pages[next].offsetTop;
} else if (e.key === "+" || e.key === "=" || e.key === "-") {
e.preventDefault(); markActive();
zoomManual.s = Math.max(1, Math.min(HERO_ZOOM_STEP * HERO_ZOOM_STEP * 1.4, zoomManual.s * Math.pow(HERO_ZOOM_STEP, e.key === "-" ? -0.6 : 0.6)));
applyZoom(zoomManual.s, zoomManual.x, zoomManual.y);
}
}
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; markRail(0); applyZoom(1, .5, .5); renderRing(0.4); }
const fired: Record<string, boolean> = {};
const autoScale: { target?: number; ox?: number; oy?: number } = {};
const SCHEDULE = [
{ t: 0, run: () => { doc!.scrollTop = 0; } },
{ t: 200, run: () => { doc!.scrollTop = pages[1] ? pages[1].offsetTop : 0; } },
{ t: 550, run: () => { autoScale.target = HERO_ZOOM_STEP; autoScale.ox = .3; autoScale.oy = .4; } },
{ t: 900, run: () => { autoScale.target = 1; } },
{ t: 1050, run: () => { doc!.scrollTop = pages[2] ? pages[2].offsetTop : 0; } },
{ t: 1300, run: () => { if (phase === "idle") startDownload(); } },
{ t: 1750, run: () => { doc!.scrollTop = pages[3] ? pages[3].offsetTop : 0; } }
];
let lastTs: number | null = null, originTs: number | null = null, raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!userActive) {
if (originTs === null) originTs = ts;
const elapsed = ts - originTs;
const loopIdx = Math.floor(elapsed / LOOP_MS);
const phaseT = elapsed % LOOP_MS;
SCHEDULE.forEach((a, i) => {
const key = loopIdx + ":" + i;
if (phaseT >= a.t && !fired[key]) { fired[key] = true; a.run(); }
});
if (autoScale.target !== undefined) {
zoomManual.s += (autoScale.target - zoomManual.s) * 0.14;
applyZoom(zoomManual.s, autoScale.ox || .5, autoScale.oy || .5);
}
markRail(computeCurrent());
}
railX += (railTarget(activeIdx < 0 ? 0 : activeIdx) - railX) * HERO_RAIL_LERP;
railTrack!.style.transform = "translateX(" + railX.toFixed(1) + "px)";
if (phase === "loading") {
received = Math.min(HERO_TOTAL_BYTES, received + HERO_SPEED * (dt / 1000));
const ratio = received / HERO_TOTAL_BYTES;
renderRing(ratio);
if (ratio >= 1) finishDownload(ts);
} else if (phase === "done") {
if (checkAnim) {
const pA = Math.max(0, Math.min(1, (ts - checkAnim.start) / 180));
const pB = Math.max(0, Math.min(1, (ts - checkAnim.start - 110) / 220));
checkA!.style.strokeDashoffset = (lenA * (1 - pA)).toFixed(2);
checkB!.style.strokeDashoffset = (lenB * (1 - pB)).toFixed(2);
if (pB >= 1) checkAnim = null;
}
if (ts - doneAt > 560 && !userActive) resetDownload();
}
raf = requestAnimationFrame(tick);
}
markRail(0);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
doc.removeEventListener("scroll", onScroll);
stage.removeEventListener("wheel", onWheel as unknown as EventListener);
thumbs.forEach((t, i) => t.removeEventListener("click", thumbHandlers[i]));
ringBtn.removeEventListener("click", onRingClick);
root.removeEventListener("keydown", onKeydown as unknown as EventListener);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.pht} is-demo`} ref={rootRef} tabIndex={0} aria-label="문서 훑기·확대·다운로드가 함께 있는 장면, 방향키로 페이지 이동·+/- 로 확대">
<div className={styles.toolbar}>
<div className={styles.rail} ref={railRef}>
<div className={styles.railTrack} ref={railTrackRef} />
</div>
<button className={styles.ringBtn} ref={ringBtnRef} type="button" aria-label="다운로드">
<svg className={styles.ring} viewBox="0 0 40 40" width={26} height={26} aria-hidden="true">
<circle className={styles.ringTrack} cx={20} cy={20} r={15} />
<circle className={styles.ringFill} ref={ringFillRef} cx={20} cy={20} r={15} transform="rotate(-90 20 20)" />
<line className={styles.ringCheck} ref={checkARef} x1={12} y1={21} x2={17} y2={27} />
<line className={styles.ringCheck} ref={checkBRef} x1={12} y1={21} x2={28} y2={12} />
</svg>
</button>
</div>
<div className={styles.stageArea} ref={stageRef}>
<div className={styles.doc} ref={docRef}>
<div className={styles.docInner} ref={docInnerRef} />
</div>
</div>
</div>
</main>
);
}09 Hero Scene Combining Several Calculations Into One Viewer Toolbar — Measured area 40.053% · moving frames 21/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


