デモ
デモを読み込めませんでした。
仕様
- 用途
- カード
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
同じ構図の 2 枚の画像を重ね、上の画像を境目から右で切ります(clip-path の inset)。境目の線とつまみは、その位置に合わせて動かします。デモの 2 枚は、昼と夜の街を同じ描き方で色だけ替えたものです。
境目の位置は、整数の px にそろえます。端数の位置にすると、縁の描かれ方が揺れます。つかむとつまみを 1.12 倍にして、ぼかしの無い影で淡い輪を付け、放すと戻します。つまみは 2D のまま拡大し、専用のレイヤーには載せません。
実際のサイトでは、枠を押した時点でポインタを捕まえ(setPointerCapture)、押した位置へ境目を飛ばしてから、動きに合わせて追います。Draggable で作るなら、横だけに動かし、枠を動ける範囲に指定します。キーボードでも動かせるように、つまみに role="slider" を付け、← → キーで動かします。
色の帯で画像を出す演出と違い、見る人が自分で境目を動かして、2 枚を見比べます。レタッチや色補正の前後・リフォームの前後・画質の比較・昼と夜の見え方に向いています。
コード
<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">つまみを引くと、加工の前と後を見比べられます。</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)`;
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: 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);
});