デモ
デモを読み込めませんでした。
仕様
- 用途
- カーソル
- 動作
- 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 ページに向いています。
コード
<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>
.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);
}
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));
});
});