デモ
デモを読み込めませんでした。
仕様
- 用途
- カード
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
商品の写真にカーソルを乗せると、別の角度から撮ったカットが少し大きめの状態から縮みながら重なり、写真の下端から「カートに入れる」ボタンがせり上がります。扱うのは押す前の見せ方までで、押したあとにカートへ飛ばすような動きは入れていません。
写真とボタンの動きは一時停止したタイムライン1本にまとめ、カーソルが乗ったら play()、外れたら reverse() で戻します。イージングは両方とも power2.inOut にしました。out 系だと逆再生の出だしがゆっくりになり、隣の商品へカーソルを移したときに前のカードのボタンがなかなか引っ込まず、ボタンが2つ並んで見えてしまいます。戻りは timeScale で入りの倍の速さです。
重ねる写真の隠し方には、ひとつ落とし穴があります。CSS で visibility だけを隠す値にすると、GSAP は不透明度を1から動かし始めるので、乗った瞬間に写真が一瞬光ります。visibility と不透明度の両方を、隠れた状態にそろえておいてください。
カードのどこを押しても商品ページへ移れるよう、商品名のリンクの ::after をカード全体に広げ、カートのボタンだけ重なり順を上げてその上に置いています。ボタンは隠れている間も Tab で届き、カードにフォーカスが入ると(focusin)ホバーと同じ動きで現れます。名前は「商品名をカートに入れる」の形なので、スクリーンリーダーでもどの商品のボタンかが分かります。
重ねる写真の最初の拡大率を1.1倍にすると切り替わりがはっきりし、等倍にすると2枚が静かに溶け合います。写真そのものは、角度や背景の色が元のカットとはっきり違うものを選ぶほうが、替わったことが伝わります。
コード
<section class="shop" aria-labelledby="shop-title">
<div class="shop__head">
<div>
<p class="shop__label">NEW ARRIVALS</p>
<h2 class="shop__title" id="shop-title">新作アイテム</h2>
</div>
<a class="shop__all" href="/shop/">すべての商品を見る</a>
</div>
<ul class="shop__grid">
<!-- --tint は画像が届くまでの背景色 -->
<li class="product" style="--tint: #dbe2e8">
<div class="product__media">
<img class="product__img" src="/img/headphones-front.jpg" alt="ワイヤレスヘッドホンを正面から見た写真" width="682" height="768">
<!-- 別の角度の画像は上に重ねて隠しておく。見た目を足すだけなので、読み上げからは外す -->
<img class="product__img product__alt" src="/img/headphones-angle.jpg" alt="" width="682" height="768">
<button class="product__add" type="button" aria-label="ワイヤレスヘッドホンをカートに入れる">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M6 8h12l-1 12H7L6 8zM9 8a3 3 0 0 1 6 0" /></svg>
カートに入れる
</button>
</div>
<div class="product__row">
<!-- リンクの ::after をカード全体に広げ、画像を押しても商品のページへ移る -->
<h3 class="product__name"><a href="/shop/wireless-headphones/">ワイヤレスヘッドホン</a></h3>
<p class="product__price">¥24,200</p>
</div>
<p class="product__detail">グラファイト・ノイズキャンセリング</p>
</li>
<li class="product" style="--tint: #eee4d7">
<div class="product__media">
<img class="product__img" src="/img/lounge-chair-front.jpg" alt="ラウンジチェアを正面から見た写真" width="682" height="768">
<img class="product__img product__alt" src="/img/lounge-chair-side.jpg" alt="" width="682" height="768">
<button class="product__add" type="button" aria-label="ラウンジチェアをカートに入れる">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M6 8h12l-1 12H7L6 8zM9 8a3 3 0 0 1 6 0" /></svg>
カートに入れる
</button>
</div>
<div class="product__row">
<h3 class="product__name"><a href="/shop/lounge-chair/">ラウンジチェア</a></h3>
<p class="product__price">¥68,000</p>
</div>
<p class="product__detail">オーク・キャメル</p>
</li>
<li class="product" style="--tint: #e2e7dd">
<div class="product__media">
<img class="product__img" src="/img/trail-backpack-front.jpg" alt="トレイルリュックを正面から見た写真" width="682" height="768">
<img class="product__img product__alt" src="/img/trail-backpack-back.jpg" alt="" width="682" height="768">
<button class="product__add" type="button" aria-label="トレイルリュックをカートに入れる">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M6 8h12l-1 12H7L6 8zM9 8a3 3 0 0 1 6 0" /></svg>
カートに入れる
</button>
</div>
<div class="product__row">
<h3 class="product__name"><a href="/shop/trail-backpack/">トレイルリュック</a></h3>
<p class="product__price">¥15,400</p>
</div>
<p class="product__detail">20L・フォレスト</p>
</li>
</ul>
</section>
.shop {
--ground: #f2f1ed; /* 背景色 */
--ink: #262626; /* 文字と、メインのボタンの背景色 */
--mute: #6b6b6b; /* 説明とラベル */
--on-ink: #ffffff; /* メインのボタンの文字 */
padding: 64px clamp(16px, 6vw, 96px);
background: var(--ground);
color: var(--ink);
}
.shop__head {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: flex-end;
gap: 16px 32px;
margin-bottom: 48px;
}
.shop__label { margin: 0; color: var(--mute); font-size: 13px; font-weight: 700; line-height: 1; letter-spacing: 0.14em; }
.shop__title { margin: 16px 0 0; font-size: clamp(32px, 5vw, 44px); font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; }
.shop__all { color: inherit; font-size: 14px; font-weight: 600; letter-spacing: 0.04em; text-decoration-thickness: 1px; text-underline-offset: 0.4em; }
.shop__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 32px;
margin: 0;
padding: 0;
list-style: none;
}
.product { position: relative; }
/* 別の角度の画像とボタンは、この画像の領域の中に重ねる。ボタンは領域の下端の外に隠す */
.product__media {
position: relative;
aspect-ratio: 341 / 384;
border-radius: 4px;
overflow: hidden;
background: var(--tint);
}
.product__img { display: block; width: 100%; height: 100%; object-fit: cover; }
/* visibility だけで隠すと、GSAP が透明度を 1 から動かして一瞬光るので、opacity も 0 にしておく */
.product__alt { position: absolute; inset: 0; opacity: 0; visibility: hidden; }
.product__add {
position: absolute;
left: 16px;
right: 16px;
bottom: 16px;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 48px;
border: 0;
border-radius: 999px;
background: var(--ink);
color: var(--on-ink);
font: inherit;
font-size: 15px;
font-weight: 600;
letter-spacing: 0.04em;
cursor: pointer;
}
.product__add svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2.67; stroke-linecap: round; stroke-linejoin: round; }
.product__add:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; }
.product__row { display: flex; justify-content: space-between; align-items: baseline; gap: 16px; margin-top: 20px; }
.product__name { margin: 0; font-size: 20px; font-weight: 700; line-height: 1.3; letter-spacing: -0.01em; }
.product__name a { color: inherit; text-decoration: none; }
/* カード全体を押せるようにする。ボタンは z-index でこの上に重ねる */
.product__name a::after { content: ""; position: absolute; inset: 0; border-radius: 4px; }
.product__name a:focus-visible { outline: none; }
.product__name a:focus-visible::after { outline: 2px solid var(--ink); outline-offset: 4px; }
.product__price { margin: 0; font-size: 16px; font-weight: 600; letter-spacing: 0.04em; font-variant-numeric: tabular-nums; white-space: nowrap; }
.product__detail { margin: 4px 0 0; color: var(--mute); font-size: 14px; line-height: 1.7; letter-spacing: 0.04em; }
import { gsap } from "gsap";
const LEAVE = 2; // 離れたときに戻る速さ(入るときの倍率)。隣へ移ったとき、前のボタンが残らない
const canHover = window.matchMedia("(hover: hover)").matches;
// 動きを減らす設定では、ほぼ一瞬で切り替える
const speed = window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 20 : 1;
// タッチ端末では、ボタンをはじめから見せたままにする(別の角度の画像は商品のページで見せる)
if (canHover) {
document.querySelectorAll(".product").forEach((card) => {
const alt = card.querySelector(".product__alt");
const button = card.querySelector(".product__add");
// 画像の領域の下端の外まで下げておく(ボタンの高さ+下の余白)
const rise = button.offsetHeight + parseFloat(getComputedStyle(button).bottom);
const hover = gsap
.timeline({ paused: true })
// 行きと帰りで同じ形になる inOut の ease(out を逆にたどると、離れてもしばらくボタンが残る)
.fromTo(alt, { autoAlpha: 0, scale: 1.06 }, { autoAlpha: 1, scale: 1, duration: 0.5, ease: "power2.inOut" }, 0)
.fromTo(button, { y: rise }, { y: 0, duration: 0.45, ease: "power2.inOut" }, 0.08);
const show = () => hover.timeScale(speed).play();
const hide = (event) => {
// カーソルもキーボードのフォーカスもカードの外へ出たときだけ戻す
if (card.matches(":hover") || card.querySelector(":focus-visible") || card.contains(event.relatedTarget)) return;
hover.timeScale(speed * LEAVE).reverse();
};
card.addEventListener("pointerenter", show);
card.addEventListener("pointerleave", hide);
// キーボードでボタンや商品名へ移ったときも、ホバーと同じように表示する
card.addEventListener("focusin", show);
card.addEventListener("focusout", hide);
});
}