デモ
デモを読み込めませんでした。
仕様
- 用途
- カード
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
境目をはさんで、左右に補正前と補正後の街が並んでいます。ハンドルを引くと境目が動き、片方の見える範囲が広がっていきます。デモの2枚は同じ構図で、違いが明るさの差として見えるよう、補正前を暗くくすんだ色、補正後を明るい色にしました。
2枚を重ね、上の1枚を境目より右だけ clip-path の inset で切り取り、境目の線とハンドルをその位置に合わせて動かしています。境目の位置は常に整数の px に丸めます。小数のままだと縁の描かれ方が位置によって変わり、ドラッグ中に境目が揺れて見えます。つかんだハンドルは1.12倍になり、ぼかしのない影で周りに淡い輪が付きます。
実際のサイトでは、画像のどこを押しても反応させます。押した時点で setPointerCapture でポインタを捕まえ、押した位置へ境目を移してから動きを追うので、途中で指やマウスが画像の外へはみ出しても境目は付いてきます。Draggable で組むなら、横方向だけに動かし、動ける範囲を画像の要素の中に限ります。キーボードでは、role を slider にしたハンドルを ← → キーで動かせます。
単色のパネルが横切って画像を見せる演出と違い、ここで境目を動かすのは見る人自身です。レタッチやリフォームの前後のように、違いを自分の目で確かめてもらいたい比較なら、納得するまで何度でも行き来してもらえます。
コード
<figure class="compare">
<!-- 下が「後」、上が「前」。上の画像を境目から右で切って、下を覗かせる -->
<img class="compare__image" src="/img/after.jpg" alt="明るさを補正した後の写真">
<img class="compare__image compare__before" src="/img/before.jpg" alt="補正する前の暗い写真">
<div class="compare__handle">
<span class="compare__line" aria-hidden="true"></span>
<!-- ハンドルはキーボードでも動かせるようにする(← → で 1% ずつ) -->
<button class="compare__knob" type="button" role="slider" aria-label="比較の境目"
aria-valuemin="0" aria-valuemax="100" aria-valuenow="50" aria-valuetext="50%">
<svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true" focusable="false">
<path d="M9 6.5 3.5 12 9 17.5M15 6.5l5.5 5.5-5.5 5.5" fill="none" stroke="currentColor"
stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
<figcaption class="compare__caption">つまみを動かすと、Before と After を見比べられます。</figcaption>
</figure>
.compare {
--knob: 56px;
position: relative;
margin: 0;
aspect-ratio: 3 / 2;
border-radius: 4px;
overflow: hidden;
cursor: ew-resize;
touch-action: none; /* タッチでドラッグするときに、ページが一緒にスクロールしないように */
}
.compare__image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
user-select: none;
-webkit-user-drag: none;
}
/* JS が動かないときは「前」の画像だけが見える(比較はできないが、絵は壊れない) */
.compare__before {
clip-path: inset(0 50% 0 0);
}
.compare__handle {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 0;
z-index: 2;
}
.compare__line {
position: absolute;
left: -2px;
top: 0;
bottom: 0;
width: 4px;
background-color: #ffffff;
box-shadow: 0 0 12px rgba(0, 0, 0, 0.25);
}
.compare__knob {
position: absolute;
left: calc(var(--knob) / -2);
top: calc(50% - var(--knob) / 2);
display: grid;
place-items: center;
width: var(--knob);
height: var(--knob);
padding: 0;
border: 0;
border-radius: 50%;
background-color: #ffffff;
color: #111111;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.3), 0 0 0 0 rgba(255, 255, 255, 0);
cursor: inherit;
}
.compare__knob:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 4px;
}
.compare__caption {
margin-top: 12px;
font-size: 14px;
line-height: 1.8;
}
import { gsap } from "gsap";
const SHADOW = "0px 6px 16px 0px rgba(0, 0, 0, 0.3)";
const HALO_OFF = `${SHADOW}, 0px 0px 0px 0px rgba(255, 255, 255, 0)`;
const HALO_ON = `${SHADOW}, 0px 0px 0px 10px rgba(255, 255, 255, 0.4)`;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; // ハンドルの拡大を一瞬で済ませる
document.querySelectorAll(".compare").forEach((compare) => {
const before = compare.querySelector(".compare__before");
const handle = compare.querySelector(".compare__handle");
const knob = compare.querySelector(".compare__knob");
let ratio = 0.5;
// 境目は整数 px にそろえる。端数の位置にすると、縁の描かれ方が揺れる
const setSplit = (x) => {
const width = compare.clientWidth;
const px = Math.round(gsap.utils.clamp(0, width, x));
ratio = width ? px / width : 0.5;
before.style.clipPath = `inset(0px ${width - px}px 0px 0px)`;
gsap.set(handle, { x: px });
const percent = Math.round(ratio * 100);
knob.setAttribute("aria-valuenow", String(percent));
knob.setAttribute("aria-valuetext", `${percent}%`);
};
// つかんでいる間はハンドルを少し大きくし、淡い輪を付ける。
// force3D: false で 2D のまま拡大する(専用のレイヤーに載せると縁が揺れる)
const hold = (pressed) =>
gsap.to(knob, {
scale: pressed ? 1.12 : 1,
boxShadow: pressed ? HALO_ON : HALO_OFF,
force3D: false,
duration: reduceMotion ? 0 : pressed ? 0.2 : 0.25,
ease: "power2.out",
});
const toX = (event) => event.clientX - compare.getBoundingClientRect().left;
compare.addEventListener("pointerdown", (event) => {
compare.setPointerCapture(event.pointerId); // ポインタが要素の外へ出ても追える
hold(true);
setSplit(toX(event)); // 押した位置へ境目を飛ばす
});
compare.addEventListener("pointermove", (event) => {
if (compare.hasPointerCapture(event.pointerId)) setSplit(toX(event));
});
compare.addEventListener("pointerup", () => hold(false));
compare.addEventListener("pointercancel", () => hold(false));
knob.addEventListener("keydown", (event) => {
const step = { ArrowLeft: -0.01, ArrowRight: 0.01, Home: -1, End: 1 }[event.key];
if (step === undefined) return;
event.preventDefault();
const next = Math.abs(step) === 1 ? (step + 1) / 2 : ratio + step;
setSplit(next * compare.clientWidth);
});
// 幅が変わっても、同じ割合の位置に置き直す
new ResizeObserver(() => setSplit(ratio * compare.clientWidth)).observe(compare);
setSplit(compare.clientWidth * ratio);
});