GSAPでカードをマウスに追従させて傾けるとき、回転させる要素自身にperspectiveを書いても3Dには傾きません。perspectiveは指定した要素の子に対して遠近投影をかけるプロパティであり、その要素自身の回転には効かないためです。親要素に書くか、GSAPのtransformPerspectiveでtransformの中に直接入れる必要があります。
この記事が以前まで紹介していたコードが、まさにその状態でした。カードの四隅に1pxのマーカーを差し込んでホバー中の実座標を測ると、上辺の長さ÷下辺の長さ=1.0000。台形にならず完全な平行四辺形のままで、GSAPは回転値を書き込んでいるのに画面上は奥行きゼロでした。JavaScriptのエラーは0件、コンソールの警告も0件です。壊れていないのに約束が果たされていないという、いちばん気づきにくい壊れ方をしていました。
この記事では、原因の切り分けと修正版のコードに加えて、自分の実装が本当に傾いているかをブラウザのコンソールで数値判定する手順まで書きます。合否ラインは単純で、上辺÷下辺が1.00から動かなければ傾いていない。掲載する数値はすべてChromiumでの実測値で、測定に使った条件も明記します。
結論:傾かない原因はperspectiveの置き場所
マウス連動のティルト(傾き)が効かないという相談は、ほぼ全部が同じ1点に集約されます。rotateXとrotateYは書けているのに、遠近投影の設定が要素の外側に用意されていないというものです。
MDNはperspectiveの値についてこう書いています(英語版・2026年8月2日取得)。
A <length> giving the distance from the user to the z=0 plane. It is used to apply a perspective transform to the children of the element.
(訳:ユーザーからz=0平面までの距離を表す長さ。要素の子に遠近変形を適用するために使われる)
出典:MDN Web Docs「perspective」
つまり.card { perspective: 1000px; }と書いたとき、遠近感が付くのは.cardの中身であって、.card自身の回転ではありません。回転する要素に書いた瞬間、その指定は自分には効かなくなるのです。
GSAP公式ドキュメントも同じことを、実装者向けの言い方で書いています。
To get your elements to have a true 3D visual perspective applied, you must either set the perspective property of the parent element or set the special transformPerspective of the element itself
(訳:要素に本当の3Dの遠近感を与えるには、親要素のperspectiveプロパティを設定するか、その要素自身に特殊なtransformPerspectiveを設定するかのどちらかが必要)
出典:GSAP Docs「CSSPlugin」
言葉だけだと納得しづらいので、JavaScriptを1文字も変えずにCSSのperspectiveの置き場所だけを差し替えて、四隅の実座標を測りました。カードの左上20%の位置にマウスを置いた瞬間の値です。
| perspectiveの書き方 | 上辺÷下辺 | 左辺÷右辺 | 結果 |
|---|---|---|---|
| どこにも書かない | 1.0000 | 1.0000 | 傾かない |
| 回転する要素(.card)自身に書く | 1.0000 | 1.0000 | 傾かない |
| 親要素(.grid)に書く | 0.9724 | 0.9800 | 傾く |
| GSAPのtransformPerspective | 0.9714 | 0.9793 | 傾く |
測定条件は、Playwright 1.62.1同梱のChromium・ビューポート1280×900・perspective 800px・傾きの最大角18度です。数値は環境で前後しますが、1.0000ちょうどか、そうでないかという差ははっきり出ます。上2行はGSAPがrotateY(-4.97deg) rotateX(4.97deg) scale(1.1, 1.1)というインラインスタイルを正しく書き込んだうえで、それでも比が1.0000です。回転値が入っているのに平行四辺形のままという状態が、この不具合の見分け方になります。
なぜ回転しているのに平面のままなのか
W3CのCSS Transforms Module Level 2は、perspectiveの初期値noneについて「すべてのオブジェクトはキャンバス上で平らに見える」と定めています(2026年8月2日取得)。3D回転は行われているのですが、投影方法が正射影なので、奥に行った辺が短く描かれません。結果として、横方向にわずかに縮んだ長方形にしかならず、その縮みはscale: 1.1の拡大に打ち消されて目視できなくなります。
「動いているように見えるのに立体に見えない」という感想になるのはこのためです。エラーが出ないので、原因にたどり着くまでに時間を溶かしやすい種類の不具合と言えます。
親に書くかtransformPerspectiveか、どちらを選ぶか
実測ではどちらも同じくらい傾きます(0.9724と0.9714)。選び分けの基準は見た目ではなく、消失点を共有したいかどうかです。
- 親要素に
perspectiveを書く:その親の下にある全カードが同じ消失点を共有する。カードを並べたグリッドでは、端のカードほど外向きに見えて自然になる。GSAP公式も「グループで共通の消失点を持たせたいなら親に書くのが通常はベスト」としている - GSAPの
transformPerspectiveを使う:transform: perspective(800px) rotateX(...)と同じ意味で、その要素だけに効く。親のマークアップを触れない場合や、カードごとに独立した見え方にしたい場合に向く
本記事の完成コードは親要素に書く方式を採用します。カードが6枚並ぶレイアウトなので、消失点を共有したほうが破綻しないためです。なおperspectiveそのものの基礎、transform-style: preserve-3dとの役割分担、3D空間に要素を並べる考え方はCSSとJSで作る3Dカルーセルの実装で丁寧に扱っています。3Dが初めてなら先にそちらを読むと、この記事の話が最短で通ります。
マウス座標を回転角に変換する
perspectiveが用意できたら、次はマウスの位置を角度に変換する計算です。ここがこの実装の中心で、行数としてはたった4行です。
中心を原点にして-0.5〜0.5に正規化する
ポインタの座標はビューポート基準(clientX / clientY)で届きます。これをカード中心を原点とした相対値に直します。
const rect = card.getBoundingClientRect();
const x = e.clientX - rect.left - rect.width / 2;
const y = e.clientY - rect.top - rect.height / 2;
この時点でxは「カード中心から右にどれだけ離れているか(px)」です。これを幅で割ると左端で-0.5、中心で0、右端で0.5という無次元の値になります。あとは最大角を掛けるだけです。
const TILT = 18;
const rotationY = (x / rect.width) * TILT;
const rotationX = (y / rect.height) * -TILT;
rotationXにマイナスが付くのは、Y軸(縦)方向は画面座標と回転の向きが逆になるためです。マウスがカードの下側にあるときyは正、そのとき手前に倒れてほしいので符号を反転させます。ここを間違えると「マウスと反対に傾く」という気持ち悪い挙動になります。動かして違和感があったら、まずこのマイナスを疑ってください。
最大角は定数で持つ(18度前後が扱いやすい)
最大角をベタ書きせずTILTという定数にしておくと、調整が1箇所で済みます。角度を変えたときに見え方がどう変わるかも実測しました(perspective 800px・カード左上20%にマウス)。
| 最大角 | 上辺÷下辺 | 印象 |
|---|---|---|
| 8度 | 0.9873 | 言われないと分からない程度 |
| 18度 | 0.9724 | 立体に見えて破綻しない |
| 30度 | 0.9568 | 端に寄せると歪みが強い |
この記事が以前まで載せていたコードは30度でした。数字だけ見ると「大きいほど立体的」ですが、カード端にマウスを置いたときの歪みが強く、画像の見え方が崩れます。8〜20度の範囲で、実際のカードサイズを見ながら決めるのが現実的です。カードが小さいほど、同じ角度でも歪みは目立ちません。
遠近の強さはperspectiveの距離で決まる
もうひとつの調整ダイヤルがperspectiveの値です。これは視点からz=0平面までの距離なので、小さいほど被写体に近づき、遠近が強くなります。同じ18度で距離だけ変えた実測が次です。
| perspectiveの値 | 上辺÷下辺 | 見え方 |
|---|---|---|
| 400px | 0.9477 | 強い。カードが小さいと歪んで見える |
| 800px | 0.9724 | 本記事の採用値 |
| 1600px | 0.9859 | 穏やか。大きな要素向き |
GSAP公式は「よく使われる値はおおよそ200〜1000で、数字が小さいほど遠近の歪みが強い」と説明しています。実測もこの説明どおりの傾向でした。角度と距離の2つを同時に動かすと収拾がつかなくなるので、まず距離を800pxで固定し、角度だけを調整するのがおすすめです。
getBoundingClientRectを毎回呼ぶ理由
「矩形の座標は最初に1回取れば十分では」と思うところですが、ポインタが動くたびに取り直すのが正解です。理由は3つあります。
getBoundingClientRect()が返すのはビューポート基準の座標なので、ページをスクロールすると値が変わる。キャッシュすると、スクロール後に回転の中心がずれる- ウィンドウ幅が変わるとグリッドの列数が変わり、カードの位置もサイズも変わる
- 取得する値は
clientXと同じ座標系なので、そのまま引き算できる。座標系を揃える処理を書かずに済む
なおgetBoundingClientRect()はtransformが適用された後の見た目の矩形を返します。カードが傾いて拡大している最中は、その拡大後の矩形が返るということです。角度計算にはわずかな揺れが乗りますが、ポインタ追従では体感できるレベルではありません。厳密に固定したい場合はpointerenter時点の矩形を保持し、スクロールとリサイズで更新する形にします。
ちなみに、同じ「カードを立体的に動かす」でも、クリックやタイマーを起点にしてカードをめくる・重ねる演出は設計がまったく別物になります。そちらはGSAPでカードをフリップ・スタックさせる実装にまとめています。スクロール量を起点にする場合はGSAPのスクロール連動アニメーションが対応する記事です。
完成コード(HTML・CSS・JavaScript)
ここから先は、そのままコピーして1枚のHTMLに貼れば動く形で載せます。この記事に掲載する最終形を実際に結合してChromiumで実行し、上辺÷下辺が1.00から外れることを確認済みです(実測値は後述)。
GSAPの読み込み
</body>の直前、または<head>内に置きます。バージョンは2026年8月2日時点の最新である3.15.0です。
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.15.0/gsap.min.js"></script>
HTML
カードはdivですが、tabindex="0"を付けてキーボードでも到達できるようにします。この1属性の有無で、マウスを使わない読者に何も起きない実装になるかどうかが変わります。
<div class="grid">
<div class="card" tabindex="0"><img src="https://picsum.photos/180/250?random=1" alt="サンプル画像1"></div>
<div class="card" tabindex="0"><img src="https://picsum.photos/180/250?random=2" alt="サンプル画像2"></div>
<div class="card" tabindex="0"><img src="https://picsum.photos/180/250?random=3" alt="サンプル画像3"></div>
<div class="card" tabindex="0"><img src="https://picsum.photos/180/250?random=4" alt="サンプル画像4"></div>
<div class="card" tabindex="0"><img src="https://picsum.photos/180/250?random=5" alt="サンプル画像5"></div>
<div class="card" tabindex="0"><img src="https://picsum.photos/180/250?random=6" alt="サンプル画像6"></div>
</div>
CSS
効かせている一行は.gridのperspective: 800pxです。ここが無いと、以降のJavaScriptを完璧に書いても平面のままになります。
body {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: linear-gradient(135deg, #1a1a1a, #2e2e2e);
margin: 0;
}
.grid {
perspective: 800px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 180px));
justify-content: center;
gap: 20px;
width: min(600px, 100% - 32px);
}
.card {
aspect-ratio: 18 / 25;
position: relative;
overflow: hidden;
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow: inset 5px 5px 15px rgba(255, 255, 255, 0.1),
inset -5px -5px 15px rgba(0, 0, 0, 0.3),
5px 5px 15px rgba(0, 0, 0, 0.4);
will-change: transform;
}
.card img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
filter: blur(6px) brightness(0.6);
}
.card:focus-visible {
outline: 3px solid #7fd1ff;
outline-offset: 3px;
}
@media (prefers-reduced-motion: reduce) {
.card img {
filter: none;
}
}
元のコードから変えた箇所と、その理由を並べます。
| 変更点 | 理由 |
|---|---|
| .grid に perspective を追加 | これが無いと傾かない。本記事の主題 |
| 幅の 600px 固定をやめた | 画面幅320pxで横スクロールが出ていた(実測133px溢れ) |
| カードの固定寸法を縦横比に変更 | 列幅に合わせて縮むようにするため |
| 画像の transition を削除 | GSAPと二重に補間され、指定の2倍以上遅くなっていた |
| 画像のぼかしを1pxから6pxへ | 1pxでは「ぼやけた状態から鮮明になる」と呼べる差が出ない |
| box-shadow の transition を削除 | JavaScript側が box-shadow を一度も変更しない死にコードだった |
| focus-visible と reduced-motion を追加 | キーボード利用者と、動きを減らす設定の利用者に対応するため |
will-change: transformは残しています。これはブラウザに合成レイヤーの準備を促す指定ですが、本記事では描画コストの計測をしていません。カードが数十枚に増えるような使い方をするなら、外した場合と比べて判断してください。
JavaScript
mousemoveではなくpointermoveを使い、pointerTypeで入力デバイスを判定しています。理由は次の章で説明します。
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
const finePointer = window.matchMedia('(hover: hover) and (pointer: fine)');
const TILT = 18;
document.querySelectorAll('.card').forEach(card => {
const img = card.querySelector('img');
const focusIn = () => {
if (reduce.matches) return;
gsap.to(img, { scale: 1.2, filter: 'blur(0px) brightness(1)', duration: 0.25, ease: 'power2.out' });
};
const reset = () => {
if (reduce.matches) return;
gsap.to(card, { rotationX: 0, rotationY: 0, scale: 1, duration: 0.4, ease: 'power2.out' });
gsap.to(img, { scale: 1, filter: 'blur(6px) brightness(0.6)', duration: 0.4, ease: 'power2.out' });
};
card.addEventListener('pointerenter', (e) => {
if (e.pointerType !== 'mouse' || !finePointer.matches) return;
focusIn();
});
card.addEventListener('pointermove', (e) => {
if (reduce.matches || e.pointerType !== 'mouse' || !finePointer.matches) return;
const rect = card.getBoundingClientRect();
const x = e.clientX - rect.left - rect.width / 2;
const y = e.clientY - rect.top - rect.height / 2;
gsap.to(card, {
rotationX: (y / rect.height) * -TILT,
rotationY: (x / rect.width) * TILT,
scale: 1.1,
duration: 0.25,
ease: 'power2.out'
});
});
const pointerReset = (e) => {
if (e.pointerType !== 'mouse') return;
reset();
};
card.addEventListener('pointerleave', pointerReset);
card.addEventListener('pointercancel', pointerReset);
card.addEventListener('focus', focusIn);
card.addEventListener('blur', reset);
});
元のコードからの実質的な変更は4点です。ポインタ種別の判定を足した、動きを減らす設定を尊重するようにした、キーボードのフォーカスでも画像が鮮明になるようにした、画像のopacity操作をやめた。最後のひとつは、ぼかしと明度で見せ方を作るなら不透明度まで動かす必要がないためです。
もうひとつ、地味ですが重要な点があります。GSAPはtransformを必ず移動→拡大→X軸回転→Y軸回転→傾斜→回転という決まった順序で組み立てます。CSSのtransformのように「書いた順に適用される」わけではないので、rotationXとscaleを同じgsap.to()に並べる順番は結果に影響しません。順序を気にせず書けるのはGSAPを使う実利のひとつです。
本当に傾いているかを数値で確かめる
ここがこの記事の本題です。目視では判定できないというのが、この不具合のいちばん厄介なところでした。回転値は入っている、動いてはいる、なんとなく立体っぽく見える気もする。だから「効いている前提」で先に進んでしまいます。
合否ラインを数字で持っておけば、この迷いは消えます。実装したものが意図どおりに効いているかを確かめる進め方は、CSSだけでホバー時に立体的な影を付ける実装でも同じ形を取っています。あわせて読むと、検証を先に決めてから作る流れがつかめます。
検証1:上辺と下辺の長さを比べる
原理は簡単です。遠近投影が効いていれば、手前に来た辺は長く、奥に行った辺は短く描かれます。だから上辺と下辺の長さの比は1から外れます。逆に、比がずっと1.0000のままなら遠近投影が効いていません。
カードの四隅に1pxの目印を置き、4秒間サンプリングして比の最小と最大を出します。ページを開いてブラウザの開発者ツールのコンソールに貼り、Enterを押してから1枚目のカードの上でマウスを大きく動かしてください。
(() => {
const card = document.querySelector('.card');
const mk = css => { const d = document.createElement('div');
d.style.cssText = 'position:absolute;width:1px;height:1px;pointer-events:none;' + css;
card.appendChild(d); return d; };
const tl = mk('left:0;top:0'), tr = mk('right:0;top:0'),
bl = mk('left:0;bottom:0'), br = mk('right:0;bottom:0');
const p = e => { const b = e.getBoundingClientRect(); return [b.x, b.y]; };
const d = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1]);
let min = 1, max = 1;
const end = performance.now() + 4000;
console.log('4秒間、1枚目のカードの上でマウスを動かしてください');
(function tick() {
const ratio = d(p(tl), p(tr)) / d(p(bl), p(br));
if (ratio < min) min = ratio;
if (ratio > max) max = ratio;
if (performance.now() < end) return requestAnimationFrame(tick);
[tl, tr, bl, br].forEach(e => e.remove());
console.log('上辺/下辺の最小 =', min.toFixed(4), '/ 最大 =', max.toFixed(4));
})();
})();
マーカーは4秒後に自分で消えるので、ページに何も残りません。合否ラインは次のとおりです。
| 出力 | 判定 |
|---|---|
| 最小・最大とも 1.0000 のまま | 遠近投影が効いていない。傾いていない |
| 1.00 を挟んで上下に動く | 効いている。マウスの位置で手前と奥が入れ替わっている |
手元での実測値です。マウスをカード内で対角線状に動かした4秒間の結果を載せます。
| 対象 | 最小 | 最大 | 判定 |
|---|---|---|---|
| 修正前のコード | 1.0000 | 1.0000 | 傾いていない |
| 本記事の完成コード | 0.9683 | 1.0321 | 傾いている |
小数第4位は動かし方や実行のたびに前後するので、そこを合わせにいく必要はありません。見るべきは1.0000を挟んで両側に振れているかです。マウスがカードの上側にあるときは上辺が奥に行って短くなり(比が1未満)、下側にあるときは逆になります(比が1超)。片側にしか振れない場合は、符号の反転を書き忘れているか、片方の軸しか回していない可能性があります。
1.0000のままだった場合の切り分けは3つです。
- 回転する要素自身に
perspectiveを書いていないか。 開発者ツールで.cardを選び、算出値にperspectiveが出ていたらそれが原因。親側へ移す - 親に書いたつもりが別の要素だった、または途中に要素が挟まっていないか。
perspectiveは直接の子だけでなく子孫全体に効くが、途中の要素にtransformやfilterが付くと座標系が切り替わって効かなくなることがある - GSAPに
transformPerspectiveを渡し忘れていないか。 親に書かない方式を選んだ場合はこちら。インラインスタイルにperspective(800px)という文字列が入っていれば渡せている
検証2:CSSのtransitionと二重に効いていないか
GSAPは毎フレーム、要素のインラインスタイルを書き換えます。そこにCSSのtransitionが乗っていると、ブラウザがその書き換えをさらに補間するので、指定したdurationより大幅に遅くなり、イージングも二重にかかります。
合否ラインは「宣言した時間で終わっているか」です。duration: 0.25なら250ms前後で目的の値に到達するはずです。次のコードは、画像の拡大が1.2倍に到達するまでの実時間を測ります。
(() => {
const card = document.querySelector('.card');
const img = card.querySelector('img');
console.log('カードにマウスを乗せてください');
card.addEventListener('pointerenter', () => {
const start = performance.now();
(function tick() {
const m = new DOMMatrixReadOnly(getComputedStyle(img).transform);
if (Math.abs(m.a - 1.2) <= 0.002) {
return console.log('1.2倍に到達まで', (performance.now() - start).toFixed(1), 'ms');
}
if (performance.now() - start > 5000) return console.log('到達せず');
requestAnimationFrame(tick);
})();
}, { once: true });
})();
手元の実測では、画像にtransitionが付いた状態が516.4ミリ秒、外した状態が199.5ミリ秒でした。どちらもGSAP側の宣言はduration: 0.25のままです。宣言の2倍以上かかっていたら二重掛けを疑う、というのが実用的な判断基準になります。
対処は単純で、GSAPが触るプロパティからCSSのtransitionを外すだけです。CSSだけで完結させるならGSAPを外す、GSAPを使うならCSS側を外す。どちらか一方に寄せます。
検証3:動きを減らす設定で本当に止まるか
OSの「視差効果を減らす」「アニメーションを減らす」をオンにした状態でカードにマウスを乗せ、開発者ツールで.cardのインラインスタイルを見ます。
| 状態 | インラインのtransform | 判定 |
|---|---|---|
| 修正前のコード | rotateY(-9.06deg) rotateX(9.04deg) scale(1.1, 1.1) | 設定を無視している |
| 本記事の完成コード | (付かない) | 設定を尊重している |
同じ条件で画像の算出filterも見ました。修正前はblur(0px)(=アニメーションが走った後の状態)、完成コードはnoneです。CSS側のメディアクエリでぼかしを切り、JavaScript側でも早期リターンする。両方に書かないと、片方だけでは動いてしまうのがこの手の対応の落とし穴です。
この記事に埋め込んであるデモも、実は傾いていない
下のCodePenデモは、あえて修正前のまま残してあります。教材として使えるからです。このデモのCSSには確かにperspective: 1000pxが書かれています。ただし書かれている場所が、回転する.card自身です。
See the Pen Untitled by masakazuimai (@masakazuimai) on CodePen.
このデモを別タブで開いて検証1のコードをコンソールに貼ると、上辺÷下辺は1.0000のまま動きません。手元で実測したときのインラインスタイルはrotateY(-9.07deg) rotateX(9.05deg) scale(1.1, 1.1)で、回転値は入っています。それでも比は1.0000でした。
ここから引き出せる教訓は明確です。perspectiveが書いてあることは、効いていることを意味しない。書いてあるかどうかではなく、どこに書いてあるかで結果が変わります。だからこそ、目視ではなく数値で確かめる手順が要ります。手元の実装を直したら、上のデモと自分の実装の両方で検証1を走らせて、数字が変わることを見てください。ここまで確認できれば、この不具合はもう再発しません。
タッチ端末とキーボードをどう扱うか
マウス追従の演出は、マウスを持っていない読者にとっては存在しない機能です。何も起きないだけならまだしも、中途半端に発火して邪魔になるのがいちばん困ります。
タップでホバー状態が誤発火する
タッチ端末をエミュレートした環境(390×844・タッチあり)でカードをタップした結果です。
| 対象 | タップ後のインラインtransform |
|---|---|
| 修正前のコード | rotateY(-5.93deg) rotateX(5.95deg) scale(1.1, 1.1) |
| 本記事の完成コード | 付かない(傾かない) |
タッチでもmousemoveは発火します。ブラウザが互換のために合成マウスイベントを送るためです。だからmousemoveだけで組むと、指で触れた瞬間にホバー演出が始まります。完成コードではpointermoveに変え、e.pointerType !== 'mouse'で弾いています。MDNはpointerTypeを「イベントを発生させたデバイスの種類を示す」プロパティと定義しており、値はmouse / pen / touchのいずれかです。
もう一段の保険がmatchMedia('(hover: hover) and (pointer: fine)')です。マウスのように「主たる入力機構でホバーができ、かつ精密に位置を指せる」環境かどうかを問い合わせます。デバイス種別の判定とメディア特性の判定を両方かけることで、タッチとマウスが両方ある端末での誤爆も抑えられます。
ひとつだけ補足があります。完成コードでタップすると、傾きは起きませんが画像はぼかしが取れて鮮明になります(実測でインラインのfilterがblur(0px) brightness(1)になることを確認)。カードにtabindexを付けた結果、タップでフォーカスが入り、フォーカス時の演出が動くためです。挙動としては自然なので、そのままにしています。タップでも完全に何も起こしたくない場合は、フォーカス時の処理でもfinePointer.matchesを見てください。
キーボードで到達できるようにする
修正前のカードはdivのままでtabindexも無く、Tabキーを押してもフォーカスが入りませんでした。実測でも、Tabを1回押した時点のアクティブ要素はBODYのままです。完成コードでは.cardにフォーカスが入り、画像のぼかしが外れます。
| 対象 | Tab押下後のアクティブ要素 | 画像のfilter |
|---|---|---|
| 修正前のコード | BODY | 変化なし |
| 本記事の完成コード | card | blur(0px) brightness(1) |
フォーカス時に傾きまで再現しないのは意図的です。傾きの角度はポインタの位置から決まるので、キーボード操作には対応する入力がありません。「情報として同じものが得られる」ことを優先し、演出の完全再現は狙わないという切り分けです。
tabindex="0"を付ける以上、フォーカスが見えることも必須です。:focus-visibleで輪郭線を出しています。なおカードがリンクやボタンとして機能するなら、divではなくaやbuttonにするほうが適切です。その場合tabindexは不要になります。
動きを減らす設定への対応はどこまで必要か
WCAG 2.2の達成基準2.3.3「インタラクションによるアニメーション」は、インタラクションによって発生する不要なアニメーションは無効化できることを求めています。ただしこれはレベルAAAの基準です(W3C「Understanding SC 2.3.3」2026年8月2日取得)。法令対応の文脈で必須になることは多くありません。
それでも入れる価値はあります。prefers-reduced-motionを見る処理は、CSS側3行とJavaScript側2行で終わります。この分量で「見ていない実装」から「見ている実装」に変わるなら、入れない理由のほうが少ないという判断です。
このカードの「ガラス感」を作っているのは何か
この記事は以前「ガラス風カード」という看板を掲げていました。CSSには確かにbackdrop-filter: blur(10px)が入っています。ところが実測すると、この配色ではほとんど何も起きていませんでした。
同じカードをbackdrop-filterありとなしでスクリーンショットに撮り、1ピクセルずつ比較しました。数値はRGB各チャンネルの差の最大値と、差が出たピクセルの割合です。
| 背景と条件 | 最大差 | 変化ピクセル |
|---|---|---|
| グラデ背景・画像がカード全面を覆う | 2 / 255 | 0.21% |
| グラデ背景・余白を付けてガラス面を露出 | 2 / 255 | 3.86% |
| ストライプ背景・余白を付けて露出 | 93 / 255 | 100% |
| ストライプ背景・画像がカード全面を覆う | 78 / 255 | 22.25% |
読み方はこうです。プロパティは正常に動いている(ストライプ背景では最大93の差が出て、100%のピクセルが変化している)。にもかかわらず元のデザインで差が出ないのは、背景が滑らかなグラデーションだからです。backdrop-filter: blur()は背後のピクセルを混ぜる処理なので、背後がもともと滑らかだと混ぜても結果がほぼ変わりません。MDNもbackdrop-filterを「要素の背後の領域に効果を適用する」プロパティと定義しています。背後に模様が無ければ、適用しても見た目は動かないわけです。
ではこのカードの立体感と質感は何が作っているのか。答えは半透明の白い枠線と、内側に入れた2本のbox-shadowです。明るい影を左上から、暗い影を右下から内側に落とすことで、面が持ち上がったように見せています。これはニューモーフィズムと呼ばれる手法の考え方そのもので、backdrop-filterとは無関係に成立します。
この影の作り方だけを取り出して深く扱っているのがCSSだけで作るニューモーフィズム風トグルスイッチです。JavaScriptを使わず、影の重ね方だけで凹凸を作る手順が追えます。
ガラス感を本当に出したいなら、選択肢は2つです。背景に模様や写真を敷いてぼかす対象を作るか、カードの中身をカード全面から少し引っ込めてガラス面を露出させるか。上の表の3行目が前者、2行目が後者の途中段階にあたります。余白だけ付けても背景が滑らかなら差は出ない、というのが実測の答えでした。
GSAPは今どこまで無料なのか
GSAPには長く「商用の一部用途は有料会員が必要」という前提がありました。ネット上の解説記事にはその頃の情報が残っています。現在は状況が変わっています。
公式の料金ページには「GSAP is now 100% free for all users, thanks to Webflow’s support.(Webflowの支援により、GSAPはすべてのユーザーに対して100%無料になりました)」と明記されています(2026年8月2日取得)。かつて有料会員向けだったプラグイン群も、同ページ上で無料提供の一覧に並んでいます。
ライセンス本文はStandard Licenseとして公開されています。URLが移動しているので、参照するなら現行のコミュニティ配下のページを見てください。旧アドレスは転送されます。禁止されている用途は、ざっくり言うとWebflowのビジュアルアニメーション機能と競合するノーコード制作ツールへの組み込みで、通常のWeb制作や受託案件は対象外です。
バージョンについても触れておきます。npmのレジストリで確認したところ、2026年8月2日時点の最新は3.15.0(2026年4月13日公開)でした。修正前の記事は3.12.2(2023年)を指定していました。この記事の検証では3.15.0を使い、ページエラー0件・コンソールエラー0件を確認しています。
なお、ライセンス条件は変わりうるものです。商用案件で使う前に、必ず公式の料金ページとライセンス本文を自分で確認してください。ここに書いた内容は2026年8月2日時点のものです。
参考にした一次ソース
本文中の仕様・引用文はすべて次のページから取りました。取得日は2026年8月2日で、いずれもこの日にHTTP 200・転送なしで到達することを確認しています。
- MDN Web Docs「perspective」(子に効くという定義)
- MDN Web Docs「transform-style」
- MDN Web Docs「backdrop-filter」
- MDN Web Docs「PointerEvent.pointerType」
- MDN Web Docs「@media (hover)」
- MDN Web Docs「prefers-reduced-motion」
- W3C「CSS Transforms Module Level 2」(perspective: none の定義)
- GSAP Docs「CSSPlugin」(transformPerspective と transform の適用順)
- GSAP「Pricing」(100%無料化の記述)
- GSAP「Standard License」
- W3C「Understanding SC 2.3.3 Animation from Interactions」(レベルAAA)
検証環境も書いておきます。Playwright 1.62.1同梱のChromium(ヘッドレス)、Node.js 23.9.0、macOS。ビューポートは特記のない限り1280×900です。SafariとFirefoxでは検証していません。3D変形とbackdrop-filterはブラウザ間で描画が異なることがあるため、本番投入前にご自身の対象ブラウザで検証1を走らせることをおすすめします。タッチ端末の挙動もエミュレーションでの確認であり、実機での確認は行っていません。
よくある質問(FAQ)
Q. マウスに合わせてカードが傾かないのはなぜですか?
ほとんどの場合、perspectiveの置き場所が原因です。perspectiveは指定した要素の子に対して遠近投影をかけるプロパティなので、回転させる要素自身に書いても効きません。カードを囲む親要素に書くか、GSAPのtransformPerspectiveでtransformの中に直接入れてください。回転値そのものはインラインスタイルに書き込まれているのに立体に見えない、という症状であればこれが該当します。
Q. 親要素のperspectiveとtransformPerspectiveはどちらを使うべきですか?
複数のカードを並べるなら親要素に書く方式を選んでください。親に書くと配下の要素が消失点を共有するため、並んだカードの見え方が揃います。カードごとに独立した見え方にしたい場合や、親のマークアップを変更できない場合はtransformPerspectiveが向きます。傾きの強さ自体はどちらでもほぼ同じで、実測では上辺÷下辺が0.9724と0.9714でした。
Q. 傾きが効いているかを目視以外で確かめる方法はありますか?
カードの四隅に1pxの目印を置き、上辺の長さと下辺の長さの比を測ってください。遠近投影が効いていれば手前の辺が長く奥の辺が短く描かれるため、比が1.00から外れます。比がずっと1.0000のままなら効いていません。本記事の検証1にコンソールへ貼るだけのコードを用意しており、修正前は最小1.0000・最大1.0000、完成コードは最小0.9683・最大1.0321という結果でした。
Q. CSSのtransitionとGSAPを同時に使っても問題ありませんか?
GSAPが操作するプロパティに対しては同時に使わないでください。GSAPは毎フレームインラインスタイルを書き換えるため、同じプロパティにtransitionが乗っているとブラウザがその変化をさらに補間し、指定した時間より大幅に遅れます。実測では宣言0.25秒に対してtransitionありが516.4ミリ秒、なしが199.5ミリ秒でした。GSAPで動かすプロパティからはtransitionを外し、どちらか一方に寄せるのが確実です。
Q. スマートフォンではこの動きをどう扱えばよいですか?
ポインタの種類で処理を止めるのが基本方針です。タッチ操作でもブラウザは互換用の合成マウスイベントを送るため、mousemoveだけで組むと指で触れた瞬間にホバー演出が誤発火します。pointermoveに変えてpointerTypeがmouseのときだけ処理し、あわせてhoverとpointerのメディア特性も確認してください。実測でも、修正前はタップで傾きが適用されたのに対し、完成コードでは傾きが適用されませんでした。
Q. GSAPは商用サイトで無料で使えますか?
2026年8月2日時点の公式料金ページには「GSAPはWebflowの支援によりすべてのユーザーに対して100%無料になった」と明記されており、かつて有料会員向けだったプラグイン群も無料提供の一覧に並んでいます。禁止されている用途はWebflowのビジュアルアニメーション機能と競合するノーコード制作ツールへの組み込みで、通常のWeb制作や受託案件は対象外です。ライセンス条件は変わりうるため、導入前に公式のライセンス本文を確認してください。
Q. 傾きの角度はどれくらいが自然ですか?
カードサイズにもよりますが、最大8度から20度の範囲が扱いやすい目安です。実測では最大角8度で上辺÷下辺が0.9873、18度で0.9724、30度で0.9568となり、30度ではカード端にマウスを置いたときの歪みが強く出ました。角度とperspectiveの距離を同時に動かすと収拾がつかなくなるので、距離を800px前後で固定して角度だけを調整するのが実務的です。
Q. perspectiveを親要素に書いたのに傾かないときは何を確認しますか?
親と回転する要素の間に、transformやfilterが指定された要素が挟まっていないか確認してください。これらのプロパティは新しい座標系を作るため、遠近投影の連鎖が途中で切れることがあります。切り分けとしては、回転する要素の直近の親に一時的にperspectiveを移して比が動くかを見るのが早いです。それでも1.0000のままなら、GSAPのtransformPerspectiveを渡す方式に切り替えてください。
まとめ
- マウス追従で傾かない原因はほぼperspectiveの置き場所。回転する要素自身に書いても効かない
- 親要素に書くか、GSAPのtransformPerspectiveを使う。実測ではどちらも同程度に傾いた
- 合否ラインは「上辺÷下辺が1.00から外れるか」。1.0000のままなら効いていない
- GSAPで動かすプロパティにCSSのtransitionを重ねない。実測で2倍以上遅くなった
- pointerTypeで入力デバイスを判定し、タッチでの誤発火を止める
- 「ガラス感」を作っているのはbackdrop-filterではなく、半透明の枠線と内側のbox-shadow
- GSAPは2026年8月2日時点で全機能が無料。バージョンは3.15.0が最新
次の一手としておすすめしたいのは、いま自分が公開しているサイトの3D演出に検証1を走らせてみることです。この不具合は音もなく入り込みます。書いてあるのに効いていない、という状態は目視では見つかりません。数字が1.0000で固まっていたら、親要素を1行直すだけで直ります。
ホバー演出を他にも試したい場合は、実装済みのパターンをまとめて見られるホバーエフェクトのギャラリーツールと、CSSアニメーションのギャラリーツールが使えます。3D空間に複数の要素を並べる方向へ広げるならCSSとJSで作る3Dカルーセルが次のステップです。
アニメーションの設計や実装をまるごと任せたい場合は、Web制作の相談も受け付けています。
