デモ
デモを読み込めませんでした。
仕様
- 用途
- カード
- 動作
- 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 に戻します。タッチ端末では、この動きはつなぎません。
傾きの最大の角度を上げると反応が大きくなり、向きを逆にすると、カーソルの側が手前に起き上がります。商品カード・料金プラン・会員証・ポートフォリオのサムネイルに向いています。
コード
<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>
.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);
});
}