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

カード

カードがカーソルに合わせて 3D で傾き、光沢が走る

デモ

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

仕様

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

しくみと調整

カーソルのある側が奥へ押し込まれるように、カードが傾きます。表面では、照り返しと斜めの光のラインがカーソルについて流れます。translateZ で手前に浮かせた文字やバッジは、傾くたびに下の層とずれて見え、そこに視差が生まれます。

角度は、カーソルの位置を中央からの割合に直し、最大22°を掛けて決めています。位置を測るのは傾くカード自身ではなく、perspective を指定した傾かない外側の要素です。傾いたカードは見かけの大きさが変わるので、そこで測るとカーソルの位置を正しく求められません。追従は quickTo(0.5秒、power3.out)に任せ、カーソルが離れたら0°へ戻します。

光のラインの位置は、カーソルの横と縦の割合を足した値に32%を掛けて、中央からずらしています。足し合わせているので、両方がそろって端へ寄ったときに振れ幅が最大になり、中央から64%まで離れます。

角を丸めるためにカードへ overflow: hidden を付けると、立体の重なりが平らになり、浮かせた文字の視差も消えてしまいます。角丸はカードには付けず、画像や光沢などの層ごとに指定することにしました。傾きの最大角を上げれば反応が大きくなり、符号を逆にすれば、カーソルのある側が手前に起き上がります。

コード

HTML
<article class="tilt">
  <!-- 傾くのはこの中だけ。外の .tilt は傾かず、位置を測る要素と奥行き(perspective)を持つ -->
  <div class="tilt__card">
    <img class="tilt__art" src="/img/aurora.jpg" alt="">
    <span class="tilt__scrim" aria-hidden="true"></span>
    <!-- 光沢。カーソルの下の丸い照り返しと、斜めに走る光の筋を重ねる -->
    <span class="tilt__glare" aria-hidden="true"></span>
    <p class="tilt__badge">NO. 026</p>
    <div class="tilt__copy">
      <h3 class="tilt__title"><a href="/collection/aurora/">オーロラ</a></h3>
      <p class="tilt__meta">コレクションカード・2026年</p>
    </div>
  </div>
</article>

CSS
.tilt {
  --radius: 4px;
  --lift: 1.05;            /* ホバー中に大きくなる割合 */
  --depth: 46px;           /* 文字を表面から浮かせる量 */
  --ink: #ffffff;
  --mute: #dedcd6;
  --on-light: #262626;     /* 明るい画像の上のラベルの線と文字 */
  --glare: rgba(255, 255, 255, 0.62);
  --sheen: rgba(255, 255, 255, 0.32);
  display: block;
  width: 384px;
  max-width: 100%;
  /* 奥行きはここで付ける。値が小さいほど遠近が強い */
  perspective: 1024px;
}

.tilt__card {
  position: relative;
  height: 512px;
  /* overflow: hidden を付けると 3D が平らになるので、角丸は重ねた要素ごとに付ける */
  transform-style: preserve-3d;
  will-change: transform;
}

.tilt__art,
.tilt__scrim,
.tilt__glare {
  position: absolute;
  inset: 0;
  border-radius: var(--radius);
  pointer-events: none;
}

.tilt__art {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* タイトルを読みやすくする暗いグラデーション。光沢はこの上に重ねる */
.tilt__scrim {
  background: linear-gradient(180deg, rgba(17, 17, 17, 0) 48%, rgba(17, 17, 17, 0.72) 100%);
}

/* 光沢は、カーソルが重なっているあいだだけ濃くする */
.tilt__glare { opacity: 0; }

/* 浮かせた要素は、傾けたときに視差が出る */
.tilt__badge {
  position: absolute;
  left: 32px;
  top: 32px;
  height: 28px;
  margin: 0;
  padding: 0 12px 0 14px;
  border-radius: 999px;
  box-shadow: inset 0 0 0 1px var(--on-light);
  color: var(--on-light);
  font-size: 13px;
  font-weight: 700;
  line-height: 28px;
  /* 字間の分だけ左の余白を足して、文字をラベルの中央に置く */
  letter-spacing: 0.14em;
  transform: translateZ(calc(var(--depth) * 0.6));
}

.tilt__copy {
  position: absolute;
  left: 32px;
  right: 32px;
  bottom: 32px;
  color: var(--ink);
  transform: translateZ(var(--depth));
}

.tilt__title {
  margin: 0;
  font-size: 64px;
  font-weight: 800;
  line-height: 1.05;
  letter-spacing: -0.02em;
}

.tilt__title a { color: inherit; text-decoration: none; }

/* キーボードでフォーカスを移したときは傾きを 0 に戻すので、フォーカスの枠線は斜めにならない */
.tilt__title a:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 6px;
}

.tilt__meta {
  margin: 8px 0 0;
  color: var(--mute);
  font-size: 14px;
  letter-spacing: 0.04em;
}

JavaScript
import { gsap } from "gsap";

const MAX_TILT = 22; // 端にいるときの傾き(度)。符号を逆にすると、カーソル側が手前に起き上がる
// タッチ端末にはカーソルが無く、動きを減らす設定の人には負担になるので、どちらでも動きを付けない
const canHover = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

// nx, ny はカードの中のカーソル位置(-1〜1)。丸い照り返しと、斜めに走る光の筋を重ねる
function glareCss(nx, ny) {
  const band = 50 + (nx + ny) * 32;
  return [
    `radial-gradient(circle at ${(nx + 1) * 50}% ${(ny + 1) * 50}%, var(--glare), rgba(255, 255, 255, 0) 48%)`,
    `linear-gradient(115deg, rgba(255, 255, 255, 0) ${band - 16}%, var(--sheen) ${band}%, rgba(255, 255, 255, 0) ${band + 16}%)`,
  ].join(",");
}

if (canHover && !reduceMotion) {
  document.querySelectorAll(".tilt").forEach((area) => {
    const card = area.querySelector(".tilt__card");
    const glare = area.querySelector(".tilt__glare");
    const lift = parseFloat(getComputedStyle(area).getPropertyValue("--lift"));
    const rotateX = gsap.quickTo(card, "rotationX", { duration: 0.5, ease: "power3.out" });
    const rotateY = gsap.quickTo(card, "rotationY", { duration: 0.5, ease: "power3.out" });

    const show = () => {
      gsap.to(card, { scale: lift, duration: 0.45, ease: "power2.out" });
      gsap.to(glare, { opacity: 1, duration: 0.45, ease: "power2.out" });
    };
    const hide = () => {
      rotateX(0);
      rotateY(0);
      gsap.to(card, { scale: 1, duration: 0.9, ease: "power3.out" });
      gsap.to(glare, { opacity: 0, duration: 0.6, ease: "power2.out" });
    };

    area.addEventListener("pointermove", (event) => {
      // 傾いて形の変わるカードではなく、傾かない外側の要素で測る
      const rect = area.getBoundingClientRect();
      const nx = ((event.clientX - rect.left) / rect.width) * 2 - 1;
      const ny = ((event.clientY - rect.top) / rect.height) * 2 - 1;
      rotateX(-ny * MAX_TILT);
      rotateY(nx * MAX_TILT);
      glare.style.background = glareCss(nx, ny);
    });
    area.addEventListener("pointerenter", show);
    area.addEventListener("pointerleave", hide);

    // キーボードでフォーカスを移したときは傾けない。光沢だけ上寄りに置き、focus の枠線がまっすぐ表示されるようにする
    area.addEventListener("focusin", () => {
      rotateX(0);
      rotateY(0);
      glare.style.background = glareCss(0, -0.4);
      show();
    });
    area.addEventListener("focusout", hide);
  });
}

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

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