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

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

デモ

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

仕様

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

しくみと調整

傾かない外枠に perspective を置き、中のカードを preserve-3d にしてから、X 軸と Y 軸で傾けます。角度は、外枠の中でのカーソルの位置を -1〜1 に直し、最大 22° を掛けて決めます。位置は、傾いて形の変わるカードではなく、傾かない外枠で測ります。

光沢は、カードの表面に重ねた要素で描きます。カーソルの位置を中心にした丸い照り返し(radial-gradient)と、カーソルと一緒に斜めに走る光の帯(linear-gradient)を重ね、重なっているあいだだけ濃くします。帯の位置は、カーソルの位置を -1〜1 に直した値を足して、中央から左右に 32% の幅で振ります。

文字やバッジを translateZ で手前に浮かせると、傾けたときに視差が出ます。ただし、カードに overflow: hidden を付けると 3D が平らになるので、角丸は面ごとに付けます。

実際のサイトでは、quickTo(0.5 秒、power3.out)でカーソルに追従させ、離れたら角度を 0 に戻します。タッチ端末では、この動きはつなぎません。

傾きの最大の角度を上げると反応が大きくなり、向きを逆にすると、カーソルの側が手前に起き上がります。商品カード・料金プラン・会員証・ポートフォリオのサムネイルに向いています。

コード

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/">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 サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。