デモ
デモを読み込めませんでした。
仕様
- 用途
- カード
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
商品の画像の上に、別の角度から撮った 2 枚目の画像を重ねて隠しておき、カーソルが重なったら透明度を上げながら 1.06 倍から等倍へ寄せて出します。同時に、画像の枠の下端の外に隠しておいた「カートに入れる」ボタンを、0.08 秒遅れてせり上げます。動きは止めたタイムライン 1 本にまとめ、重なったら play()、離れたら reverse() します。
2 枚目の画像は 0.5 秒、ボタンは 0.45 秒で、どちらも ease は power2.inOut です。out 系の ease にすると、逆にたどったときに出だしがゆっくりになり、隣の商品へ移ったあとも前のボタンが残って、ボタンが 2 つ並んで見えます。離れたときは timeScale で入るときの倍の速さにして戻します。
2 枚目の画像は、CSS で visibility と透明度の両方を隠す値にしておきます。visibility だけにすると、GSAP が透明度を 1 から動かし始め、重なった瞬間に一瞬光ります。商品名のリンクは ::after をカード全体に広げておくと、画像を押しても商品のページへ移り、ボタンは重なり順を上げてその上に出せます。
ボタンは隠れているあいだも Tab で届き、焦点がカードの中へ入ったら(focusin)同じ動きで出します。ボタンの名前は「商品名をカートに入れる」にして、どの商品のボタンかが読み上げでも分かるようにします。ホバーの無いタッチ端末では、ボタンをはじめから見せたままにします。
押したあとの動き(カートへ飛ばすなど)ではなく、押す前の見せ方の型です。寄りを 1.1 倍にすると切り替わりがはっきりし、1 倍にすると静かに溶け合います。2 枚目には、角度や地の色が 1 枚目とはっきり違う写真を選ぶと、替わったことが伝わります。EC の商品一覧・おすすめ枠・関連商品に向いています。
コード
<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);
});
}