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

ホバーで商品カードの画像が別の角度に替わり、カートに入れるボタンがせり上がる

デモ

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

仕様

用途
カード
動作
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 の商品一覧・おすすめ枠・関連商品に向いています。

コード

HTML
<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>

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

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

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

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