デモ
デモを読み込めませんでした。
仕様
- 用途
- カード
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
カーソルのある側が奥へ押し込まれるように、カードが傾きます。表面では、照り返しと斜めの光のラインがカーソルについて流れます。translateZ で手前に浮かせた文字やバッジは、傾くたびに下の層とずれて見え、そこに視差が生まれます。
角度は、カーソルの位置を中央からの割合に直し、最大22°を掛けて決めています。位置を測るのは傾くカード自身ではなく、perspective を指定した傾かない外側の要素です。傾いたカードは見かけの大きさが変わるので、そこで測るとカーソルの位置を正しく求められません。追従は quickTo(0.5秒、power3.out)に任せ、カーソルが離れたら0°へ戻します。
光のラインの位置は、カーソルの横と縦の割合を足した値に32%を掛けて、中央からずらしています。足し合わせているので、両方がそろって端へ寄ったときに振れ幅が最大になり、中央から64%まで離れます。
角を丸めるためにカードへ overflow: hidden を付けると、立体の重なりが平らになり、浮かせた文字の視差も消えてしまいます。角丸はカードには付けず、画像や光沢などの層ごとに指定することにしました。傾きの最大角を上げれば反応が大きくなり、符号を逆にすれば、カーソルのある側が手前に起き上がります。
コード
<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>
.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;
}
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);
});
}