GODRICH
#cssアニメーション#ボタン#ローディング#トランジション#スクロール#ダッシュボード#実用ui#デザイントレンド

マウス 追従 エフェクト 9選 — マウスストーカーの作り方

マウス 追従 エフェクトとは、カーソルの座標を毎フレーム読んで要素を一拍遅れで追わせるインタラクションです。核は lerp 一行で、canvas 不要、transform だけなので軽く動きます。文字・光・背景・パーティクル・カードまで9種を、追う対象が大きくなる順にまとめました。

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

この9つは人気順ではなく追う対象が大きくなる順です。01で点ひとつから遅れ追従の原理を学び、02・03で背景と光という舞台全体が反応する範囲に広がり、04・05は文字とパーティクルがカーソルの後ろに列をなす対象で、06から08はメニュー・カード・リストという実際の UI 部品が追う段階で、09は画像が速度に反応して伸びる結びです。原理はどれも同じ lerp なので、一度理解すれば9つすべてが同じコードの変奏になります。

01追従の基本 — 遅れて追う

すべての追従の土台で、残り距離の一部だけ毎フレーム進める lerp 一行により要素を一拍遅れで追わせ、残像3つがその遅れをフレーム差として見せます。

追従の基本 — 遅れて追う
新しいタブで開く

01 · 追従の基本 — 遅れて追う — 計測 累積面積 3.684% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

pointermoverequestAnimationFrametranslate3d

lerp 一行が遅れを作る

function lerp(a, b, k) { return a + (b - a) * k; }

function tick() {
  dot.x = lerp(dot.x, target.x, 0.16);
  dot.y = lerp(dot.y, target.y, 0.16);
  draw();
  requestAnimationFrame(tick);
}

k が 0.16 なら毎フレーム残り距離の 16% しか進まないので、カーソルが止まると距離は 84%、71%、59% と縮んでなだらかに寄り、残像3つはこの点を係数 0.10、0.065、0.04 でさらに追い、遅れを目に見えるようにします。

どれだけ遅れるか測った

描画計測は変化面積 3.68%・強度 120.2・動いたフレーム 23/23 で、実際にマウスを動かしたプローブでは最後の移動から40フレーム後に点がカーソルと 2.2px 以内に収束しました。

02追ってくる背景 — 3枚の視差

遠い丘・近い丘・草の3枚がカーソル x を異なる比率で追い、草はカーソル側へ skewX で倒れ、太陽の瞳もカーソルを追います。

追ってくる背景 — 3枚の視差
新しいタブで開く

02 · 追ってくる背景 — 3枚の視差 — 計測 累積面積 6.548% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

translate3dskewXvar(--px)

枚ごとに押される幅が違う

pxNow = lerp(pxNow, px, 0.08);
scene.style.setProperty('--px', pxNow.toFixed(4));
var dx = pxNow - 0.5;
far.style.transform   = 'translate3d(' + (dx * -10).toFixed(1) + 'px,0,0)';
mid.style.transform   = 'translate3d(' + (dx * -22).toFixed(1) + 'px,0,0)';
grass.style.transform = 'translate3d(' + (dx * -34).toFixed(1) + 'px,0,0)';

--px は 0 から 1 の間のカーソル横位置を格納するカスタムプロパティで、草の傾きはこの値ひとつを9枚すべてが skewX(calc((0.5 - var(--px)) * 26deg)) の一行で共有し、丘は遠いほど -10px・近いほど -22px 押され、草は -34px と最も大きく押されて視差が生まれます。

視差が数字として写る瞬間

変化面積 6.55%・強度 123.7・動いたフレーム 15/23 で、カーソルを右端に置いた30フレーム後に --px は 0.913 まで近づき(目標 0.963)、手前の草が同じフレームで -13.9px 押されている様子が記録されました。

03ついてくる光 — スポットライト

radial-gradient の光の塊が暗い舞台をカーソルより遅れて横切り、通過した文字と星を照らします。

ついてくる光 — スポットライト
新しいタブで開く

03 · ついてくる光 — スポットライト — 計測 累積面積 29.695% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

radial-gradientmix-blend-moderequestAnimationFrame

光の材料

.sl__light {
  width: 220px; height: 220px;
  background: radial-gradient(circle, rgba($subject-cream, .95) 0%,
              rgba($stage-yellow, .38) 38%, rgba($stage-yellow, 0) 68%);
  mix-blend-mode: screen;
  border-radius: 50%;
}

screen は暗い側だけ明るく持ち上げるため、光の下では文字が浮かび上がって通り過ぎれば沈み、追跡係数はゆっくり流れる 0.09 です。

遅い光の算数

変化面積 29.70% と最多で、強度 45.0・動いたフレーム 17/23 で、カーソルを固定して30フレーム数えても光の中心とカーソルの距離は最初の約 8% が残っており、一拍遅れの光がどれだけ長く目標を追うかが数字で示されました。

04ついてくる文字 — 文字の列車

単語を文字ごとに分け、最初の文字はカーソルを、後ろの文字は前の文字を追わせると、文字がカーソルの後ろに一拍ずつ続く列車になります。

ついてくる文字 — 文字の列車
新しいタブで開く

04 · ついてくる文字 — 文字の列車 — 計測 累積面積 10.345% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

pointermovewill-changetranslate3d

前の文字を追う鎖

var follow = [0.22, 0.16, 0.12, 0.09, 0.07];

function tick() {
  spots[0].x = lerp(spots[0].x, target.x, follow[0]);
  spots[0].y = lerp(spots[0].y, target.y - 34, follow[0]);
  for (var i = 1; i < spots.length; i++) {
    spots[i].x = lerp(spots[i].x, spots[i - 1].x, follow[i]);
    spots[i].y = lerp(spots[i].y, spots[i - 1].y + 8, follow[i]);
  }
  draw();
  requestAnimationFrame(tick);
}

後ろほど係数が小さいのが要点で、カーソルが急旋回すると後ろの文字が外側へ広がってしっぽのようになり、各文字には will-change: transform を与えて、ブラウザが前もって合成レイヤーを作れるようにします。

ポスターが掴んだ重なり

変化面積 10.35%・強度 120.3・動いたフレーム 23/23 ですが、最初の版は5文字すべてが同じ経路を遅延だけでずらして動き、抜き出したフレームの右端で2文字がひと塊に重なったため、文字ごとに開始位置を斜めに広げて対角線の列車に直しました。

05派手なパーティクルマウス — DOMの紙吹雪

canvas なしで span の破片をカーソル速度に比例して弾き、element.animate() の呼び出しで飛ばして消します。

派手なパーティクルマウス — DOMの紙吹雪
新しいタブで開く

05 · 派手なパーティクルマウス — DOMの紙吹雪 — 計測 累積面積 2.71% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

element.animate()pointermoveopacity

移動距離で弾く

function spawn(x, y, dx, dy) {
  var element = pool[next];
  next = (next + 1) % pool.length;
  element.className = 'pb__p ' + shapes[(Math.random() * shapes.length) | 0];
  element.style.setProperty('--pc', colors[(Math.random() * colors.length) | 0]);
  var ang = Math.atan2(dy, dx) + (Math.random() - 0.5) * 2.4;
  var dist = 34 + Math.random() * 46;
  var tx = x + Math.cos(ang) * dist;
  var ty = y + Math.sin(ang) * dist;
  element.animate([
    { transform: 'translate3d(' + (x - 5) + 'px,' + (y - 5) + 'px,0) rotate(0deg) scale(1)', opacity: 1 },
    { transform: 'translate3d(' + (tx - 5) + 'px,' + (ty - 5) + 'px,0) rotate(200deg) scale(.15)', opacity: 0 }
  ], { duration: 620 + Math.random() * 320, easing: 'cubic-bezier(.2,.8,.2,1)' });
}

カーソルが 26px 動くたびに破片がひとつ弾けるので、速く動くほどたくさん降り、点・リング・菱形の3図形の色は --pc プロパティひとつに畳まれ、canvas 側の軸はキャンバス パーティクル エンジンが引き継ぎます。

生きているアニメーションを数えた

ポインタを激しく動かすと生存アニメーションが41個まで上がり、止めてから1.2秒で0個に戻ります。再生終了した破片は fill を残さず最初の opacity: 0 に戻るので残骸が積もらず、計測は変化面積 2.71%・強度 112.1・動いたフレーム 23/23 でした。

06ナビゲーションのカーソルエフェクト — 滑るピル

メニュー項目の下のピル・ハイライトがカーソルの乗った項目の位置へ滑って移動し、ロゴの瞳はカーソル x を追います。

ナビゲーションのカーソルエフェクト — 滑るピル
新しいタブで開く

06 · ナビゲーションのカーソルエフェクト — 滑るピル — 計測 累積面積 3.0% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

pointerovergetBoundingClientRecttranslate3d

項目の位置を測って lerp で向かう

function measure(el) {
  var r = el.getBoundingClientRect();
  var w = wrap.getBoundingClientRect();
  return { x: r.left - w.left, w: r.width };
}

function tick() {
  now.x = lerp(now.x, slot.x, 0.16);
  now.w = lerp(now.w, slot.w, 0.16);
  pill.style.transform = 'translate3d(' + now.x.toFixed(1) + 'px,0,0)';
  pill.style.width = now.w.toFixed(1) + 'px';
  requestAnimationFrame(tick);
}

pointerenter のタイミングで getBoundingClientRect により項目の左端と幅を測ると、ピルはその位置へ移動しながら幅まで伸び、項目が button なのでタブ移動でも focus で同じ測定が起こり、キーボードにも反応します。

ピクセル単位で寄る

計測は変化面積 3.00%・強度 129.2・動いたフレーム 16/23 で、2番目の項目に pointerenter を送ってから0.7秒後、ピルの x は 44.9px となり、項目の実際の x 44.9px とちょうど一致しました。プレビューのループも、ロード時に測った項目の位置を CSS 変数でキーフレームに渡して読ませているので、フォントや言語が変わって項目の幅が変わってもピルが文字を追い越すことはありません。

07カードのオーバーエフェクト — グローリングと磁石

カード内を巡る radial-gradient の光の輪がカーソルを追い、カード自体は 3D 傾きなしの移動だけでカーソル側へわずかに寄ります。

カードのオーバーエフェクト — グローリングと磁石
新しいタブで開く

07 · カードのオーバーエフェクト — グローリングと磁石 — 計測 累積面積 17.272% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

pointermoveradial-gradientvar(--gx)

輪はカード内座標を追う

function setTarget(cx, cy) {
  var r = card.getBoundingClientRect();
  t.x = Math.min(r.width, Math.max(0, cx - r.left));
  t.y = Math.min(r.height, Math.max(0, cy - r.top));
  pull.x = Math.max(-7, Math.min(7, (t.x - r.width / 2) * 0.09));
  pull.y = Math.max(-7, Math.min(7, (t.y - r.height / 2) * 0.12));
}

輪の位置は --gx と --gy の2プロパティに格納され、SCSS の translate3d(calc(var(--gx) * 1px - 42px), calc(var(--gy) * 1px - 42px), 0) で動き、プロパティは輪の要素自身に設定しないと計算が束ねられず、カードの吸着はカーソルが中心から遠いほど最大 7px 引かれるクランプです。

カードの外へは出ない

マウス 追従 アニメーション css のような語で探す方にも、ここは出発点として適しています。種は8行の JS で、CSS は位置を保つだけなので、どのカードにも貼りやすく、計測は変化面積 17.27%・強度 71.5・動いたフレーム 19/23 で、カーソルを右下へ動かすと --gx 263.9・--gy 108.6 に達し、カーソルがカードの外へ出ると座標が縁にクランプされて輪はカードを出ませんでした。

08ホバー時の画像プレビュー — リボンの尾

行にカーソルを乗せると、その項目の絵が遅れてカーソルを追って浮かび、絵にはリボンの名札が付いて一緒に動きます。

ホバー時の画像プレビュー — リボンの尾
新しいタブで開く

08 · ホバー時の画像プレビュー — リボンの尾 — 計測 累積面積 12.68% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

pointerovertranslate3drotate

場面を切り替え、傾けながら追う

function tick() {
  p.x = lerp(p.x, t.x, 0.13);
  p.y = lerp(p.y, t.y, 0.13);
  rotNow = lerp(rotNow, rot, 0.1);
  rot = lerp(rot, 0, 0.06);
  preview.style.transform = 'translate3d(' + (p.x - 75).toFixed(1) + 'px,'
    + (p.y - 55).toFixed(1) + 'px,0) rotate(' + rotNow.toFixed(1) + 'deg)';
  requestAnimationFrame(tick);
}

3つの場面は同じ大きさで重ねて opacity で切り替え、傾きはカーソルの横移動量に応じて最大7度立ち上がって戻り、この傾きがあると絵がカーソルに引かれていく感触が出ます。

行が変わればリボンも変わる

計測は変化面積 12.68%・強度 91.5・動いたフレーム 18/23 で、2番目の行に pointerenter を送ると見えている場面のインデックスが1に変わり、リボンの文言はその行の名前に書き換わり、絵の中の風景は写真ファイルなしのグラデーション3枚なので、zip はどこで開いても壊れません。

09画像の歪み追跡 — 速度で伸びる

カーソルの毎フレーム移動量を測って、絵をその方向へ歪めて伸ばし、カーソルが止まるとゆっくり戻します。

画像の歪み追跡 — 速度で伸びる
新しいタブで開く

09 · 画像の歪み追跡 — 速度で伸びる — 計測 累積面積 5.497% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

skewXscalerequestAnimationFrame

速度を歪みに変える

function tick() {
  sx = lerp(sx, vx, 0.24);
  sy = lerp(sy, vy, 0.24);
  vx = lerp(vx, 0, 0.12);
  vy = lerp(vy, 0, 0.12);
  var skew = Math.max(-11, Math.min(11, sx * 0.32));
  var stretch = 1 + Math.min(0.14, Math.abs(sx) * 0.011 + Math.abs(sy) * 0.009);
  art.style.transform = 'skewX(' + skew.toFixed(2) + 'deg) scale(' + stretch.toFixed(3) + ')';
  requestAnimationFrame(tick);
}

速度 vx と vy は毎フレーム12%ずつ減衰するので、カーソルが止まっても値はすぐに0にならず、伸びが残像のように残って絵が跳ねて戻ります。

伸びて戻る幅

計測は変化面積 5.50%・強度 63.6・動いたフレーム 16/23 で、カーソルを往復でなぞる間に絵は skewX(-11deg) scale(1.14) まで伸び、1.5秒後に skewX(0deg) scale(1) へ戻りました。

マウスストーカー css だけでどこまで作れるか?

ここまで9つを貫く材料は4つだけです。pointermove で座標を受け取り、requestAnimationFrame で毎フレーム lerp 一行を回し、transform だけですべてを動かし、prefers-reduced-motion で遅れを止めます。マウスストーカー css にライブラリを足す記事は多くありますが、この4つで文字・光・背景・破片・メニュー・カード・画像まで賄えるのが上の9つで、同じ lerp ループの物理寄りの効果は物理インタラクションが引き継ぎます。

係数ひとつで性格が分かれる

係数 使う場所 性格
0.22 04の先頭文字 カーソルに密着する
0.16 01の点、06のピル 一拍遅れの基本値
0.09 03の光 ゆっくり流れる舞台照明
0.04 01の最後の残像 ずいぶん後から届くしっぽ

どの軸のマウス効果か決めておくと良い

この記事の軸は追ってくる対象です。カーソルの形自体を変える効果はマウス エフェクトが、カーソルを追って回る 3D シーンはポインタ 3D シーンがそれぞれ扱うので、カーソル飾りを探している方はそちらが近く、背景・文字・カードが反応する側が必要ならこの9つが答えです。

どこで壊れるか — CSSだけで回る部分と JS で回る部分

死んだ機能検査が静かに通過した

最初の版の 05 は typeof Element.animate === 'function' を検査してから破片を弾いていましたが、この式はどのブラウザでも常に偽です。animate はプロトタイプのメソッドなのでコンストラクタの Element には存在せず、ガードが常に失敗して破片がひとつも弾けないままプレビューのループは平然と回っていました。計測ゲートが見るのは .is-demo の CSS キーフレームなのでこの死んだ経路は掴めず、実際にマウスを動かしてアニメーション数を数えるプローブで0個と出て初めて発見できました。検査を document.body.animate に直した後は移動中41個に戻っています。プレビューは回るのに、実際にマウスを動かすと何も起きないコードはこういう形をしています。

translate のパーセントは要素自身の大きさ基準

01のプレビュー経路を最初 translate3d(78%, 22px, 0) のように書いたところ、カーソルリングが意図よりずっと短くしか動きませんでした。translate のパーセントは舞台ではなく要素自身の幅基準なので、18px のリングの 78% は 14px にしかならないためです。経路全体を px で書き直し、文字列車の最初のプレビューでも同じ原理で5文字がひと塊に重なって写ったため、開始位置を広げて直しました。抜いたフレームを人が見るまで通過ではない、という原則が2回とも当てはまった形です。

9つのデモと React 版をすべて収めた zip のパスワードは abmheerh で、タッチ画面では位置を追うデモが指を置く pointerdown で同じ追従を続けるようにしてあります。

3つのバリエーションとアクセシビリティ

名前 変える値 感じ
duration $dur-enter 600ms → $dur-quick 220ms 追いつきが速くなりカーソルに密着する
easing $ease-out → $ease-pop 到着時にひと跳ねする
color $subject-blue → $stage-yellow 追う要素が寒色から暖色へ移る

アクセシビリティは2層です。CSS 側では prefers-reduced-motion ブロックがプレビューのループを止めて各デモの基本の姿を見せ、JS 側では同じ設定を読んで lerp 追跡の代わりにカーソル位置へ即座に寄せます。03の光は中央に留まり、09の絵は伸びません。06のピルはボタンの focus にも同じ移動をかけてキーボード操作に反応し、ポインタのない画面に備えて位置を追う7つは pointerdown も同じように受け付け、06・08のようなカーソルの乗った対象を切り替える部品は pointerenter と focus がその役を担います。

FAQ

マウス 追従 エフェクトはスマートフォンでも使えますか?

はい。位置を追う7つは pointerdown も同じように受け付けるので、指を置いたり動かした位置がそのまま追従先になります。ただしタッチにはカーソルがないので、後ろに列をなす文字の列車より、面全体が反応する02や07の方が伝わりやすいです。

lerp の代わりに transition だけではだめですか?

ポインタが動くたびに transition を掛け直す方法は、イベントが連続すると目的地同士がぶつかり合って震えが生じます。lerp のループは目標が何度変わっても残り距離に比例して一様に減衰するので、04や05のように複数の追従対象を並べる構成で特に安定します。

パーティクルを増やすと重くなりませんか?

この構造は8個の破片をプールで回すので、数を増やしても DOM ノード数は変わりません。生存アニメーションは実測で最大41個で、それぞれ1秒以内に自分で片付きます。数千個をまく構成ならキャンバス パーティクル エンジンの出番です。

出典

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

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