02Focal Zoom That Calculates Its Center From the Pointer's Coordinates
The coordinates where a wheel or pinch happens are converted into a ratio relative to the page and calculated, and the zoomed view grows while keeping that exact point centered on screen.
Best forZooming into a blueprint for review
ZOOM_FOCAL_STEP_RATIOapplyWheeleaseInOut

<main class="stage">
<div class="pvz is-demo" id="pvzRoot" tabindex="0">
<div class="pvz__view" id="pvzView">
<div class="pvz__page" id="pvzPage"></div>
<div class="pvz__dot" id="pvzDot"></div>
</div>
<p class="pvz__label" id="pvzLabel">100%</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-yellow);
.pvz {
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-ink;
outline: none;
}
.pvz__view {
position: absolute;
inset: 0;
overflow: hidden;
}
.pvz__page {
position: absolute;
left: 12%;
top: 10%;
width: 76%;
height: 80%;
background: $subject-cream;
border-radius: $r-sm;
padding: $sp-4;
box-sizing: border-box;
will-change: transform;
}
.pvz__title { height: 10px; border-radius: $r-xs; background: rgba(23, 20, 26, .4); margin-bottom: $sp-2; }
.pvz__line { height: 5px; border-radius: $r-xs; background: rgba(23, 20, 26, .18); margin-top: $sp-1; }
.pvz__fig { height: 22%; margin-top: $sp-2; border-radius: $r-sm; background: rgba(47, 109, 246, .22); }
.pvz__dot {
position: absolute;
width: 14px;
height: 14px;
margin-left: -7px;
margin-top: -7px;
border-radius: 50%;
background: rgba(255, 77, 31, .85);
box-shadow: 0 0 0 4px rgba(255, 77, 31, .25);
pointer-events: none;
will-change: transform;
}
.pvz__label {
position: absolute;
left: $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) {
.pvz__label { font-size: 9px; }
}
@media (prefers-reduced-motion: reduce) {
.pvz__page, .pvz__dot { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pvz {
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: #17141a;
outline: none;
}
.pvz__view {
position: absolute;
inset: 0;
overflow: hidden;
}
.pvz__page {
position: absolute;
left: 12%;
top: 10%;
width: 76%;
height: 80%;
background: #fff7e6;
border-radius: 8px;
padding: 16px;
box-sizing: border-box;
will-change: transform;
}
.pvz__title {
height: 10px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.4);
margin-bottom: 8px;
}
.pvz__line {
height: 5px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.18);
margin-top: 4px;
}
.pvz__fig {
height: 22%;
margin-top: 8px;
border-radius: 8px;
background: rgba(47, 109, 246, 0.22);
}
.pvz__dot {
position: absolute;
width: 14px;
height: 14px;
margin-left: -7px;
margin-top: -7px;
border-radius: 50%;
background: rgba(255, 77, 31, 0.85);
box-shadow: 0 0 0 4px rgba(255, 77, 31, 0.25);
pointer-events: none;
will-change: transform;
}
.pvz__label {
position: absolute;
left: 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) {
.pvz__label {
font-size: 9px;
}
}
@media (prefers-reduced-motion: reduce) {
.pvz__page, .pvz__dot {
transition: none;
}
}(function () {
// 변형 02: ZOOM_FOCAL_STEP_RATIO 1.2 → 2.4 (전값이 소스 초기값)
// 기법: 휠(또는 시연 스케줄)이 일어난 지점의 좌표를 Page 폭·높이 기준 비율(0~1)로 바꿔
// transform-origin 으로 쓴다. 확대율은 그 비율의 거듭제곱(ZOOM_FOCAL_STEP_RATIO^n)으로
// 계산해, 값이 클수록 같은 조작에도 훨씬 크게 뛴다. 초점 표식(dot)의 반경은 1/scale 로
// 역보정해 확대돼도 항상 같은 크기로 보인다(계산 전파).
var root = document.getElementById('pvzRoot');
var view = document.getElementById('pvzView');
var page = document.getElementById('pvzPage');
var dot = document.getElementById('pvzDot');
var label = document.getElementById('pvzLabel');
var ZOOM_FOCAL_STEP_RATIO = 1.2;
var LOOP_MS = 2000;
function seedRand(seed) { var s = seed >>> 0; return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; }; }
var rnd = seedRand(430021);
var lineCount = 7;
var linesHTML = '<div class="pvz__title" style="width:52%"></div>';
for (var i = 0; i < lineCount; i += 1) linesHTML += '<div class="pvz__line" style="width:' + (40 + rnd() * 52) + '%"></div>';
linesHTML += '<div class="pvz__fig"></div>';
page.innerHTML = linesHTML;
var L1 = ZOOM_FOCAL_STEP_RATIO;
var L2 = ZOOM_FOCAL_STEP_RATIO * ZOOM_FOCAL_STEP_RATIO;
var KF = [
{ t: 0, s: 1, x: .5, y: .5 },
{ t: 300, s: L1, x: .28, y: .38 },
{ t: 900, s: L2, x: .28, y: .38 },
{ t: 1300, s: 1, x: .28, y: .38 },
{ t: 1500, s: L1, x: .7, y: .64 },
{ t: 2000, s: 1, x: .7, y: .64 }
];
function easeInOut(t) { return t * t * (3 - 2 * t); }
function render(scale, ox, oy) {
page.style.transformOrigin = (ox * 100) + '% ' + (oy * 100) + '%';
page.style.transform = 'scale(' + scale.toFixed(3) + ')';
var vr = view.getBoundingClientRect();
dot.style.left = (ox * vr.width) + 'px';
dot.style.top = (oy * vr.height) + 'px';
dot.style.transform = 'translate(-50%,-50%) scale(' + (1 / scale).toFixed(3) + ')';
label.textContent = Math.round(scale * 100) + '%';
}
function scheduled(elapsed) {
var phase = elapsed % LOOP_MS;
var i = 0;
while (i < KF.length - 1 && phase >= KF[i + 1].t) i += 1;
var a = KF[i], b = KF[Math.min(i + 1, KF.length - 1)];
var span = Math.max(1, b.t - a.t);
var t = easeInOut(Math.max(0, Math.min(1, (phase - a.t) / span)));
var s = a.s + (b.s - a.s) * t;
render(s, b.x, b.y);
}
var manual = { s: 1, x: .5, y: .5 };
function applyWheel(dir, ox, oy) {
manual.s = Math.max(1, Math.min(L2 * 1.4, manual.s * Math.pow(ZOOM_FOCAL_STEP_RATIO, dir * 0.6)));
manual.x = ox; manual.y = oy;
render(manual.s, manual.x, manual.y);
}
var userActive = false;
function markActive() { if (!userActive) { userActive = true; root.classList.remove('is-demo'); manual.s = 1; render(1, .5, .5); } }
view.addEventListener('wheel', function (e) {
e.preventDefault();
markActive();
var r = view.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));
applyWheel(e.deltaY < 0 ? 1 : -1, ox, oy);
}, { passive: false });
root.addEventListener('keydown', function (e) {
if (e.key === '+' || e.key === '=' || e.key === '-' || e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault();
markActive();
var dir = (e.key === '+' || e.key === '=' || e.key === 'ArrowUp') ? 1 : -1;
applyWheel(dir, manual.x, manual.y);
}
});
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { root.classList.remove('is-demo'); userActive = true; render(1, .5, .5); }
var 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; scheduled(elapsed); }
requestAnimationFrame(tick);
}
render(1, .5, .5);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./PdfViewerPointerZoomFocalPoint.module.scss";
type KeyEvt = { key: string; preventDefault(): void };
type WheelEvt = { deltaY: number; clientX: number; clientY: number; preventDefault(): void };
export default function PdfViewerPointerZoomFocalPoint() {
const rootRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<HTMLDivElement>(null);
const pageRef = useRef<HTMLDivElement>(null);
const dotRef = useRef<HTMLDivElement>(null);
const labelRef = useRef<HTMLParagraphElement>(null);
useEffect(() => {
const root = rootRef.current, view = viewRef.current, page = pageRef.current;
const dot = dotRef.current, label = labelRef.current;
if (!root || !view || !page || !dot || !label) return;
// 변형 02: ZOOM_FOCAL_STEP_RATIO 1.2 → 2.4 (전값이 소스 초기값)
const ZOOM_FOCAL_STEP_RATIO = 1.2;
const LOOP_MS = 2000;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rnd = seedRand(430020);
let linesHTML = '<div class="' + styles.title + '" style="width:52%"></div>';
for (let i = 0; i < 7; i += 1) linesHTML += '<div class="' + styles.line + '" style="width:' + (40 + rnd() * 52) + '%"></div>';
linesHTML += '<div class="' + styles.fig + '"></div>';
page.innerHTML = linesHTML;
const L1 = ZOOM_FOCAL_STEP_RATIO;
const L2 = ZOOM_FOCAL_STEP_RATIO * ZOOM_FOCAL_STEP_RATIO;
const KF = [
{ t: 0, s: 1, x: .5, y: .5 },
{ t: 300, s: L1, x: .28, y: .38 },
{ t: 900, s: L2, x: .28, y: .38 },
{ t: 1300, s: 1, x: .28, y: .38 },
{ t: 1500, s: L1, x: .7, y: .64 },
{ t: 2000, s: 1, x: .7, y: .64 }
];
function easeInOut(t: number) { return t * t * (3 - 2 * t); }
function render(scale: number, ox: number, oy: number) {
page!.style.transformOrigin = (ox * 100) + "% " + (oy * 100) + "%";
page!.style.transform = "scale(" + scale.toFixed(3) + ")";
const vr = view!.getBoundingClientRect();
dot!.style.left = (ox * vr.width) + "px";
dot!.style.top = (oy * vr.height) + "px";
dot!.style.transform = "translate(-50%,-50%) scale(" + (1 / scale).toFixed(3) + ")";
label!.textContent = Math.round(scale * 100) + "%";
}
function scheduled(elapsed: number) {
const phase = elapsed % LOOP_MS;
let i = 0;
while (i < KF.length - 1 && phase >= KF[i + 1].t) i += 1;
const a = KF[i], b = KF[Math.min(i + 1, KF.length - 1)];
const span = Math.max(1, b.t - a.t);
const t = easeInOut(Math.max(0, Math.min(1, (phase - a.t) / span)));
const s = a.s + (b.s - a.s) * t;
render(s, b.x, b.y);
}
const manual = { s: 1, x: .5, y: .5 };
let userActive = false;
function applyWheel(dir: number, ox: number, oy: number) {
manual.s = Math.max(1, Math.min(L2 * 1.4, manual.s * Math.pow(ZOOM_FOCAL_STEP_RATIO, dir * 0.6)));
manual.x = ox; manual.y = oy;
render(manual.s, manual.x, manual.y);
}
function markActive() { if (!userActive) { userActive = true; root!.classList.remove("is-demo"); manual.s = 1; render(1, .5, .5); } }
function onWheel(e: WheelEvt) {
e.preventDefault();
markActive();
const r = view!.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));
applyWheel(e.deltaY < 0 ? 1 : -1, ox, oy);
}
function onKeydown(e: KeyEvt) {
if (e.key === "+" || e.key === "=" || e.key === "-" || e.key === "ArrowUp" || e.key === "ArrowDown") {
e.preventDefault();
markActive();
const dir = (e.key === "+" || e.key === "=" || e.key === "ArrowUp") ? 1 : -1;
applyWheel(dir, manual.x, manual.y);
}
}
view.addEventListener("wheel", onWheel as unknown as EventListener, { passive: false });
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; render(1, .5, .5); }
let 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; scheduled(elapsed); }
raf = requestAnimationFrame(tick);
}
render(1, .5, .5);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
view.removeEventListener("wheel", onWheel as unknown as EventListener);
root.removeEventListener("keydown", onKeydown as unknown as EventListener);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.pvz} is-demo`} ref={rootRef} tabIndex={0}>
<div className={styles.view} ref={viewRef}>
<div className={styles.page} ref={pageRef} />
<div className={styles.dot} ref={dotRef} />
</div>
<p className={styles.label} ref={labelRef}>100%</p>
</div>
</main>
);
}02 Focal Zoom That Calculates Its Center From the Pointer's Coordinates — Measured area 57.451% · 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)
Embeds03 Notification list that splits read from unread9 Activity Feed UI Widgets — Sidebar-Ready Lists
Embeds02 Tabs that swap the same example per language9 Code Block UI Patterns — No Highlighter Library