GODRICH

マウス 追従 アニメーション css で作る3D場面9選

マウス追従アニメーションとは、ポインタの位置に反応して要素が回る CSS 3D 回転の場面です。「マウス 追従 アニメーション css」の9つの場面は perspective と preserve-3d、素の JavaScript

自動再生 · タイルを押すとその項目へ · すべて1つのzip

9つの並び順は派手さではなく、場面がポインタにどう反応するかの順です。まず角度で反応する3つ — ポインタの X・Y を rotateYrotateX の角度に変える製品モック(01)に、奥行きごとの係数でカードの層を分け(02)、ドラッグで自由回転させます(03)。次は方向で反応する3つ — カーソルまでの角度を測る瞳(04)に、同じ単語を重ねて浮かべる 3D テキスト(05)、カーソルを光源にするハイライト(06)が続きます。最後の3つはポインタがなくても成立する道 — パノラマで視差の感覚を加え(07)、傾きセンサーに引き継ぎ(08)、自動回転と prefers-reduced-motion での停止で締めます(09)。ステージの色はインク3・紙2・黄2・橙2の順で回しています。スクロールに反応する画面はスクロールストーリーテンプレートに、カーソル自体を変える効果はマウスカーソル効果9種にあります。

01マウスに合わせて回る製品モックアップ

ポインタの X 位置を rotateY ±16度、Y 位置を rotateX ±6度に変える基本の場面です。慣性とバネ戻りを1つのループで処理します。目標角度へ引く力と摩擦を毎フレーム掛け合わせると、離したときに弾むように正面へ戻ります。グリッドのプレビューはポインタが端から端まで通ったのと同じ範囲を CSS キーフレームの軌道で再生し、累積面積 3.96%・移動フレーム 23/23 を記録しました。製品詳細ページのメイン画像に使います。

perspectiverotateYtransform-style
// 減衰追跡: 目標へ引く力(stiffness)と摩擦(damping)で慣性とバネ戻りを1つの式で処理する
function step() {
  velX = (velX + (aimX - nowX) * 0.18) * 0.76;
  velY = (velY + (aimY - nowY) * 0.18) * 0.76;
  nowX += velX;
  nowY += velY;
  model.style.transform = 'rotateX(' + nowX.toFixed(2) + 'deg) rotateY(' + nowY.toFixed(2) + 'deg)';
  var resting = Math.abs(velX) < 0.01 && Math.abs(velY) < 0.01 && Math.abs(aimX - nowX) < 0.05 && Math.abs(aimY - nowY) < 0.05;
  if (resting && !holding) { running = false; return; }
  requestAnimationFrame(step);
}
/* perspective は場面(親)に、preserve-3d は回る要素に置く — 1つの要素に重ねると面が潰れる */
.pr__scene {
  position: relative;
  perspective: 620px;
  perspective-origin: 50% 42%;
}
.pr__model {
  position: absolute; inset: 0; margin: auto;
  transform-style: preserve-3d;
}

02奥行きごとにずれるカードスタック

カード3枚がそれぞれ別の奥行き係数を持ち、JavaScript はポインタの x を -1〜1 の値1つだけ渡します。前のカードは 26px、真ん中は 16px、後ろは 8px 動き、translateZ が遠近による縮みまで担うので層が分かれて読めます。同じ値で光沢帯のグラデーション角度も連動して動きます。プレビューは累積面積 11.46%・移動フレーム 19/23 — 料金プランの比較カードに使います。

translate3dlinear-gradientrotateY
/* 奥行き係数: 前のカードが最も大きく動く。translate3d の z が遠近縮小も担う */
.cs__card {
  transform: translate3d(calc(-50% + var(--px) * var(--shift)), calc(-50% + var(--y)), var(--z))
             rotateY(calc(var(--px) * var(--tilt)));
}
.cs__card--back  { --shift: 8px;  --z: -56px; --tilt: 3deg; --y: -28px; }
.cs__card--mid   { --shift: 16px; --z: -28px; --tilt: 5deg; --y: -18px; }
.cs__card--front { --shift: 26px; --z: 0px;   --tilt: 7deg; --y: 0px; }
function step() {
  now += (aim - now) * 0.16;
  scene.style.setProperty('--px', now.toFixed(3));
  if (Math.abs(aim - now) < 0.002) { scene.style.setProperty('--px', aim.toFixed(3)); running = false; return; }
  requestAnimationFrame(step);
}

03ドラッグで回す6面ボックス

6つの面にコンテンツを置き、ドラッグで自由に回す箱です。離すと残った速度でさらに回り、90度の目盛りのうち最も近い側へ引かれて止まります — 慣性とスナップが同じループの中で続きます。プレビューは累積面積 9.35%・移動フレーム 18/23。6つのカテゴリを選ぶ画面に使います。

rotateXpointermovesetPointerCapture
// 離した後は残り速度で回り(慣性)、90度の目盛りの最も近い側へ引かれて止まる
function step() {
  if (dragging) { running = false; return; }
  var snap = Math.round(angle / 90) * 90;
  spin = spin * 0.92 + (snap - angle) * 0.08;
  angle += spin;
  paint();
  if (Math.abs(spin) < 0.05 && Math.abs(snap - angle) < 0.3) { angle = snap; paint(); running = false; return; }
  requestAnimationFrame(step);
}
/* 6つの面は同じ正方形を辺の半分(--s / 2)だけ外へ押し出して立てたもの */
.cb__face--f { transform: translateZ(calc(var(--s) / 2)); }
.cb__face--r { transform: rotateY(90deg) translateZ(calc(var(--s) / 2)); }
.cb__face--u { transform: rotateX(90deg) translateZ(calc(var(--s) / 2)); }

04目でカーソルを追うキャラクター

目ごとに自分の中心からカーソルまでの角度を Math.atan2 で測り、その方向へ瞳を目の大きさの 22% だけ動かします。まばたきは、まぶたが1フレームで覆いかぶさって別のフレームで開く steps(1, end) のカットで表現し、猫とロボットは立つ場所が違うので同じカーソルを別の角度で見ます。プレビューは累積面積 2.57%・移動フレーム 20/23 — 404ページのマスコットに使います。

Math.atan2scaleYsteps
// 目ごとに中心からカーソルまでの角度を測り、その方向へ瞳を半径の分だけ出す
function look(clientX, clientY) {
  for (var i = 0; i < eyes.length; i++) {
    var eye = eyes[i];
    var pupil = eye.firstElementChild;
    var r = eye.getBoundingClientRect();
    var angle = Math.atan2(clientY - (r.top + r.height / 2), clientX - (r.left + r.width / 2));
    var reach = Math.min(r.width, r.height) * 0.22;
    pupil.style.transform = 'translate(' + (Math.cos(angle) * reach).toFixed(1) + 'px, ' + (Math.sin(angle) * reach).toFixed(1) + 'px)';
  }
}

05層になって浮かぶ3D文字

同じ単語を5枚重ね、枚ごとに translateZ を -32px から 0px まで変えて厚みを作ります。ポインタが動くと手前の層と奥の層が別の角度へ押され、文字がまるごと彫られたように読めます。奥の層は半透明のインクへ退いて手前の層が生き残ります。プレビューは累積面積 3.03%・移動フレーム 23/23 — イベントページの大見出しに使います。

translateZperspectiverotateX
.tx__layer {
  position: absolute; left: 50%; top: 50%;
  transform: translate(-50%, -50%) translateZ(var(--z));
  color: rgba($subject-ink, .55);
  font-weight: 800; font-size: clamp(26px, 8vw, 40px); line-height: 1; letter-spacing: .04em;
}
.tx__layer:nth-child(1) { --z: -32px; }
.tx__layer:nth-child(2) { --z: -24px; }
.tx__layer:nth-child(3) { --z: -16px; }
.tx__layer:nth-child(4) { --z: -8px; }
.tx__layer--top {
  --z: 0px;
  position: relative; left: auto; top: auto;
  transform: translateZ(0);
}

06カーソルを光源にするハイライト

カーソルの位置に丸い光を作り、その位置を面ごとにその面基準のパーセントで渡します。角張った面は conic-gradient で折り目の付いた面のように、波打つ面は repeating-radial-gradient のさざ波のように描き、光は mix-blend-mode: screen で下の模様を生かしたまま明るくします。プレビューは累積面積 13.95%・移動フレーム 22/23 — ダークテーマのダッシュボードで強調パネルに使います。

radial-gradientconic-gradientmix-blend-mode
@property --mx { syntax: "<percentage>"; inherits: true; initial-value: 50%; }
@property --my { syntax: "<percentage>"; inherits: true; initial-value: 50%; }

/* 光はカーソルの位置から広がる丸い明るさ。mix-blend-mode で下の面の模様を生かしたまま明るくする */
.lt__glow {
  position: absolute; inset: 0;
  background: radial-gradient(circle 76px at var(--mx) var(--my),
    rgba($stage-paper, .92), rgba($stage-paper, .28) 42%, rgba($stage-paper, 0) 72%);
  mix-blend-mode: screen;
}
function lightAt(clientX, clientY) {
  var faces = panel.querySelectorAll('.lt__surface');
  for (var i = 0; i < faces.length; i++) {
    var r = faces[i].getBoundingClientRect();
    faces[i].style.setProperty('--mx', ((clientX - r.left) / r.width * 100).toFixed(1) + '%');
    faces[i].style.setProperty('--my', ((clientY - r.top) / r.height * 100).toFixed(1) + '%');
  }
}

07前後に分かれる風景パノラマ

空 8px、山 22px、草原 44px — 層ごとの移動係数を3倍ずつ広げて窓の外のような奥行きを作ります。JavaScript は 02 と同じ -1〜1 の値1つだけを渡し、係数は CSS が層ごとの --depth で持ちます。山は clip-path の三角形2枚で描きました。プレビューは累積面積 14.1%・移動フレーム 21/23 — 旅行商品の紹介バナーに使います。

translateXfilterclip-path
.tp__layer {
  position: absolute; left: -12%; right: -12%;
  transform: translateX(calc(var(--px) * var(--depth)));
}
.tp__layer--sky {
  --depth: 8px;
  top: 0; height: 62%;
}
.tp__layer--hill {
  --depth: 22px;
  bottom: 26%; height: 48%;
}
.tp__layer--field {
  --depth: 44px;
  bottom: 0; height: 30%;
}

/* 山は clip-path の三角形2枚 — グラデーションで描くと半分のくさびになり稜線として読めない */
.tp__peak {
  position: absolute; bottom: 0; height: 100%;
  clip-path: polygon(50% 0, 100% 100%, 0 100%);
}

08傾きセンサーへ引き継ぐフォールバック

マウスがない端末では deviceorientationbetagamma の値で同じ場面を回します。今どちらが操作しているかをラベルで示すのがこの部品の主役です。iOS 13 以降は DeviceOrientationEvent.requestPermission がユーザー操作のあとでしかセンサーの使用を許さないので、それまではポインタが担当します。プレビューは累積面積 1.71%・移動フレーム 23/23 — スマホで開く製品プレビューに使います。

deviceorientationDeviceOrientationEventpointermove
// iOS 13 以降は DeviceOrientationEvent.requestPermission がユーザー承認の後でしか渡さない — それまでポインタが担当
var hasSensor = typeof window.DeviceOrientationEvent !== 'undefined'
             && typeof window.DeviceOrientationEvent.requestPermission !== 'function';

if (hasSensor) {
  window.addEventListener('deviceorientation', function (e) {
    var beta = e.beta === null ? 0 : e.beta;     // 前後の傾き
    var gamma = e.gamma === null ? 0 : e.gamma;  // 左右の傾き
    tilt(Math.max(-12, Math.min(12, (beta - 40) * 0.4)), Math.max(-18, Math.min(18, gamma * 0.6)), TILT_LABEL);
  });
}

09ポインタがないときの自動回転

(hover: hover) and (pointer: fine) のメディアクエリでポインタがある端末かどうかを先に尋ね、あれば人が回し、なければ CSS アニメーションが8秒かけて1回転を繰り返します。動きを減らす設定では回転を止めて1つの面を正面に立てます。プレビューは累積面積 7.72%・強度 145.8・移動フレーム 23/23 — キオスクの待機画面に使います。

prefers-reduced-motionmatchMediaanimation-duration
// ポインタがある端末か先に尋ねる — あれば人が回し、なければ自分で回る
var fine = window.matchMedia('(hover: hover) and (pointer: fine)');

function backToAuto() {
  prism.style.transform = '';
  prism.classList.add('ar__prism--auto');
  state.textContent = AUTO_LABEL;
}
/* 自動回転はクラス1つで着脱 — 人が回している間このクラスはない */
.ar__prism--auto {
  animation-name: arTurn;
  animation-duration: 8s;
  animation-timing-function: $easing;
}

どこで壊れるか — 落とし穴

1つ目は、プレビューの代表場面がいつも正面ばかりだったことです。回転した面の見える幅は角度のコサインなので、面積の変化率はサイン×角速度になります。±16度を等速で往復させると正面(0度)付近でフレーム間の差が最も大きくなり、poster がいつも最も平らな正面で選ばれてしまいました。キーフレームを切り替えて正面をゆっくり通り、端で速く曲がるようにしたところ、同じ測定条件でも傾いた場面が最大変化として選ばれるようになりました。2つ目は 07 の山を conic-gradient で描いたら半分のくさびとして描かれたこと — 頂点が下端中央の扇形は180度より広く開いたときだけ生き残るので、稜線には最初から clip-path の三角形が正解でした。3つ目は 03 の青い面の上のクリーム色の文字が既定のトークン青(#2f6df6)の上で 4.25:1 となり AA(4.5:1)に届かなかったこと — 面の色を1段下げた #0a4fe8 で 5.98:1 を測定しました(根拠: run/290/_대비실측.json)。ポインタが本当に追従するかは9つすべて Playwright の mouse.move で確認し、その記録が run/290/_probe/실측.json です。

落とし穴 症状 直し方
等速の往復回転 poster がいつも正面 正面をゆっくり、端を速く
conic-gradient の山 半分のくさびとして描画 clip-path の三角形2枚
青の面の上のクリーム文字 4.25:1 AA コントラスト未達 面を #0a4fe8 へ下げて 5.98:1

9つの場面のアーカイブパスワードは 27ds8fgs で、展開すると項目ごとに vanilla 版と React 版がペアで入っています。

アクセシビリティ

3D 効果は装飾なので、9つの場面すべてに role="img" と要約の aria-label を付け、ポインタ依存の機能にはキーボード操作も用意しました — 矢印キーで回転(01・05)、面を1つずつ送る(03)、視線を向ける(04)、光を動かす(06・08)、09 は Escape で自動回転に戻ります。08 のラベルが読み上げられるときは、センサーとポインタのどちらが実際に担当しているかを文で伝えます。prefers-reduced-motion では回転・視差・まばたきを止め、各場面を最も読みやすい固定角度に立てます — 06 だけは光の位置自体が情報なので光の場所を残し、動きだけを切ります。

FAQ

マウスがないスマホではどうなりますか?

09 の matchMedia('(hover: hover) and (pointer: fine)') がタッチ専用端末を見つけ、その端末では場面がゆっくり自分で回ります。08 を足せば操作が傾きセンサーの値に引き継がれ、端末を傾ける手がマウスの代わりを務めます。

3D 変換のせいで画面が重くなるのですが原因はどこですか?

たいていは、回している面の数です。transform-style: preserve-3d で囲まれた要素は合成レイヤーが積み重なり、面の数に比例してコストがかかります。07 のような層ごとに係数だけが違う視差の場面では will-change: transform を動く層にだけ掛け、影や filter を毎フレーム再計算するのは避けてください。

慣性なしで即座に追従させるには?

01・05 の step() で速度の項を外し、now += (aim - now) * 0.14 の1行だけ残せば即時追従になります。逆に慣性を強くするには摩擦係数(0.76)を 1 に近い側へ上げます。0.9 を超えると離した後いつまでも揺れるので 0.76〜0.85 の間がおすすめです。角度の計算はMath.atan2 のリファレンス、ポインタイベントはPointer Events の文書を参照してください。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。