Reactで、2枚の画像をつまみで見比べる表示を作る方法
2枚の画像を同じ位置に重ね、上の画像の右側を CSS の clip-path で隠せば、境目のつまみを左右に動かして見比べる表示になります。React で持つのは境目の位置(幅に対する %)だけです。画像そのものの大きさは変えないので、境目をどこに動かしても2枚の絵はずれません。
ここでは、つまみのドラッグと画像のクリック、矢印キーで境目を動かせる ImageCompare を作りました。あわせて、上の画像を包む要素の幅を変える書き方と比べ、setPointerCapture() や preventDefault() を呼ばないと何が起きるかも確かめています。
検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、Playwright 1.63.0 で、Playwright でマウスとキーボードを操作して確かめました。画像は、写真ではなく Pillow で描いた 1200×800 の PNG を、カラーとモノクロの2枚にしたものです。2枚は同じ大きさにしてあり、大きさの違う画像を重ねる場合は試していません。タッチ操作、Firefox と Safari、スマートフォンのブラウザ、スクリーンリーダーでの読み上げも試していません。

2枚の画像を重ね、上の画像を clip-path で切る
ImageCompare は、下に敷く画像(after)と上に重ねる画像(before)を受け取ります。境目の位置 position は、左端を 0、右端を 100 とする % で持ちます。
import { useRef, useState } from 'react';
import type { KeyboardEvent, PointerEvent } from 'react';
type Props = {
before: string;
after: string;
beforeAlt: string;
afterAlt: string;
// 境目の最初の位置(左端からの %)
initial?: number;
onChange?: (position: number) => void;
};
const clamp = (n: number, min: number, max: number) => Math.min(Math.max(n, min), max);
export function ImageCompare({ before, after, beforeAlt, afterAlt, initial = 50, onChange }: Props) {
// 境目の位置は、幅に対する % で持つ(画面の幅が変わっても同じ所を指す)
const [position, setPosition] = useState(initial);
const rootRef = useRef<HTMLDivElement>(null);
const handleRef = useRef<HTMLDivElement>(null);
const dragging = useRef(false);
function update(next: number) {
const p = clamp(next, 0, 100);
setPosition(p);
onChange?.(p);
}
// ポインターの位置を、幅に対する % に直す
function toPercent(e: PointerEvent) {
const r = rootRef.current!.getBoundingClientRect();
return ((e.clientX - r.left) / r.width) * 100;
}
function handlePointerDown(e: PointerEvent<HTMLDivElement>) {
if (e.button !== 0) return;
dragging.current = true;
// ポインターが画像の外へ出ても、離すまでこの要素にイベントが届くようにする
e.currentTarget.setPointerCapture(e.pointerId);
// 押した所へ境目を動かし、続けて矢印キーでも動かせるようにつまみへフォーカスする
update(toPercent(e));
handleRef.current?.focus();
e.preventDefault();
}
function handlePointerMove(e: PointerEvent<HTMLDivElement>) {
if (dragging.current) update(toPercent(e));
}
function handlePointerUp() {
dragging.current = false;
}
// 矢印キーで 1% ずつ、Shift を押しながらなら 10% ずつ。Home と End で端へ
function handleKeyDown(e: KeyboardEvent) {
const step = e.shiftKey ? 10 : 1;
const next = {
ArrowLeft: position - step,
ArrowDown: position - step,
ArrowRight: position + step,
ArrowUp: position + step,
Home: 0,
End: 100,
}[e.key];
if (next === undefined) return;
e.preventDefault();
update(next);
}
const rounded = Math.round(position);
return (
<div
ref={rootRef}
className="compare"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
>
{/* 下に敷く画像。この画像の大きさが、全体の大きさになる */}
<img src={after} alt={afterAlt} />
{/* 上に重ねる画像。右側を clip-path で隠し、境目より左だけを見せる */}
<img
className="compare-before"
src={before}
alt={beforeAlt}
style={{ clipPath: `inset(0 ${100 - position}% 0 0)` }}
/>
<div
ref={handleRef}
className="compare-handle"
role="slider"
tabIndex={0}
aria-label="2枚の画像の境目"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={rounded}
aria-valuetext={`左の画像を ${rounded}% 表示`}
onKeyDown={handleKeyDown}
style={{ left: `${position}%` }}
/>
</div>
);
}
上の画像に付けている inset() は、要素の上・右・下・左の各辺から、指定した分だけ内側に入った長方形を作る関数です。clip-path に渡すと、その長方形の外は描かれません。右だけを 100 - position % 削るので、境目より左だけが見えます。
ドラッグ中かどうかは、useRef で作った ref に入れています。描画には使わない値なので、ref に置けば書き換えても再レンダリングは起きません。
見た目は CSS で付けます。上の画像を position: absolute と inset: 0 で下の画像にぴったり重ね、境目の縦線と丸いつまみは .compare-handle とその ::after で描いています。
.compare {
position: relative;
max-width: 600px;
overflow: hidden;
cursor: ew-resize;
/* タッチでドラッグしたときに、ページが横にスクロールしないようにする */
touch-action: pan-y;
user-select: none;
}
.compare img { display: block; width: 100%; height: auto; }
/* 上に重ねる画像は、下の画像とぴったり同じ位置・大きさにする */
.compare .compare-before {
position: absolute;
inset: 0;
height: 100%;
object-fit: cover;
}
/* 境目の縦線と、まん中の丸いつまみ */
.compare-handle {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
margin-left: -1px;
background: #fff;
}
.compare-handle::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 32px;
height: 32px;
margin: -16px 0 0 -16px;
border: 2px solid #fff;
border-radius: 50%;
background: #067a98;
box-sizing: border-box;
}
.compare-handle:focus-visible { outline: none; }
.compare-handle:focus-visible::after { box-shadow: 0 0 0 3px #00c8e8; }
touch-action: pan-y は、タッチでの縦のスクロールだけをブラウザに任せる指定です(touch-action の説明)。横に指を動かしたときはスクロールにならず、ポインターのイベントとして届く想定ですが、筆者はタッチ操作では試していません。
使う側では、onChange で受け取った位置を画面に出しています。
import { useState } from 'react';
import { ImageCompare } from './ImageCompare';
export function App() {
const [position, setPosition] = useState(50);
return (
<main>
<ImageCompare
before="before.png"
after="after.png"
beforeAlt="モノクロの風景"
afterAlt="カラーの風景"
onChange={setPosition}
/>
<p className="compare-info">モノクロの範囲:{Math.round(position)}%</p>
</main>
);
}
境目が、本当にその位置にあるかを画素で確かめる
画像を 600×400 で表示してスクリーンショットを撮り、横1行の画素を左から調べました。モノクロの画素(R・G・B の差が 2 以下)が左から何 px 続くかを数えています。出力の y=100 は、元の画像(高さ 800)での行の位置です。白い縦線は、撮るときだけ見えなくしました。
画面上の画像の大きさ: 600×400
モノクロの範囲:50% / aria-valuenow 50
y=100 の行: 幅 600px のうち、左から 300px がモノクロ(それより右にモノクロの画素: 0)
画像の左から 25% の所をクリック: モノクロの範囲:25% / aria-valuenow 25
y=100 の行: 幅 600px のうち、左から 150px がモノクロ(それより右にモノクロの画素: 0)
つまみを 80% の所までドラッグ中: モノクロの範囲:80% / aria-valuenow 80
y=600 の行: 幅 600px のうち、左から 480px がモノクロ(それより右にモノクロの画素: 0)
50% なら 300px、25% なら 150px、80% なら 480px で、境目は指定した位置ぴったりでした。
画面の幅を 640px から 360px に変えたときも、境目は同じ割合の所に残りました。位置を px ではなく % で持っているためです。
幅 640px: モノクロの範囲:30% / aria-valuenow 30 / 画像の幅 600px、縦線の中心は左から 30.0% / y=100 の行: 幅 600px のうち、左から 180px がモノクロ(それより右にモノクロの画素: 0)
幅 360px に変更: モノクロの範囲:30% / aria-valuenow 30 / 画像の幅 320px、縦線の中心は左から 30.0% / y=100 の行: 幅 320px のうち、左から 96px がモノクロ(それより右にモノクロの画素: 0)
包む要素の幅を変える書き方だと、上の画像が縮む
clip-path を使わずに、上の画像を overflow: hidden の要素で包み、その要素の幅を境目の位置にする書き方も考えられます。比べるために、境目を 30% に固定した WidthCompare を作りました。
// 比べるための書き方:clip-path の代わりに、上の画像を包む要素の幅を変える
import { useState } from 'react';
export function WidthCompare({ before, after }: { before: string; after: string }) {
const [position] = useState(30);
return (
<div className="compare">
<img src={after} alt="カラーの風景" />
<div className="width-wrap" style={{ position: 'absolute', inset: 0, width: `${position}%`, overflow: 'hidden' }}>
<img className="width-before" src={before} alt="モノクロの風景" />
</div>
</div>
);
}
画像全体: 600×400 / 上の画像: 180×120
y=100 の行: 幅 600px のうち、左から 180px がモノクロ(それより右にモノクロの画素: 0)
clip-path の場合の上の画像: 600×400
モノクロの範囲は、clip-path で 30% にしたときと同じ 180px ですが、上の画像は 180×120 に縮んでいました。画像に width: 100% を付けているので、包む要素の幅に合わせて小さくなったのです。clip-path の場合は、上の画像は 600×400 のままで、描く範囲だけが変わります。

包む書き方で縮まないようにするには、中の画像の幅を全体の幅と同じにする指定を、別に加える必要があります。clip-path なら、画像の大きさには手を付けずに済みます。
ドラッグとクリックは、まとめて親の要素で受ける
ポインターのイベントは、つまみではなく全体を包む .compare で受けています。こうすると、つまみをつかんだときも、画像のほかの所を押したときも、押した位置へ境目が移り、そのままドラッグを続けられます。つまみから離れた 10% の所(画像の上のほう)を押して 60% の所までドラッグすると、境目は 60% になりました。
押したときに呼んでいる setPointerCapture() は、そのポインターのイベントを、ボタンを離すまで、呼び出した対象の要素に送り続けるためのものです。呼ばない場合と比べました(setPointerCapture を何もしない関数に置き換えたページを使っています)。
あり: 50% の所から画像の右の外までドラッグして離す: モノクロの範囲:100% / aria-valuenow 100 / 押さずに 20% の所へ戻る: モノクロの範囲:100% / aria-valuenow 100
なし: 50% の所から画像の右の外までドラッグして離す: モノクロの範囲:98% / aria-valuenow 98 / 押さずに 20% の所へ戻る: モノクロの範囲:20% / aria-valuenow 20
呼ばない場合は、画像の外へ出たところでイベントが届かなくなり、境目は 98% で止まりました。98% は、Playwright で 8% 刻みに動かしたときに画像の中で最後に届いた位置で、100% の端までは届きませんでした。さらに、画像の外で離したことも伝わらないので、ボタンを押さずに画像の上へ戻っただけで、境目がポインターについてきました。呼んだ場合は、画像の外までドラッグすると境目は 100% の端で止まり、離したあとは動きません。
pointerdown で preventDefault() を呼んで、つまみのフォーカスを保つ
画像を押したあと、続けて矢印キーで細かく動かせるように、handlePointerDown の中でつまみの focus() を呼んでいます。ただし、それだけではフォーカスが残りませんでした。preventDefault() を何もしない関数に置き換えたページと比べた結果です。
あり: 25% の所をクリック → フォーカス: compare-handle / mousedown: なし / 続けて → を2回: モノクロの範囲:27% / aria-valuenow 27
なし: 25% の所をクリック → フォーカス: BODY / mousedown: mousedown / 続けて → を2回: モノクロの範囲:25% / aria-valuenow 25
preventDefault() を呼ばない場合は、pointerdown のあとに mousedown も届き、クリックのあとのフォーカスは body にありました。そのため、→ キーを押しても境目は動きません。ブラウザは、マウスだけを前提にしたページでも動くように、ポインターの操作に対応するマウスのイベント(mousedown など)も送ることがあります。pointerdown で preventDefault() を呼ぶと、その一部を送らないようにできます(MDN の Pointer Events のページにある、マウスのイベントとの互換性の説明)。呼んだ場合は mousedown が届かず、フォーカスはつまみに残りました。
キーボードと読み上げのための属性
つまみの div には、slider のロールと、aria-valuemin・aria-valuemax・aria-valuenow を付けています。MDN は、できれば <input type="range"> を使うよう勧めていますが、ここではつまみを画像の上に重ねて自由に描くために div で作りました。input 要素を使わずにスライダーを作るときは、tabindex でフォーカスできるようにし、値が変わるたびに aria-valuenow を更新します。数字だけでは何の値か伝わりにくいので、aria-valuetext で「左の画像を 50% 表示」のような文も渡しています。Playwright の getByRole('slider', { name: '2枚の画像の境目' }) でつまみを見つけられることは確かめましたが、実際のスクリーンリーダーでどう読まれるかは試していません。
キーは、MDN にある slider の操作に合わせ、矢印キーで 1% ずつ、Home と End で端へ動くようにしました。Shift を押しながらの矢印キーで 10% ずつ動くのは、この記事で決めた動きです。
50% の所をクリックしてから → を3回: モノクロの範囲:53% / aria-valuenow 53
Shift + ←: モノクロの範囲:43% / aria-valuenow 43
End: モノクロの範囲:100% / aria-valuenow 100
Home: モノクロの範囲:0% / aria-valuenow 0
さらに ←: モノクロの範囲:0% / aria-valuenow 0
Tab で離れて Shift+Tab で戻ったときのフォーカス: compare-handle
Home で 0% にしたあと ← を押しても、clamp で 0〜100 に収めているので 0% のままです。Tab でほかへ移ってから Shift+Tab で戻ると、フォーカスはつまみに戻りました。





