本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
カード

画像の境目のつまみを引いて前後を見比べる

デモ

操作:ドラッグ。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。

仕様

用途
カード
動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

同じ構図の 2 枚の画像を重ね、上の画像を境目から右で切ります(clip-pathinset)。境目の線とつまみは、その位置に合わせて動かします。デモの 2 枚は、昼と夜の街を同じ描き方で色だけ替えたものです。

境目の位置は、整数の px にそろえます。端数の位置にすると、縁の描かれ方が揺れます。つかむとつまみを 1.12 倍にして、ぼかしの無い影で淡い輪を付け、放すと戻します。つまみは 2D のまま拡大し、専用のレイヤーには載せません。

実際のサイトでは、枠を押した時点でポインタを捕まえ(setPointerCapture)、押した位置へ境目を飛ばしてから、動きに合わせて追います。Draggable で作るなら、横だけに動かし、枠を動ける範囲に指定します。キーボードでも動かせるように、つまみに role="slider" を付け、← → キーで動かします。

色の帯で画像を出す演出と違い、見る人が自分で境目を動かして、2 枚を見比べます。レタッチや色補正の前後・リフォームの前後・画質の比較・昼と夜の見え方に向いています。

コード

HTML
<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>
CSS
.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;
}
JavaScript
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);
});

インタラクションデザインについて、
お気軽にご相談ください。

ここに並べているのは、Web サイトの中で動く UI のサンプルです。新しく作るサイトに組み込むことも、いま公開中のサイトに、ボタンやヒーロー、スクロールの演出だけを足すこともできます。設計から実装までの進め方はWeb サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。