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

css ボタン ホバー エフェクト 9選 — 距離の値で変わる

もしカーソルがまだボタンから少し離れていれば、距離の値はまだ大きいままだ。その値が縮むにつれて1番は後光の半径を広げ、6番は枠線を描き進め、9番は周囲の粒子密度を満たす。css ボタン ホバー エフェクト を探すと、この9つのボタンは同じ距離の値だけで反応が変わります。

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


距離の値ひとつがなぜ9つのボタンを同時に動かすのか

このページの9つのデモはすべて同じ小さな関数から始まります。ポインターとボタン中心とのピクセル距離を測り、その数値を0から1の比率に変え、見た目のあらゆる値がその一つの比率だけを読み取ります。:hover の境界でクラスを切り替えるだけの実装では、カーソルがまだ近づいている途中の変化を再現できません。css ボタン マウス オーバー で検索して来た人が期待するのはこの連続した変化のほうです。9つの違いは、どのプロパティがその比率を読むか、そして比率とのあいだにもう一段の計算(スプリング・ベクトル・順位・実測幅)が挟まっているかどうかだけです。

近づくボタンが実際にどの画面で役立つか

LPのCTAボタン(01・09)は、クリックの前段階でも後光や粒子が反応することで、近づくだけで価値があるように見せられます。カード型のボタン一覧(02・04・07)は、並んだ候補の中から一つだけを目立たせたいときにスケール・ぼかしのパターンが向いています。商品カードの先行反応(03)や強調ボタンの色変化(05)は、一つだけの主役ボタンに向いています。css ボタン デザイン を探している人の多くは、こうした9つをそのまま流用したいはずで、このページ下部の zip にはその実装がそのまま入っています。

パターン別コード — index.htmlからそのまま切り出しました

下のコードはすべて各項目の index.html から改変なしで切り出したものです。画面上のバッジ文字だけを実際にこのページが表示する日本語に置き換えています。デモの中で自動的に動いて見えるカーソルは syntheticOffset という関数が描く仮想の軌道で、実際の誰かのポインター操作を記録したものではありません。

01距離値の分だけ後光が広がるボタン

カーソルとボタンの間の距離値が縮む間、ボタンを包む後光の半径が広がり、明るさも同時に上がります。一つの近接比率(p)がぼかし半径・広がり・brightness() という3つのCSS値を同じ呼び出しの中で動かすため、光の広がりと明るさがずれることなく揃って変化します。

距離値の分だけ後光が広がるボタン
新しいタブで開く

01 · 距離値の分だけ後光が広がるボタン — 計測 累積面積 20.583% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

PROX_GLOW_RADIUS_MAX_PXdistanceToButtonsyntheticOffset
  function distanceToButton(x, y) {
    var r = btn.getBoundingClientRect();
    var cx = r.left + r.width / 2;
    var cy = r.top + r.height / 2;
    return Math.hypot(x - cx, y - cy);
  }

  function render(p) {
    var blurRadius = p * PROX_GLOW_RADIUS_MAX_PX;
    var spread = p * PROX_GLOW_RADIUS_MAX_PX * 0.3;
    var alpha = (0.18 + p * 0.55).toFixed(2);
    btn.style.boxShadow = '0 0 ' + blurRadius.toFixed(1) + 'px ' + spread.toFixed(1) + 'px rgba(255,247,230,' + alpha + ')';
    btn.style.filter = 'brightness(' + (1 + p * 0.22).toFixed(3) + ')';
  badge.textContent = '距離値 ' + Math.round((1 - p) * 100) + '%';
  }

計測値: 累積面積 20.6%、平均強度 52.6、動いたフレーム 22/23。

02距離値が縮むほど徐々に大きくなるボタン

カーソルの距離値はボタンの大きさを二値的に切り替えません。スプリング(STIFFNESS・DAMPING)が目標比率を1フレームずつ追いかけるため、カーソルが止まった後も大きさはしばらく動き続けます。同じスプリングの位置は --tone-p というカスタムプロパティにも書き込まれ、背景トーンもスケールと一緒にオーバーシュートします。

距離値が縮むほど徐々に大きくなるボタン
新しいタブで開く

02 · 距離値が縮むほど徐々に大きくなるボタン — 計測 累積面積 18.788% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

springStepSTIFFNESSDAMPING
  function render(p) {
    var scale = 1 + p * SL_SCALE_MAX;
    var lift = p * SL_LIFT_MAX_PX;
    var depth = 8 + p * 18;
    btn.style.transform = 'translateY(' + (-lift).toFixed(2) + 'px) scale(' + scale.toFixed(4) + ')';
    btn.style.boxShadow = '0 ' + depth.toFixed(1) + 'px ' + (depth * 1.6).toFixed(1) + 'px rgba(23,20,26,' + (0.18 + p * 0.18).toFixed(2) + ')';
    // 計算レイヤー: 同じスプリングオーバーシュート値(p)を背景トーン(影の奥のグロー)にも伝える。
    var tone = clamp(p, 0, 1.15);
    scene.style.setProperty('--tone-p', tone.toFixed(3));
  badge.textContent = '距離値 ' + Math.round((1 - clamp(p, 0, 1)) * 100) + '%';
  }

  function springStep(target) {
    var force = (target - pos) * STIFFNESS;
    vel = (vel + force) * DAMPING;
    pos += vel;
  }

計測値: 累積面積 18.8%、平均強度 47.4、動いたフレーム 18/23。

03距離値が角度値に変わる傾きボタン

ボタン中心からカーソルへの向きは、単なる距離ではなくベクトルとして保持されたうえで回転角に変換されるため、カーソルが触れる前からその方向へボタンが傾きます。角度の計算に使った ux・uy・p はそのまま縁のハイライト位置と奥行きの影にも使い回されます。

距離値が角度値に変わる傾きボタン
新しいタブで開く

03 · 距離値が角度値に変わる傾きボタン — 計測 累積面積 13.244% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

vectorToButtonTA_MAX_DEGPROX_RADIUS_PX
  function render(dx, dy) {
    // 方向ベクトル→角度(atan2)変換 — 距離値(近いほど1)を角度の大きさとして使う。
    var d = Math.hypot(dx, dy);
    var p = clamp(1 - d / PROX_RADIUS_PX, 0, 1);
    var ux = d > 0.001 ? dx / d : 0;
    var uy = d > 0.001 ? dy / d : 0;
    var rotY = ux * p * TA_MAX_DEG;
    var rotX = -uy * p * TA_MAX_DEG;
    var lean = p * 6;
    btn.style.transform = 'translate(' + (ux * lean).toFixed(2) + 'px,' + (uy * lean).toFixed(2) + 'px) rotateX(' + rotX.toFixed(2) + 'deg) rotateY(' + rotY.toFixed(2) + 'deg)';
    // 計算レイヤー: 同じ傾き角度(ux・uy・p)を縁のハイライト位置・奥行きの影にも伝える。
    var shadowX = ux * p * 16;
    var shadowY = 6 + uy * p * 12;
    var shadowBlur = 14 + p * 22;
    btn.style.boxShadow = shadowX.toFixed(1) + 'px ' + shadowY.toFixed(1) + 'px ' + shadowBlur.toFixed(1) + 'px rgba(23,20,26,' + (0.2 + p * 0.3).toFixed(2) + ')';
    btn.style.setProperty('--hl-x', (50 + ux * 38).toFixed(1) + '%');
    btn.style.setProperty('--hl-y', (50 + uy * 38).toFixed(1) + '%');
    btn.style.setProperty('--hl-o', (0.12 + p * 0.5).toFixed(2));
    var angleDeg = Math.atan2(dy, dx) * 180 / Math.PI;
  badge.textContent = '角度値 ' + Math.round(rotY) + '°';
    void angleDeg;
  }

計測値: 累積面積 13.2%、平均強度 52.4、動いたフレーム 18/23。

04距離値が遠いほどぼやける周囲のボタン

それぞれのボタンを固定半径だけで測るのではなく、まず全ボタン同士の距離を順位付けします。最も近い1つは必ず順位0になり、ぼかしは「生の近さ」と「順位」のうち不利なほうで決まるため、2つが接近していても最も近い1つだけが必ず鮮明に残ります。

距離値が遠いほどぼやける周囲のボタン
新しいタブで開く

04 · 距離値が遠いほどぼやける周囲のボタン — 計測 累積面積 18.241% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

updateTargetscentersOfBF_BLUR_MAX_PX
  function centersOf() {
    return btns.map(function (b) {
      var r = b.getBoundingClientRect();
      return [r.left + r.width / 2, r.top + r.height / 2];
    });
  }

  function updateTargets(px, py) {
    var centers = centersOf();
    var dists = centers.map(function (c) { return Math.hypot(px - c[0], py - c[1]); });
    // 距離ソート: 最も近いボタンの順位(rank)を求め、順位が低い(近い)ほどぼかしを減らす。
    var order = dists.map(function (d, i) { return i; }).sort(function (a, b) { return dists[a] - dists[b]; });
    order.forEach(function (idx, rank) {
      var near = clamp(1 - dists[idx] / PROX_RADIUS_PX, 0, 1);
      var byRank = rank === 0 ? 0 : Math.min(1, rank / (btns.length - 1));
      var mix = Math.max(byRank, 1 - near);
      targetBlur[idx] = mix * BF_BLUR_MAX_PX;
    });
  }

計測値: 累積面積 18.2%、平均強度 19.6、動いたフレーム 16/23。

05距離値が色の補間地点になるボタン

ここでは距離比率をサイズや角度ではなく、二つの固定RGB値を混ぜる補間点 t として使っています。ボタン自身の色は background-color の transition ではなく線形補間の結果で、同じ補間色がそのままボタン周囲のリングとカード全体の背景にも使い回されます。

距離値が色の補間地点になるボタン
新しいタブで開く

05 · 距離値が色の補間地点になるボタン — 計測 累積面積 14.462% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

lerpColorCOLOR_FARCOLOR_NEAR
  function lerpColor(c1, c2, t) {
    return [
      Math.round(c1[0] + (c2[0] - c1[0]) * t),
      Math.round(c1[1] + (c2[1] - c1[1]) * t),
      Math.round(c1[2] + (c2[2] - c1[2]) * t)
    ];
  }

  function render(p) {
    var c = lerpColor(COLOR_FAR, COLOR_NEAR, p);
    btn.style.background = 'rgb(' + c[0] + ',' + c[1] + ',' + c[2] + ')';
    // 計算レイヤー: 同じ補間色を縁(広がるリング)・カード全体の背景トーンにも伝える。
    var ringPx = (6 + p * 20).toFixed(1);
    var ringA = (0.14 + p * 0.26).toFixed(2);
    btn.style.boxShadow = '0 0 0 ' + ringPx + 'px rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + ringA + ')';
    scene.style.background = 'radial-gradient(circle, rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + (0.1 + p * 0.22).toFixed(2) + '), transparent 72%)';
  badge.textContent = '補間地点 ' + Math.round(p * 100) + '%';
  }

計測値: 累積面積 14.5%、平均強度 46.7、動いたフレーム 20/23。

06距離値の分だけ枠線が描かれるボタン

枠線は border ではなくSVGの rect で、実際の周囲長を getTotalLength() で一度だけ測っています。だから描画に使う長さは推測値ではなく実測値です。strokeDashoffset が同じ距離比率を読み取り、カーソルが近づくにつれてその実測の長さ分だけ線を描き進めます。

距離値の分だけ枠線が描かれるボタン
新しいタブで開く

06 · 距離値の分だけ枠線が描かれるボタン — 計測 累積面積 14.921% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

getTotalLengthstrokeDashoffsettotalLength
  // 実際の図形の周囲の長さ(px) — 推測値ではなく、SVGが直接測った値。
  var totalLength = rect.getTotalLength();
  rect.style.strokeDasharray = String(totalLength);

  var targetP = 0;
  var curP = 0;
  var hasPointer = false;

  function distanceToButton(x, y) {
    var r = wrap.getBoundingClientRect();
    var cx = r.left + r.width / 2;
    var cy = r.top + r.height / 2;
    return Math.hypot(x - cx, y - cy);
  }

  function render(p) {
    var offset = (1 - p) * totalLength;
    rect.style.strokeDashoffset = String(offset);
    rect.style.opacity = String(0.2 + p * 0.8);
  badge.textContent = '縁の値 ' + Math.round(p * 100) + '%';
    // 計算レイヤー: 同じ進行率(p)をリング周囲のグロー・ボタン背景のハイライト角(グラデーション角)にも伝える。
    wrap.style.setProperty('--p', p.toFixed(3));
  }

計測値: 累積面積 14.9%、平均強度 46.5、動いたフレーム 21/23。

07距離値の分だけ浮き上がる影のボタン

オフセットとぼかしは同じ距離値を読みますが独立していません。ぼかしはオフセットに固定倍率を掛けた値として定義されているため、カーソルがどれだけ近づいても影の見た目の関係は常に同じに保たれます。

距離値の分だけ浮き上がる影のボタン
新しいタブで開く

07 · 距離値の分だけ浮き上がる影のボタン — 計測 累積面積 7.634% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

PROX_SHADOW_LIFT_MAX_PXdistanceToButtonrender
  function distanceToButton(x, y) {
    var r = btn.getBoundingClientRect();
    var cx = r.left + r.width / 2;
    var cy = r.top + r.height / 2;
    return Math.hypot(x - cx, y - cy);
  }

  // オフセット・にじみを距離の関数として — どちらも同じ値(p)から分岐する
  function render(p) {
    var offsetY = p * PROX_SHADOW_LIFT_MAX_PX;
    var blur = offsetY * 1.7;
    var lift = p * 8;
    btn.style.transform = 'translateY(' + (-lift).toFixed(2) + 'px)';
    btn.style.boxShadow = '0 ' + offsetY.toFixed(1) + 'px ' + blur.toFixed(1) + 'px rgba(23,20,26,' + (0.22 + p * 0.24).toFixed(2) + ')';
  badge.textContent = '距離値 ' + Math.round((1 - p) * 100) + '%';
  }

計測値: 累積面積 7.6%、平均強度 62.1、動いたフレーム 21/23。

08距離値が文字間隔値として広がるボタン

距離比率はそのまま letterSpacing の em 値に書き込まれますが、重要なのはその後です。実際に文字が広がった後のラベルの描画幅を getBoundingClientRect() で測り直し、その実測値を下線とグロー(発光)のサイズに使っています。想定の式で幅を仮定してはいません。

距離値が文字間隔値として広がるボタン
新しいタブで開く

08 · 距離値が文字間隔値として広がるボタン — 計測 累積面積 26.463% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

LS_MAX_EMletterSpacingsyntheticOffset
  function distanceToButton(x, y) {
    var r = btn.getBoundingClientRect();
    var cx = r.left + r.width / 2;
    var cy = r.top + r.height / 2;
    return Math.hypot(x - cx, y - cy);
  }

  // 距離値を文字間隔値に変換する — 遠いときは0、近づくほど広がる
  function render(p) {
    var spacing = p * LS_MAX_EM;
    label.style.letterSpacing = spacing.toFixed(3) + 'em';
    btn.style.filter = 'brightness(' + (1 + p * 0.12).toFixed(3) + ')';
    // 計算レイヤー: 広がった実際の文字幅(getBoundingClientRect)をラベル背景・下線の長さに伝える。
    var w = label.getBoundingClientRect().width;
    underline.style.width = w.toFixed(1) + 'px';
    btn.style.setProperty('--underline-o', (0.2 + p * 0.6).toFixed(2));
    btn.style.setProperty('--label-w', w.toFixed(1) + 'px');
    btn.style.setProperty('--label-o', (0.12 + p * 0.4).toFixed(2));
    // 計算レイヤー: 同じ進行率(p)をボタンの後光の影の半径・不透明度にも伝える。
    var spread = (6 + p * 26).toFixed(1);
    btn.style.boxShadow = '0 0 ' + spread + 'px ' + spread + 'px rgba(10,79,232,' + (0.16 + p * 0.42).toFixed(2) + ')';
  badge.textContent = '間隔値 ' + spacing.toFixed(2) + 'em';
  }

計測値: 累積面積 26.5%、平均強度 37.6、動いたフレーム 21/23。

09距離値の分だけ粒子が集まるボタン

見えている粒子の数は整数で一つずつ切り替わるのではなく、p * PROX_PARTICLE_MAX_COUNT という連続した比率です。各粒子は自分の番号がその比率を下回ったときにだけ描かれるため、群れは点滅せず輪郭が連続的に埋まっていきます。角度にも半径にも粒子ごとのノイズと位相のずれがあり、同じ距離値でも二つとして同じ軌道を描く粒子はありません。

距離値の分だけ粒子が集まるボタン
新しいタブで開く

09 · 距離値の分だけ粒子が集まるボタン — 計測 累積面積 11.006% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

PROX_PARTICLE_MAX_COUNTdrawglobalAlpha
  function draw(p, t) {
    var w = canvas.width, h = canvas.height;
    ctx.clearRect(0, 0, w, h);
    var cx = w / 2, cy = h / 2;
    var count = p * PROX_PARTICLE_MAX_COUNT;
    var radius = (RADIUS_FAR_PX + (RADIUS_NEAR_PX - RADIUS_FAR_PX) * p) * radiusScale;
    var positions = [];
    particles.forEach(function (pt, i) {
      var visible = clamp(count - i, 0, 1);
      if (visible <= 0) { return; }
      var ang = pt.baseAngle + t * 0.5 + pt.noise * Math.sin(t * 1.3 + pt.phase) * 0.6;
      var rad = radius + pt.noise * 10 * radiusScale * Math.sin(t * 0.9 + pt.phase);
      var x = cx + Math.cos(ang) * rad;
      var y = cy + Math.sin(ang) * rad * 0.72;
      positions.push({ x: x, y: y, size: pt.size * radiusScale * 1.3, alpha: (0.4 + p * 0.6) * visible });
    });

計測値: 累積面積 11.0%、平均強度 102.3、動いたフレーム 20/23。

9つの近接パターンを、動いた面積と強度で比べます

各デモは仮想カーソルがボタンの周りを周回する様子をキャプチャとして記録し、連続するキャプチャ間の23区間の差分を480×300のキャンバス面積に対する割合として測っています。

# パターン 累積面積 平均強度 動いたフレーム
01 後光の半径が広がる 20.6% 52.6 22/23
02 拡大と浮き上がり 18.8% 47.4 18/23
03 傾きが角度を追う 13.2% 52.4 18/23
04 最近接だけ鮮明化 18.2% 19.6 16/23
05 近づくと色相が変わる 14.5% 46.7 20/23
06 枠線が描き進む 14.9% 46.5 21/23
07 影が奥行きで浮く 7.6% 62.1 21/23
08 文字間隔が広がる 26.5% 37.6 21/23
09 粒子密度が群れる 11.0% 102.3 20/23

3つのバリエーションと、値を動かすと何が変わるか

バリエーション 値 何が変わるべきか
後光半径の最大値を調整する 01 PROX_GLOW_RADIUS_MAX_PX 40 → 80 値を短くするとカーソルが最も近づいても後光は小さく控えめに残り、値を伸ばすと後光が大きく広がりボタン全体を覆うほどはっきり見えるはずです。
影の浮き上がり最大値を調整する 07 PROX_SHADOW_LIFT_MAX_PX 24 → 48 値を短くすると影はボタンの下に近く留まり低く浮いた印象になり、値を伸ばすと影が遠くまで広がりボタンがずっと高く浮き上がったように見えるはずです。
粒子の最大個数値を調整する 09 PROX_PARTICLE_MAX_COUNT 6 → 14 値を短くすると最も近づいても粒子はまばらに浮いて控えめに見え、値を伸ばすと粒子が密に集まりボタン周辺が賑やかに華やかに見えるはずです。

タッチ端末や非力な端末で壊れる落とし穴

この9つはどれも実在するユーザーのポインター位置を読み取っていません。動いて見えるカーソルは syntheticOffset が描く仮想の軌道で、誰かのマウス操作を記録したものではありません。タッチ端末では接触前に連続した pointermove が発生しないため、指は途中の近づく過程なしにいきなり p = 1 へ着地します。css ボタン アニメーション おしゃれ 系の実装をマウス前提だけで作ると、タッチ端末では間のフレームが抜けているように見えますが、実際には補間すべきホバー状態自体が存在しません。非力な端末でまず注意すべきは09番で、ctx.clearRect と最大6個分の三角関数計算を毎フレーム行う、この9つの中で唯一キャンバスを使うパターンだからです。実際のHTML・CSS・JSファイルをzipから取り出すには、このページの少し下にある zfbz3x5g が必要です。

アクセシビリティ — prefers-reduced-motionが実際に止めるもの

9つすべてが読み込み時に一度だけ window.matchMedia('(prefers-reduced-motion: reduce)') を確認します。設定が入っている場合、自動デモ用のカーソルを動かす requestAnimationFrame ループはそもそも起動しません。通常のブラウザで見える syntheticOffset の周回運動自体が動かなくなります。実際の操作は生きたままで、本物の pointermove・focus・blur がイベントハンドラの中で目標値を直接 render() に渡すため、ボタンは徐々に近づくのではなく最終状態へ一気に切り替わります。通常なら springStep を経る2番も、この設定下では同じく直接呼び出しに切り替わります。仕組みの詳細はMDNのprefers-reduced-motionを参照してください。

css ボタン ホバー エフェクト は css ボタン アニメーション と何が違うか?

そうした一般的な実装で検索すると、多くは :hover の境界で一度だけ発火するキーフレームやトランジションが返ってきます。ボタンは休止状態かホバー状態のどちらかで、その間を一定時間のトゥイーンがつなぐだけです。このページの9つはカーソルがまだ近づいている途中から連続した距離値を読み取るため、ボタンに触れる前から反応が半分終わっています。ここでの9つは境界で切り替わるのではなく、近さそのものに反応するという意味でのホバーエフェクトです。

導入前によくある質問を三つ片づけます

キーボードだけの操作でも近接ボタンは動きますか?

はい。すべての項目が pointermove・pointerleave と同じように focus・blur を監視していて、フォーカス時には目標値を1に、外れると0に設定します。タブキーで移動しても、マウスで近づいたときと同じ最終状態に到達します。

なぜ09番だけ平均強度がこれほど高いのですか?

09番は <canvas> を clearRect で消してから毎フレーム粒子を描き直しています。CSSのプロパティが近い2つの値の間をなめらかに動くのに比べ、ピクセルが変わるときの変化量自体が大きくなりやすいためです。

css ボタン マウス オーバー の実例はこの9つ以外にどこで見られますか?

同じボタンでも違う物理演算を扱った記事が2本あります。押し込みバネを扱うボタン 押し込み UI 9選 — 押し込みの値が戻る計算の9通りと、ポインターで傾く3Dボタンのcss ボタン 立体感 9種 — クリックまで再現です。

ほかの近接ボタン記事をもっと探すなら

上の2本に加えて、同じボタンの一族をキャンバスのピクセル計算で扱った記事がcss ボタン クリック アニメーション 9選 — キャンバス計算にあります。このページはクリックの瞬間を意図的に扱わず近接だけに絞っているので、クリック後の反応はそちらで補えます。

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

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