Reactで、2枚の画像をつまみで見比べる表示を作る方法

​​React​CSS,Playwright,Pointer Events,TypeScript,アクセシビリティ

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、スマートフォンのブラウザ、スクリーンリーダーでの読み上げも試していません。

つまみを右へドラッグするとモノクロの範囲が広がり、画像の左のほうをクリックすると境目がそこへ移り、→ キーを5回押すと境目が少しずつ右へ動く
3回の操作で境目が動く様子。変わった数字だけピンクにしている(カーソルと操作の表示は GIF 用に重ねたもの)

2枚の画像を重ね、上の画像を clip-path で切る

ImageCompare は、下に敷く画像(after)と上に重ねる画像(before)を受け取ります。境目の位置 position は、左端を 0、右端を 100 とする % で持ちます。

tsx
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 で描いています。

css
.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 で受け取った位置を画面に出しています。

tsx
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)での行の位置です。白い縦線は、撮るときだけ見えなくしました。

text
画面上の画像の大きさ: 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 ではなく % で持っているためです。

text
幅 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 を作りました。

tsx
// 比べるための書き方: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>
  );
}
text
画像全体: 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 で右側を隠した表示で、境目の左右で絵がつながっている。下は包む要素の幅を 30% にした表示で、左上にモノクロの風景が小さく縮んで重なっている
境目が 30% のときの2つの書き方。下の書き方では、上の画像が縮んで絵がずれる

包む書き方で縮まないようにするには、中の画像の幅を全体の幅と同じにする指定を、別に加える必要があります。clip-path なら、画像の大きさには手を付けずに済みます。

ドラッグとクリックは、まとめて親の要素で受ける

ポインターのイベントは、つまみではなく全体を包む .compare で受けています。こうすると、つまみをつかんだときも、画像のほかの所を押したときも、押した位置へ境目が移り、そのままドラッグを続けられます。つまみから離れた 10% の所(画像の上のほう)を押して 60% の所までドラッグすると、境目は 60% になりました。

押したときに呼んでいる setPointerCapture() は、そのポインターのイベントを、ボタンを離すまで、呼び出した対象の要素に送り続けるためのものです。呼ばない場合と比べました(setPointerCapture を何もしない関数に置き換えたページを使っています)。

text
あり: 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() を何もしない関数に置き換えたページと比べた結果です。

text
あり: 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% ずつ動くのは、この記事で決めた動きです。

text
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 で戻ると、フォーカスはつまみに戻りました。

​Posted by 86