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

商品の画像の上で、カーソルの周りが虫めがねのように拡大される

デモ

操作:カーソルを動かす・カーソルを重ねる。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。

仕様

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

しくみと調整

商品の画像の枠の中に丸いレンズの要素を重ね、その背景に同じ画像を「枠の大きさ × 倍率」の大きさで敷きます。レンズの中心に来る点が、拡大した画像でも中心に映るよう、background-position を「レンズの半径 − ポインタの位置 × 倍率」にずらします。画像に入るとレンズが scale で 0 から膨らみ、出ると縮んで消えます。

倍率は 2.5 倍、レンズの直径は画像の幅の 38%(128〜240px)で、縁は白の 3px に墨の細い線を添えて、どんな色の画像の上でも輪郭が見えるようにしています。レンズの中心は quickTo で 0.15 秒かけて追いつきます。拡大鏡は細部を狙って見る道具なので、遅れを長くすると狙った所と映る所がずれて見づらくなります。膨らむときだけ back.out で少し弾ませ、縮むときは弾ませずに 0.3 秒で閉じます。

背景の位置は、レンズの縁まで含めた箱を基準に計算します。background-origin を border-box にしないと、背景が縁の内側から始まり、縁の太さの分だけ映る所がずれます。画像は枠と同じ縦横比にします。object-fit で切り抜いて見せていると、見えている画像と拡大した背景の位置が合わなくなります。拡大用に大きな画像を用意するときは、表示の大きさ × 倍率以上の画素数にし、初めてレンズを開くときに読み込みます。

画像の上では本物のカーソルを cursor: none で隠し、レンズをカーソルの代わりにします。指で触る端末ではポインタにつながず、画像をそのまま見せます。キーボードでは画像に焦点を入れると中央にレンズが開き、矢印キーで動かせて、Esc か焦点が外れると閉じます。動きを減らす設定では、遅れと膨らむ動きを無くし、その場で開け閉めします。

つまみは倍率とレンズの直径、追いつく時間です。倍率を上げるほど細部は大きく映りますが、少し動かしただけで映る所が大きく動くので、3 倍くらいまでが扱いやすい範囲です。隠れた別の画像を照らして見せる演出と違い、同じ画像をその場で拡大するので、腕時計やアクセサリー、布の織り目のように、細部が決め手になる商品の EC ページに向いています。

コード

HTML
<section class="product" aria-labelledby="product-title">
  <!-- 焦点を受けて、矢印キーでもレンズを動かせるようにする。data-zoom-src は拡大に使う大きな画像(無ければ同じ画像を使う) -->
  <figure class="zoom" tabindex="0" aria-describedby="zoom-hint">
    <img class="zoom__image" src="/img/minamo-38.jpg" data-zoom-src="/img/minamo-38-large.jpg" alt="ミナモ 38。グリーンの文字盤に、ベージュのレザーストラップ" width="1200" height="1200">
    <span class="zoom__lens" aria-hidden="true"></span>
  </figure>

  <div class="product__info">
    <h1 class="product__title" id="product-title">ミナモ 38</h1>
    <p class="product__price">¥48,400<small>(税込)</small></p>
    <p class="product__hint" id="zoom-hint">画像にカーソルを合わせると、細部を拡大して見られます。キーボードでは画像を選び、矢印キーで動かせます。</p>
  </div>
</section>

CSS
.product {
  --ink: #262626;
  --mute: #6b6b6b;
  display: grid;
  /* 広い画面では画像と説明を 2 列、狭い画面では 1 列に並べる */
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr));
  gap: 32px 64px;
  align-items: center;
  max-width: 1088px;
  margin: 0 auto;
  padding: 64px 24px;
  color: var(--ink);
}

.zoom {
  --lens: clamp(128px, 38%, 240px); /* レンズの直径 */
  --lens-ring: #ffffff;
  position: relative;
  margin: 0;
  aspect-ratio: 1; /* 画像と同じ縦横比にする。違うと、拡大した画像の位置がずれる */
  overflow: hidden;
  border-radius: 4px;
}

.zoom__image {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* 同じ画像を拡大して背景に敷いた丸。位置と大きさは JS が決める */
.zoom__lens {
  position: absolute;
  left: 0;
  top: 0;
  width: var(--lens);
  aspect-ratio: 1;
  box-sizing: border-box;
  border: 3px solid var(--lens-ring);
  border-radius: 50%;
  box-shadow: 0 0 0 1px rgba(38, 38, 38, 0.32);
  background-repeat: no-repeat;
  background-origin: border-box;
  pointer-events: none;
  visibility: hidden;
}

/* JS がつないだときだけ出す。画像の上ではレンズがカーソルの代わりなので、本物のカーソルを隠す */
.zoom.has-lens .zoom__lens {
  visibility: visible;
}

@media (hover: hover) and (pointer: fine) {
  .zoom.has-lens {
    cursor: none;
  }
}

.zoom:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 4px;
}

.product__title {
  margin: 0 0 24px;
  font-size: clamp(32px, 5vw, 44px);
  font-weight: 800;
  line-height: 1.1;
  letter-spacing: -0.02em;
}

.product__price {
  margin: 0;
  font-size: 24px;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
}

.product__price small,
.product__hint {
  font-size: 13px;
  font-weight: 400;
  line-height: 1.7;
  letter-spacing: 0.04em;
  color: var(--mute);
}

JavaScript
import { gsap } from "gsap";

const ZOOM = 2.5; // 拡大率。拡大用の画像は、表示の大きさ × この倍率以上の画素数にする
const STEP = 0.08; // 矢印キー 1 回で動かす量(画像の幅に対する比)
const KEYS = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// 指で触る端末にはポインタが無いので、ポインタにはつながない(キーボードでは使える)
const canHover = window.matchMedia("(hover: hover) and (pointer: fine)").matches;

document.querySelectorAll(".zoom").forEach((zoom) => {
  const image = zoom.querySelector(".zoom__image");
  const lens = zoom.querySelector(".zoom__lens");
  if (!image || !lens) return;
  const spot = { x: 0, y: 0 }; // レンズの中心(画像の中の px)。quickTo が少し遅れて追う
  const aim = { x: 0, y: 0 }; // 追いかけている先
  let hovering = false;
  gsap.set(lens, { xPercent: -50, yPercent: -50, scale: 0 });
  zoom.classList.add("has-lens");

  // レンズの中心に来る点が、拡大した画像でも中心に映るように背景をずらす
  const render = () => {
    const r = lens.offsetWidth / 2;
    gsap.set(lens, { x: spot.x, y: spot.y });
    lens.style.backgroundPosition = `${r - spot.x * ZOOM}px ${r - spot.y * ZOOM}px`;
  };
  // 動きを減らす設定では遅れずに置く(quickTo は duration が 0 だと値が動かない)
  const follow = (prop) =>
    reduceMotion
      ? (value) => {
          spot[prop] = value;
          render();
        }
      : gsap.quickTo(spot, prop, { duration: 0.15, ease: "power3", onUpdate: render });
  const toX = follow("x");
  const toY = follow("y");

  const moveTo = (x, y) => {
    aim.x = x;
    aim.y = y;
    // 閉じていれば、その位置から開く(遠くからレンズが飛んでこないように)
    if (gsap.getProperty(lens, "scale") < 0.02) {
      toX(x, x);
      toY(y, y);
    } else {
      toX(x);
      toY(y);
    }
  };
  const open = (x, y) => {
    // 大きな画像は、初めて開くときに読み込む
    if (!lens.style.backgroundImage) lens.style.backgroundImage = `url("${image.dataset.zoomSrc || image.currentSrc || image.src}")`;
    lens.style.backgroundSize = `${zoom.clientWidth * ZOOM}px ${zoom.clientHeight * ZOOM}px`;
    moveTo(x, y);
    gsap.to(lens, { scale: 1, duration: reduceMotion ? 0 : 0.45, ease: "back.out(1.4)", overwrite: "auto" });
  };
  const close = () => gsap.to(lens, { scale: 0, duration: reduceMotion ? 0 : 0.3, ease: "power2.inOut", overwrite: "auto" });
  const local = (event) => {
    const box = zoom.getBoundingClientRect();
    return [event.clientX - box.left, event.clientY - box.top];
  };

  if (canHover) {
    zoom.addEventListener("pointerenter", (event) => {
      hovering = true;
      open(...local(event));
    });
    zoom.addEventListener("pointermove", (event) => moveTo(...local(event)));
    zoom.addEventListener("pointerleave", () => {
      hovering = false;
      close();
    });
  }

  // キーボード: 焦点が入ったら中央で開き、矢印キーで動かす。Esc か焦点が外れたら閉じる
  zoom.addEventListener("focus", () => {
    if (zoom.matches(":focus-visible")) open(zoom.clientWidth / 2, zoom.clientHeight / 2);
  });
  zoom.addEventListener("blur", () => {
    if (!hovering) close();
  });
  zoom.addEventListener("keydown", (event) => {
    if (event.key === "Escape") close();
    const step = KEYS[event.key];
    if (!step) return;
    event.preventDefault(); // 矢印キーでページがスクロールしないように
    const w = zoom.clientWidth;
    const h = zoom.clientHeight;
    open(gsap.utils.clamp(0, w, aim.x + step[0] * w * STEP), gsap.utils.clamp(0, h, aim.y + step[1] * h * STEP));
  });
});

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

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