ビフォーアフタースライダーの実装 - 画像比較 UI の設計と最適化
画像比較スライダーの用途と設計要件
画像比較スライダー (Before/After Slider) は、2 枚の画像を重ね合わせ、ドラッグ操作で境界線を移動させることで視覚的に差異を確認できる UI コンポーネントです。写真編集のビフォーアフター、画像圧縮の品質比較、Web デザインの新旧比較、医療画像の経時変化など、幅広い場面で活用されています。
設計上の要件:
- 直感的な操作性: ユーザーが説明なしで操作方法を理解できること。スライダーのハンドルが視覚的に「ドラッグ可能」であることを示す
- レスポンシブ対応: デスクトップのマウス操作とモバイルのタッチ操作の両方に対応する。画面幅に応じてコンポーネントサイズが適切にスケールする
- パフォーマンス: ドラッグ中に 60fps を維持する。リペイントを最小限に抑え、GPU アクセラレーションを活用する
- アクセシビリティ: キーボード操作 (矢印キー) に対応し、スクリーンリーダーで状態を伝達する。ARIA 属性を適切に設定する
- 画像の同期: 2 枚の画像が完全に同じ位置・サイズで重なること。アスペクト比の異なる画像への対応方針を決める
実装アプローチは大きく 3 つあります: CSS clip-path 方式、overflow: hidden + 幅制御方式、Canvas 描画方式です。それぞれにトレードオフがあり、用途に応じて最適な方式を選択します。本記事では最もパフォーマンスに優れた clip-path 方式を中心に解説します。
HTML 構造とセマンティクス - アクセシブルなマークアップ
画像比較スライダーの HTML 構造は、セマンティクスとアクセシビリティを考慮して設計します。スクリーンリーダーユーザーにも比較の意図が伝わり、キーボードで操作可能な構造にします。
推奨 HTML 構造:
<div class="comparison-slider" role="group" aria-label="画像比較"><div class="comparison-slider__before"><img src="before.webp" alt="処理前の画像" /><span class="comparison-slider__label">Before</span></div><div class="comparison-slider__after"><img src="after.webp" alt="処理後の画像" /><span class="comparison-slider__label">After</span></div><div class="comparison-slider__handle" role="slider" aria-label="比較位置" aria-valuemin="0" aria-valuemax="100" aria-valuenow="50" tabindex="0"></div></div>
マークアップのポイント:
- role="group": コンテナに設定し、内部要素が関連するグループであることを示す
- role="slider": ハンドル要素に設定し、スライダーコントロールであることをスクリーンリーダーに伝える
- aria-valuemin/max/now: スライダーの現在位置をパーセンテージで伝達する。JavaScript で動的に更新する
- tabindex="0": ハンドルをフォーカス可能にし、キーボード操作を受け付ける
- alt 属性: 各画像に適切な代替テキストを設定し、画像が表示されない環境でも内容を伝える
ラベル表示は CSS の position: absolute で画像の左上/右上に配置し、スライダー操作時に隠れないよう z-index を調整します。ラベルのフォントサイズはコンテナ幅に応じて clamp(0.75rem, 2vw, 1rem) でスケールさせると、レスポンシブ環境で自然に見えます。
CSS 実装 - clip-path によるパフォーマンス最適化
clip-path を使用した実装は、GPU アクセラレーションが効きやすく、ドラッグ中のパフォーマンスに最も優れています。clip-path の変更はレイアウト再計算を発生させず、コンポジットレイヤーの更新のみで済むためです。
基本 CSS:
.comparison-slider { position: relative; overflow: hidden; cursor: col-resize; } .comparison-slider__before, .comparison-slider__after { position: absolute; inset: 0; } .comparison-slider__before img, .comparison-slider__after img { display: block; width: 100%; height: 100%; object-fit: cover; } .comparison-slider__after { clip-path: inset(0 0 0 50%); } .comparison-slider__handle { position: absolute; top: 0; bottom: 0; left: 50%; width: 4px; background: white; transform: translateX(-50%); box-shadow: 0 0 8px rgba(0,0,0,0.3); }
パフォーマンス最適化のポイント:
- will-change: clip-path: After 要素に設定し、ブラウザにアニメーション対象であることを事前通知する。ただし常時設定するとメモリ消費が増えるため、ドラッグ開始時に追加し終了時に削除する
- contain: layout: コンテナに設定し、内部の変更が外部レイアウトに影響しないことをブラウザに伝える
- GPU レイヤー昇格:
transform: translateZ(0)でハンドル要素を独立したコンポジットレイヤーに昇格させ、移動時のリペイントを回避する
レスポンシブ対応: コンテナに aspect-ratio: 16/9 (または画像のアスペクト比) を設定し、幅 100% で高さを自動計算させます。画像は object-fit: cover でコンテナに合わせて表示し、アスペクト比が異なる画像でも破綻しない設計にします。
JavaScript 実装 - ドラッグ操作とイベント処理
スライダーのインタラクションを JavaScript で実装します。マウスイベントとタッチイベントの両方に対応し、Pointer Events API を使用することで統一的に処理できます。
コア実装のポイント:
- Pointer Events API:
pointerdown、pointermove、pointerupを使用する。マウス、タッチ、ペンを 1 つの API で統一的に処理できる。setPointerCapture()でハンドル外に移動してもイベントを受け取り続ける - 位置計算:
event.clientX - container.getBoundingClientRect().leftでコンテナ内の相対位置を算出し、コンテナ幅で割ってパーセンテージ (0-100) に変換する - requestAnimationFrame:
pointermoveイベントは高頻度で発火するため、直接 DOM を更新せずrequestAnimationFrameでフレームごとに 1 回だけ更新する - 境界制限: パーセンテージを
Math.max(0, Math.min(100, percent))でクランプし、スライダーがコンテナ外に出ないようにする
キーボード操作の実装:
handle.addEventListener('keydown', (e) => { if (e.key === 'ArrowLeft') updatePosition(currentPercent - 1); if (e.key === 'ArrowRight') updatePosition(currentPercent + 1); if (e.key === 'Home') updatePosition(0); if (e.key === 'End') updatePosition(100); });
DOM 更新関数:
function updatePosition(percent) { percent = Math.max(0, Math.min(100, percent)); afterEl.style.clipPath = `inset(0 0 0 ${percent}%)`; handleEl.style.left = `${percent}%`; handleEl.setAttribute('aria-valuenow', Math.round(percent)); }
タッチデバイスでの注意点: touch-action: none をコンテナに設定し、ブラウザのデフォルトスクロール動作を抑制します。ただし、垂直スクロールは許可したい場合は touch-action: pan-y に変更し、水平方向のみスライダーが反応するようにします。
高度な機能 - アニメーション、遅延読み込み、複数インスタンス
基本実装に加え、ユーザー体験を向上させる高度な機能を実装します。
初期アニメーション (Onboarding):
- ページ読み込み時にスライダーが左右に揺れるアニメーションを再生し、操作可能であることをユーザーに示す
- CSS:
@keyframes hint { 0%,100% { left: 50% } 25% { left: 35% } 75% { left: 65% } } - アニメーションはユーザーが初めてインタラクションした時点で停止する (
animation: noneを設定) - IntersectionObserver でビューポートに入った時にのみアニメーションを開始し、不要な処理を避ける
画像の遅延読み込み:
- 比較スライダーは通常ページ下部に配置されるため、
loading="lazy"で画像を遅延読み込みする - 画像読み込み完了前はプレースホルダー (アスペクト比を維持した灰色ボックス) を表示し、CLS (Cumulative Layout Shift) を防止する
- 両方の画像が読み込み完了してからスライダーを有効化する:
Promise.all([img1.decode(), img2.decode()]).then(enableSlider)
複数インスタンスの管理:
- 1 ページに複数のスライダーを配置する場合、各インスタンスを独立して管理する
- クラスベースの実装:
class ComparisonSlider { constructor(el) { this.container = el; this.init(); } } - 初期化:
document.querySelectorAll('.comparison-slider').forEach(el => new ComparisonSlider(el)) - メモリリーク防止: SPA でコンポーネントが破棄される際に
destroy()メソッドでイベントリスナーを解除する
縦方向スライダー: clip-path: inset(50% 0 0 0) で上下分割にし、ハンドルを水平に配置することで縦方向の比較も実現できます。data-direction="vertical" 属性で方向を切り替える設計にすると汎用性が高まります。
既存ライブラリの比較と選定基準
自前実装の代わりに、既存のライブラリを活用する選択肢もあります。プロジェクトの要件に応じて、自前実装とライブラリ利用のどちらが適切かを判断します。
主要ライブラリの比較:
- img-comparison-slider (Web Component): バンドルサイズ 3.5KB (gzip)。Web Component として実装されており、フレームワーク非依存。
<img-comparison-slider>タグで宣言的に使用可能。キーボード操作とアクセシビリティに標準対応。最も軽量で推奨 - TwentyTwenty (jQuery): jQuery 依存のレガシーライブラリ。機能は十分だが、jQuery を使用していないプロジェクトでは不適切。バンドルサイズ 5KB + jQuery 87KB
- Cocoen: バニラ JavaScript 実装。2KB (gzip) と軽量。タッチ対応済み。ただしアクセシビリティ対応が不十分 (ARIA 属性なし)
- React Compare Image: React 専用コンポーネント。TypeScript 対応。Props でカスタマイズ可能だが、React プロジェクト以外では使用不可
選定基準:
- バンドルサイズ重視: img-comparison-slider (3.5KB) または Cocoen (2KB)
- アクセシビリティ重視: img-comparison-slider (ARIA 完全対応)
- React プロジェクト: React Compare Image
- カスタマイズ性重視: 自前実装 (本記事の方式)
自前実装を選ぶべきケース:
- デザインシステムに完全に統合する必要がある場合
- 特殊な操作 (ピンチズーム、回転、3 枚以上の比較) が必要な場合
- パフォーマンス要件が厳しく、不要なコードを一切含めたくない場合
- 既存ライブラリのアクセシビリティ対応が不十分な場合
いずれの場合も、Core Web Vitals への影響を計測し、LCP (Largest Contentful Paint) を遅延させないよう画像の最適化 (WebP/AVIF 使用、適切なサイズ指定) を併せて実施してください。