Reactで、画像をホイールで拡大してドラッグで動かす方法
画像の拡大率と位置を1つの state に持ち、CSS の transform で translate() と scale() をかければ、ホイールで拡大してドラッグで動かせる画像の枠を、ライブラリを使わずに作れます。拡大するときに位置も一緒に計算し直すと、カーソルの下にある場所が動かないまま拡大できます。
注意したいのは、ホイールのイベントの受け取り方です。JSX の onWheel の中では preventDefault() が効かず、画像を縮小するつもりでホイールを回すとページまでスクロールしました。addEventListener() で passive: false を付けて登録すると、スクロールを止められます。
検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、Playwright 1.63.0 です。マウスのホイールとドラッグは Playwright で操作し、タッチは Chromium の DevTools プロトコル(CDP)の Input.dispatchTouchEvent で送りました。Firefox と Safari、実機のスマートフォン、トラックパッドでの操作は試していません。2本指のピンチでの拡大も、この記事では扱いません。

拡大率と位置を state に持つ ZoomPanImage
表示枠1つ分のコンポーネント ZoomPanImage です。state の view に、拡大率(scale)と、画像の左上が枠の左上からどれだけずれているか(x・y)を持ちます。
import { useEffect, useRef, useState } from 'react';
import type { PointerEvent } from 'react';
type Props = {
src: string;
alt: string;
width: number; // 枠の幅(px)。画像はこの幅で表示する
height: number; // 枠の高さ(px)
};
// 拡大率と、画像の左上の位置(枠の左上からの px)
type View = { scale: number; x: number; y: number };
const MIN_SCALE = 1;
const MAX_SCALE = 8;
// ホイールの deltaY が 100 のとき、約 1.22 倍(e の 0.2 乗)にする
const ZOOM_SPEED = 0.002;
// 画像が枠の外へ逃げて、枠の中に空きができないように位置を収める
function clampView(v: View, width: number, height: number): View {
const scale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, v.scale));
const minX = width - width * scale;
const minY = height - height * scale;
return {
scale,
x: Math.min(0, Math.max(minX, v.x)),
y: Math.min(0, Math.max(minY, v.y)),
};
}
// 枠の中の点 (px, py) の下にある画像の位置を動かさずに、拡大率を nextScale にする
function zoomAt(v: View, nextScale: number, px: number, py: number, width: number, height: number): View {
const scale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, nextScale));
// (px, py) の下にある点の、画像の上での位置(拡大率 1 のときの px)
const ix = (px - v.x) / v.scale;
const iy = (py - v.y) / v.scale;
return clampView({ scale, x: px - ix * scale, y: py - iy * scale }, width, height);
}
type Drag = { pointerId: number; startX: number; startY: number; origin: View };
export function ZoomPanImage({ src, alt, width, height }: Props) {
const [view, setView] = useState<View>({ scale: 1, x: 0, y: 0 });
const frameRef = useRef<HTMLDivElement>(null);
const drag = useRef<Drag | null>(null);
// wheel は addEventListener で、passive: false を付けて登録する
useEffect(() => {
const el = frameRef.current;
if (!el) return;
function handleWheel(e: WheelEvent) {
e.preventDefault(); // ページのスクロールを止める
const rect = el!.getBoundingClientRect();
const px = e.clientX - rect.left;
const py = e.clientY - rect.top;
const factor = Math.exp(-e.deltaY * ZOOM_SPEED);
// 登録は1回だけなので、今の view は更新用の関数で受け取る
setView((v) => zoomAt(v, v.scale * factor, px, py, width, height));
}
el.addEventListener('wheel', handleWheel, { passive: false });
return () => el.removeEventListener('wheel', handleWheel);
}, [width, height]);
function handlePointerDown(e: PointerEvent<HTMLDivElement>) {
if (e.pointerType === 'mouse' && e.button !== 0) return;
e.currentTarget.setPointerCapture(e.pointerId);
drag.current = { pointerId: e.pointerId, startX: e.clientX, startY: e.clientY, origin: view };
}
function handlePointerMove(e: PointerEvent<HTMLDivElement>) {
const d = drag.current;
if (!d || d.pointerId !== e.pointerId) return;
const next = {
scale: d.origin.scale,
x: d.origin.x + (e.clientX - d.startX),
y: d.origin.y + (e.clientY - d.startY),
};
setView(clampView(next, width, height));
}
function handlePointerEnd(e: PointerEvent<HTMLDivElement>) {
if (drag.current?.pointerId === e.pointerId) drag.current = null;
}
// ボタンからは、枠の中心を基準に拡大・縮小する
function zoomBy(factor: number) {
setView((v) => zoomAt(v, v.scale * factor, width / 2, height / 2, width, height));
}
return (
<div className="zoom-pan">
<div
ref={frameRef}
className="zoom-frame"
style={{ width, height }}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerEnd}
onPointerCancel={handlePointerEnd}
>
<img
src={src}
alt={alt}
width={width}
height={height}
draggable={false}
style={{ transform: `translate(${view.x}px, ${view.y}px) scale(${view.scale})` }}
/>
</div>
<div className="zoom-controls">
<button type="button" onClick={() => zoomBy(1 / 1.5)} disabled={view.scale <= MIN_SCALE}>
縮小
</button>
<output aria-live="polite">{Math.round(view.scale * 100)}%</output>
<button type="button" onClick={() => zoomBy(1.5)} disabled={view.scale >= MAX_SCALE}>
拡大
</button>
<button type="button" onClick={() => setView({ scale: 1, x: 0, y: 0 })}>
元に戻す
</button>
</div>
</div>
);
}
画像には translate(x, y) scale(scale) をかけます。CSS で transform-origin: 0 0 を指定して、拡大の基準を画像の左上にしています。transform-origin を指定しないと、基準は要素の中心です。Chromium で getComputedStyle() の値を見ると、指定しないときは 160px 120px(320×240 の中心)、0 0 を指定したときは 0px 0px でした。基準を左上にしておくと、画像の上の点 (ix, iy) が、枠の中では (x + ix × scale, y + iy × scale) に来る、という簡単な式で扱えます。
拡大率は 1 倍から 8 倍までにしました。clampView() は、画像を動かしすぎて枠の中に空きができないように、x を「枠の幅 − 拡大後の画像の幅」から 0 までに収めます。y も、高さで同じように収めます。
CSS は次のとおりです。
.zoom-frame {
overflow: hidden; /* 拡大した画像が、枠の外にはみ出して見えないようにする */
position: relative;
outline: 1px solid #9aa5ab; /* border にすると、getBoundingClientRect() の左上と画像の左上がずれる */
cursor: grab;
touch-action: none; /* タッチの動きを、ブラウザのスクロールや拡大に使わせない */
user-select: none;
}
.zoom-frame:active { cursor: grabbing; }
.zoom-frame img {
display: block;
transform-origin: 0 0; /* 拡大の基準を画像の左上にする */
}
overflow: hidden で、拡大した画像のうち枠の外の部分を隠します。touch-action: none は、指で動かしたときにブラウザがスクロールや拡大を始めないようにする指定です(MDN の touch-action の説明)。画像に付けた draggable={false} は、画像をドラッグできない要素にする指定です。MDN の draggable 属性の説明によると、指定しないとき、画像はブラウザの既定の動きでドラッグできます。
枠の線は、border ではなく outline で引いています。理由は、拡大の計算を説明したあとの「枠の線を border で引くと、カーソルの下がずれる」の節で書きます。
使う側の App は、画像のファイルと枠の大きさを渡すだけです。
import { ZoomPanImage } from './ZoomPanImage';
export function App() {
return (
<main>
<ZoomPanImage src="map.png" alt="町の地図(マス目に A1〜H6 の番号付き)" width={320} height={240} />
</main>
);
}
カーソルの下の場所を動かさずに拡大する
zoomAt() が、拡大の計算をする関数です。まず、カーソルの位置 (px, py) の下にある点が、画像の上のどこにあたるかを求めます。
ix = (px - x) / scale
iy = (py - y) / scale
拡大率を新しい値にしたあとも、その点が (px, py) にあるように、新しい x・y を逆算します。
x' = px - ix * scale'
y' = py - iy * scale'
画像を 320×240 で表示したときの (80, 80) にピンクの丸を描き、その上にカーソルを置いてホイールを回しました。この記事では、deltaY が負になる向き(ページを上へスクロールする向き)を「上へ」、正になる向きを「下へ」と書きます。Playwright の mouse.wheel() で、1回に deltaY を 100 ずつ送っています。
上へ(deltaY -100)1回目: 表示 122% / translate(-17.7px, -17.7px) scale(1.2214) / scrollY 0 / カーソルの下: (80, 80)
上へ(deltaY -100)2回目: 表示 149% / translate(-39.3px, -39.3px) scale(1.4918) / scrollY 0 / カーソルの下: (80, 80)
上へ(deltaY -100)3回目: 表示 182% / translate(-65.8px, -65.8px) scale(1.8221) / scrollY 0 / カーソルの下: (80, 80)
下へ(deltaY +100)1回目: 表示 149% / translate(-39.3px, -39.3px) scale(1.4918) / scrollY 0 / カーソルの下: (80, 80)
下へ(deltaY +100)2回目: 表示 122% / translate(-17.7px, -17.7px) scale(1.2214) / scrollY 0 / カーソルの下: (80, 80)
下へ(deltaY +100)3回目: 表示 100% / translate(0px, 0px) scale(1) / scrollY 0 / カーソルの下: (80, 80)
下へ(deltaY +100)4回目: 表示 100% / translate(0px, 0px) scale(1) / scrollY 0 / カーソルの下: (80, 80)
上へ12回: 表示 800% / translate(-560px, -560px) scale(8) / scrollY 0 / カーソルの下: (80, 80)
deltaY が負のときは、Math.exp(-deltaY * 0.002) が 1 より大きくなるので拡大します。1回ごとに約 1.22 倍です。拡大率を足し算ではなく掛け算で変えているので、下へ同じ回数だけ回すと、ちょうど元の 100% に戻りました。8 倍で止まり、1 倍より小さくもなりません。どの段階でも、カーソルの下はピンクの丸の (80, 80) のままです。
比べるために、拡大率だけを変えて位置をそのままにした版でも回しました。
比べる用(拡大率だけを変え、位置はそのまま):
1回目: 表示 122% / translate(0px, 0px) scale(1.2214) / scrollY 0 / カーソルの下: (65.5, 65.5)
2回目: 表示 149% / translate(0px, 0px) scale(1.4918) / scrollY 0 / カーソルの下: (53.6, 53.6)
3回目: 表示 182% / translate(0px, 0px) scale(1.8221) / scrollY 0 / カーソルの下: (43.9, 43.9)
こちらは画像の左上を基準に広がるだけなので、ピンクの丸はカーソルから右下へ離れていきます。3回目には、カーソルの下は (43.9, 43.9) になりました。見たい場所にカーソルを合わせて回しても、その場所がカーソルから離れていきます。
ホイールで受け取る deltaY は、いつも px の単位とは限りません。MDN の deltaY の説明には、単位は deltaMode で決まるので確かめるように、とあります。この記事のコードは px として扱っていて、Chromium でしか試していません。また、Pointer Events の仕様にある、ホイールのイベントの説明によると、deltaY の正の向きはページの下端の向きです。ただし、ホイールやトラックパッドを物理的にどちらへ動かすと正になるかは、環境や機器の設定によって変わることがあります。
枠の線を border で引くと、カーソルの下がずれる
handleWheel は、カーソルの位置 (px, py) を、枠の getBoundingClientRect() の左上からの距離として求め、zoomAt() に渡しています。getBoundingClientRect() が返す四角形は border を含みます。そのため、枠の線を border で引くと、四角形の左上と画像の左上が線の太さの分だけずれ、zoomAt() が「カーソルの下」として扱う点もずれます。記事の CSS で線を outline で引いているのは、このためです。
線を 1px の border にした版で、前の節と同じようにピンクの丸の上でホイールを上へ3回回しました。
枠の getBoundingClientRect() の左上: (20, 16) / 画像の左上: (21, 17)
1回目: 表示 122% / translate(-17.9px, -17.9px) scale(1.2214) / scrollY 0 / カーソルの下: (80.2, 80.2)
2回目: 表示 149% / translate(-39.8px, -39.8px) scale(1.4918) / scrollY 0 / カーソルの下: (80.3, 80.3)
3回目: 表示 182% / translate(-66.6px, -66.6px) scale(1.8221) / scrollY 0 / カーソルの下: (80.5, 80.5)
枠の getBoundingClientRect() の左上は (20, 16) で、画像の左上 (21, 17) と 1px ずれています。拡大するたびに、カーソルの下の点がピンクの丸の (80, 80) から少しずつ離れ、3回目には (80.5, 80.5) になりました。線を outline で引いた記事のコードでは、どの回も (80, 80) のままです。
onWheel では、ページのスクロールを止められない
ホイールを回したとき、ブラウザはふつうページをスクロールします。画像の上で回したときはスクロールさせたくないので、preventDefault() を呼びます。ところが、これを JSX の onWheel の中で呼んだ版では、止められませんでした。カーソルをピンクの丸の上に置き、上へ2回、下へ1回回した結果です(ページの下に余白を足して、スクロールできるようにしています)。
記事のコード(addEventListener と passive: false):
-100 → 表示 122% / translate(-17.7px, -17.7px) scale(1.2214) / scrollY 0
-100 → 表示 149% / translate(-39.3px, -39.3px) scale(1.4918) / scrollY 0
+100 → 表示 122% / translate(-17.7px, -17.7px) scale(1.2214) / scrollY 0
比べる用(JSX の onWheel):
-100 → 表示 122% / translate(-17.7px, -17.7px) scale(1.2214) / scrollY 0
-100 → 表示 149% / translate(-39.3px, -39.3px) scale(1.4918) / scrollY 0
+100 → 表示 122% / translate(-17.7px, 0px) scale(1.2214) / scrollY 100
onWheel の版でも画像は縮小しましたが、下へ回したときにページも 100px スクロールしました(上へ回したときは、ページがいちばん上にあるのでスクロールしていません)。縦の位置も、記事のコードの -17.7px とは違う 0px になりました(この違いの原因は調べていません)。コンソールには、ホイールを回すたびに次のエラーが出ていました。
onwheel.html: [error] Unable to preventDefault inside passive event listener invocation.
React は、イベントのリスナーを要素ごとではなく、ルート(createRoot() に渡した要素)にまとめて登録しています。React の ReactDOMRoot.js で、ルートの要素に対して listenToAllSupportedEvents() を呼んでいます。React の DOM のソースの DOMPluginEventSystem.js を読むと、touchstart・touchmove・wheel の3つは、passive を付けて登録されています。ブラウザが document に対してこの3つを最初から passive にしているのに合わせた、とコメントにあります。MDN の addEventListener() の説明によると、passive のリスナーはイベントを取り消せないので、ブラウザはリスナーの終わりを待たずにスクロールを始めます。リスナーの中で preventDefault() を呼んでも何も起きません。
そこで記事のコードでは、useEffect の中で枠の要素に addEventListener('wheel', handleWheel, { passive: false }) で直接登録しています。MDN にもあるとおり、passive でないことをはっきりさせたいときは、false を明示して指定します。後片付けの関数で removeEventListener() を呼んで、外しています。
preventDefault() を呼ぶのは枠の上でホイールを回したときだけなので、枠の外では、ページはふつうにスクロールします。
枠の下でホイール(deltaY +100): 表示 100% / translate(0px, 0px) scale(1) / scrollY 100
記事のコードは、拡大率が 100% のときに下へ回しても preventDefault() を呼びます(「カーソルの下の場所を動かさずに拡大する」の節の「下へ(deltaY +100)4回目」でも、scrollY は 0 のままでした)。そのため、カーソルが画像の上にある間は、ページをホイールでスクロールできません。縦に長いページに大きな画像を置くときは、この動きでよいかを考えて決めてください。
addEventListener で登録したら、state は更新用の関数で読む
wheel のリスナーを登録する useEffect の依存配列は [width, height] です。枠の大きさが変わらないかぎり、最初に登録した handleWheel がずっと使われます。そのため、この関数の中で view を直接読むと、最初のレンダリングのときの値(拡大率 1)のままです。
記事のコードでは、setView に更新用の関数を渡し、引数の v で今の値を受け取っています。公式リファレンスにある、Effect の中で前の state をもとに更新する例と同じ書き方です。比べるために、setView(zoomAt(view, view.scale * factor, ...)) と view を直接読む版で、上へ3回回しました。
比べる用(更新用の関数を使わず、view をそのまま読む):
1回目: 表示 122% / translate(-17.7px, -17.7px) scale(1.2214) / scrollY 0 / カーソルの下: (80, 80)
2回目: 表示 122% / translate(-17.7px, -17.7px) scale(1.2214) / scrollY 0 / カーソルの下: (80, 80)
3回目: 表示 122% / translate(-17.7px, -17.7px) scale(1.2214) / scrollY 0 / カーソルの下: (80, 80)
何回回しても 122% から先へ進みません。どの回も、拡大率 1 の view をもとに計算しているためです。記事のコードでは、3回で 182% まで拡大しました。
ドラッグで動かす
ドラッグは、押したときに setPointerCapture() を呼び、押した位置とそのときの view を ref の drag に覚えておきます。動かすたびに、押した位置からの移動量を、覚えておいた x・y に足します。setPointerCapture() を呼んでおくと、ポインターが枠の外へ出ても、離すまで枠にイベントが届きます。
182% に拡大してから、マウスでドラッグしました。
ドラッグの前: 表示 182% / translate(-65.8px, -65.8px) scale(1.8221) / scrollY 0
右へ30px・下へ20px動かしている間: 表示 182% / translate(-35.8px, -45.8px) scale(1.8221) / scrollY 0
離したあと: 表示 182% / translate(-35.8px, -45.8px) scale(1.8221) / scrollY 0
左へ150px・下へ300px(枠の外まで)動かしている間: 表示 182% / translate(-185.8px, 0px) scale(1.8221) / scrollY 0
離したあと: 表示 182% / translate(-185.8px, 0px) scale(1.8221) / scrollY 0
右へ400px・上へ400px動かして離したあと: 表示 182% / translate(0px, -197.3px) scale(1.8221) / scrollY 0
右へ 30px、下へ 20px 動かすと、x・y もちょうどその分だけ変わりました。下へ 300px 動かしたときは、枠の外まで出ても付いてきて、y は 0 で止まっています。画像の上端が枠の上端に来たところで、それ以上は下がりません。右上へ大きく動かしたときは、x が 0、y が「240 − 240 × 1.8221」の -197.3px で止まりました。
タッチでも、拡大ボタンで 225% にしてから、CDP で指を右へ 40px、下へ 30px 動かしました。pointercancel は届かず、画像はその分だけ動いています。
拡大ボタンを2回: 表示 225% / translate(-200px, -150px) scale(2.25) / scrollY 0
指で右へ40px・下へ30px動かす: 表示 225% / translate(-160px, -120px) scale(2.25) / scrollY 0 / イベント: pointerdown pointerup
ホイールを使えない環境のための拡大ボタン
タッチだけの端末や、キーボードだけで操作するときは、ホイールで拡大できません。そこで、枠の下に「縮小」「拡大」「元に戻す」のボタンを置きました。ボタンでは、枠の中心を基準に 1.5 倍ずつ拡大・縮小します。倍率は output 要素に出し、aria-live="polite" を付けました(スクリーンリーダーでの読み上げは試していません)。
元に戻す: 表示 100% / translate(0px, 0px) scale(1) / scrollY 0 / 縮小ボタン disabled: true
拡大を1回: 表示 150% / translate(-80px, -60px) scale(1.5) / scrollY 0 / 枠の中心の下: (160, 120)
拡大をさらに5回: 表示 800% / translate(-1120px, -840px) scale(8) / scrollY 0 / 拡大ボタン disabled: true
拡大しても、枠の中心の下は画像の中心 (160, 120) のままです。100% のときは「縮小」が、800% のときは「拡大」が押せなくなります。
キーボードでも試しました。「拡大」にフォーカスして Enter を押すと 150% になり、Tab で「元に戻す」へ移って Space を押すと 100% に戻りました。
「拡大」にフォーカスして Enter: 表示 150% / translate(-80px, -60px) scale(1.5) / scrollY 0
Tab のあとのフォーカス: 元に戻す
Space: 表示 100% / translate(0px, 0px) scale(1) / scrollY 0





