ビフォーアフター スライダー UI 9選 — ドラッグで比較
ビフォーアフター スライダーは2枚の写真を重ね、動かせる境界線で前後を並べて見せるUIです。境界線はクリックやドラッグにリアルタイムで反応する必要があるため、CSSだけでは作れずJSが数行必要です。
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 クリック比較スライダー
- 02 ドラッグハンドル比較スライダー
- 03 タッチスワイプ比較スライダー
- 04 キーボード対応比較スライダー
- 05 縦方向比較スライダー
- 06 BEFORE・AFTERラベルバッジ
- 07 キャプションオーバーレイ比較スライダー
- 08 ズーム同期比較スライダー
- 09 複数ペア比較カルーセル
9つは見た目の順番ではなく、このスライダーが実際に使われる順番で並べました。まずクリックで動かしてみて → 指でずっと引っぱってみて → スマホで押してみて → キーボードだけで操作できるか確認して → 向きを縦に変えてみて → ラベルを付けてみて → キャプションを添えてみて → 拡大してみて → 最後に複数組を1ページに並べます。
01クリック比較スライダー
写真のどこをクリックしても、その位置に境界線がすぐ移動します。ドラッグ不要の最も単純な形なので、まず試すのに向いています。
el.addEventListener("click", function (e) {
el.classList.remove("is-demo");
var r = el.getBoundingClientRect();
var pct = ((e.clientX - r.left) / r.width) * 100;
el.style.setProperty("--reveal", Math.max(0, Math.min(100, pct)) + "%");
});
02ドラッグハンドル比較スライダー
中央の丸いハンドルを指のように引っぱると、縦線がリアルタイムで追従します。多くの商品ページが採用する基本形です。
grip.addEventListener("pointerdown", function (e) {
el.classList.remove("is-demo");
dragging = true;
grip.setPointerCapture(e.pointerId);
set(fromX(e.clientX));
});
grip.addEventListener("pointermove", function (e) {
if (dragging) set(fromX(e.clientX));
});
03タッチスワイプ比較スライダー
細いハンドルだけでなくスライダー全体がタッチ対象になり、グリップも48pxまで広げているので指先が太くても確実に当たります。
.ba {
touch-action: none;
cursor: grab;
}
.ba__grip {
width: $sp-12; // 48px — 指先の太さを考えた最小タッチサイズ
height: $sp-12;
border-radius: $r-pill;
}
04キーボード対応比較スライダー
Tabでハンドルにフォーカスし、矢印キーで5%ずつ、Home・Endで両端まで移動します。マウスがなくても前後を比較できます。
el.addEventListener("keydown", function (e) {
el.classList.remove("is-demo");
if (e.key === "ArrowLeft") { set(cur() - 5); e.preventDefault(); }
else if (e.key === "ArrowRight") { set(cur() + 5); e.preventDefault(); }
else if (e.key === "Home") { set(0); e.preventDefault(); }
else if (e.key === "End") { set(100); e.preventDefault(); }
});
05縦方向比較スライダー
境界線が左右ではなく上下に動くので、縦長の写真を回転させずにそのまま比較できます。
.ba__before {
clip-path: inset(0 0 calc(100% - var(--reveal)) 0);
}
.ba__handle {
top: var(--reveal);
height: $sp-1;
transform: translateY(-50%);
}
06BEFORE・AFTERラベルバッジ
左右上部に固定されたピルバッジが、境界線の位置に合わせてopacityが変化し、今どちら側を見ているか迷いません。
function set(pct) {
pct = Math.max(0, Math.min(100, pct));
// 位置(%)と0〜1の数値を別々に登録し、opacityの計算は数値側を使う
el.style.setProperty("--reveal", pct + "%");
el.style.setProperty("--reveal-num", (pct / 100).toFixed(3));
}
07キャプションオーバーレイ比較スライダー
下部に半透明のグラデーション帯が敷かれ「3週間経過」のようなキャプションが添えられ、何を比較している写真か言葉でも伝えます。
.ba__caption {
position: absolute;
left: 0; right: 0; bottom: 0;
padding: $sp-6 $sp-4 $sp-3;
background: linear-gradient(to top, rgba(23,20,26,.78) 0%, rgba(23,20,26,0) 100%);
color: $color;
font: 700 13px/1.3 system-ui, sans-serif;
}
08ズーム同期比較スライダー
スライダー上でホイールを回すと前後2枚が同じ倍率・同じ中心点で一緒に拡大され、細部を拡大しても比較がずれません。
el.addEventListener("wheel", function (e) {
el.classList.remove("is-demo");
e.preventDefault();
var cur = parseFloat(getComputedStyle(el).getPropertyValue("--zoom")) || 1;
setZoom(cur + (e.deltaY < 0 ? 0.15 : -0.15));
}, { passive: false });
09複数ペア比較カルーセル
scroll-snapカルーセルの中に前後スライダー3組を並べ、IntersectionObserverで画面中央に来ているスライドだけドラッグが有効になります。
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (en) {
en.target.classList.toggle("is-active", en.intersectionRatio > 0.6);
});
}, { root: track, threshold: [0.6] });
panes.forEach(function (p) { io.observe(p); });
どこで壊れるか — 古いブラウザは滑らかに動かない
9つとも --reveal というカスタムプロパティ一つで境界線の位置を管理しています。この値を @property で <percentage> 型として登録して初めて、自動プレビューの境界線が20%から80%まで滑らかに動きます — 登録しないままキーフレームでカスタムプロパティを動かすと、ブラウザが値を補間できずカクカクと飛びます。幸い実際のドラッグ・クリック操作はJSがインラインスタイルに値を直接書き込むため、この登録に対応しない古いブラウザでも手での操作自体は問題なく動きます。制作中、縦型(05番)は480×300の舞台では問題なかったのに、320px幅で確認すると実際に高さがはみ出し、カード幅を200pxから120pxまで縮める必要がありました — 横型より縦型のほうが、狭い画面で先にあふれます。この320px確認は9項目すべてに毎回行っていて、はみ出しがあれば数値でそのまま分かるので見た目だけで判断しません。9ファイルの圧縮パスワードはdv4hytn4で、zipにはどの項目もvanilla版とReact版が並んで入っています。
アクセシビリティ — reduced-motionで9つが残すもの
prefers-reduced-motion: reduce を指定すると、9つとも自動プレビューのループ(is-demoアニメーション)が止まり、境界線は50%で固定されます。04番はもともとキーボードだけで全域を操作できるため、アニメーションを止めても機能は減りません。08番のズームも同じ規則で、動きを抑えると倍率が1に固定されます。
FAQ
ビフォーアフター スライダーにはJavaScriptが必須ですか?
はい。境界線はクリック・ドラッグ・キー入力といったリアルタイムの入力に追従する必要があり、--reveal を更新し続けるコードが要ります。9つとも12〜34行の短いバニラJSだけで書いています。
写真はどう差し替えますか?
.ba__after・.ba__before の background に url(パス) を指定するか、<img> タグに変えて object-fit: cover を指定します。zip内のREADMEに両方の方法を書いています。
縦型と横型を同じページに混在させても大丈夫ですか?
大丈夫です。--reveal と clip-path の軸が違うだけなので、クラス名さえ衝突しなければ同じページに共存できます。
clip-pathの仕組みはMDNのclip-pathページに、ポインターキャプチャはMDNのPointer eventsページにまとまっています。複数の写真を並べる別の方法はCSS フォト ギャラリー 9選で、実用UIはUIカテゴリーでさらに見られます。