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

css ボタン 立体感 9種 — クリックまで再現

9種のcss ボタン 立体感デモをここに並べた。 カーソルがボタン上のどこにあるかを角度値ひとつに変えると、平らな面はどう立体に見えるのか? 座標値がrotateX・rotateYを決めるボタン、傾きの大きさ値が縁の光の強さを決めるボタン、押し込み深さ値がバネ減衰で戻るボタンのように、同じ計算ひとつがボタンごとに異なる

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

同じ座標のペアが、ボタンごとに別の仕事をする

この9つのボタンはすべて、ポインターの位置を中心からの比率として読み取るところから始まります。ポインターが動くたびに1回だけ値を読み、requestAnimationFrameのループの中でその値を目標値へイージングさせます。項目ごとに変わるのは、そのイージング済みの値の上でrender()がどんな計算を1つ重ねるかだけで、座標はボタンを回転させ、大きさは光の届く範囲を決め、押していた時間はバネで戻る動きを決めます。どれもcss ボタン ホバー エフェクトとしてよくある一方向のフェードではなく、9つとも同じ座標のペアをそれぞれ違う仕事に使っているだけです。

01カーソル座標値が回転させるボタン

カーソル座標のペアがrotateX・rotateYを直接動かし、LERP = 0.16でイージングをかけるため、傾きはポインターに一瞬遅れてついてきます。同じ座標は面を横切るハイライトの位置と、ドロップシャドウのぼかし・オフセットの深さにも使われるため、光る角と影が伸びる角は常に一致します。ポインターがない間は2軸の位相をずらしたリサージュ曲線が同じrender()を駆動するので、自動デモとヘッドレス計測は同じ計算を再現します。

カーソル座標値が回転させるボタン
新しいタブで開く

01 · カーソル座標値が回転させるボタン — 計測 累積面積 3.144% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

MAX_DEGLERProtateX
  var MAX_DEG = 14;          // カーソルがボタンの端に達したときの最大回転角
  var LERP = 0.16;           // 慣性lerp係数 — 値が目標に追いつく速さ
  var targetNX = 0, targetNY = 0;
  var curNX = 0, curNY = 0;
  var hasPointer = false;
  var clickTimer = null;

  function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }

  // ポインターがない時(自動デモ・ヘッドレス計測)に同じ計算を回すための合成座標 — 2軸の位相をずらしたリサージュ軌跡
  function syntheticTarget(t) {
    return [Math.sin(t * 1.15), Math.sin(t * 0.82 + 1.05)];
  }

  function render(nx, ny) {
    var rotY = nx * MAX_DEG;
    var rotX = -ny * MAX_DEG;
    btn.style.transform = 'rotateX(' + rotX.toFixed(2) + 'deg) rotateY(' + rotY.toFixed(2) + 'deg)';
    var sx = 50 + nx * 34, sy = 50 + ny * 34;
    sheen.style.background = 'radial-gradient(circle at ' + sx.toFixed(1) + '% ' + sy.toFixed(1) + '%, rgba(255,247,230,.5), rgba(255,247,230,0) 58%)';
    var depth = Math.min(1, Math.sqrt(nx * nx + ny * ny));
    btn.style.boxShadow = '0 ' + (10 + depth * 16).toFixed(1) + 'px ' + (18 + depth * 22).toFixed(1) + 'px rgba(8,6,12,' + (0.30 + depth * 0.16).toFixed(2) + ')';
  }

02光源角度値が通り過ぎるボタンのハイライト

この項目だけは座標ペアではなく角度値を読み、render(angle, mag)がその角度をSWEEP_WIDTH度幅のconic-gradientスイープに変換し、面のわずかな回転も加えます。shortestDelta()が円周の短い側でイージングするため、スイープが0度と360度の境目を長く回り込むことはありません。大きさ(mag)だけが床の影のずれ幅と背景の光の明るさを決め、方向と強さを別々の値として扱います。

光源角度値が通り過ぎるボタンのハイライト
新しいタブで開く

02 · 光源角度値が通り過ぎるボタンのハイライト — 計測 累積面積 12.364% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SWEEP_WIDTHLERPconic-gradient
  function render(angle, mag) {
    var rotY = Math.cos(angle * Math.PI / 180) * mag * 10;
    var rotX = Math.sin(angle * Math.PI / 180) * mag * -10;
    btn.style.transform = 'rotateX(' + rotX.toFixed(2) + 'deg) rotateY(' + rotY.toFixed(2) + 'deg)';
    var a0 = (angle - SWEEP_WIDTH / 2).toFixed(1);
    var a1 = angle.toFixed(1);
    var a2 = (angle + SWEEP_WIDTH / 2).toFixed(1);
    // コントラスト・幅を広げ、光沢の筋がボタン全面を横切るようにした — SWEEP_WIDTH(角度幅)の値自体は変えていない
    var alpha = (0.46 + mag * 0.54).toFixed(2);
    sweep.style.background = 'conic-gradient(from ' + a0 + 'deg at 50% 50%, rgba(23,20,26,0), rgba(23,20,26,' + alpha + ') ' + (SWEEP_WIDTH / 2).toFixed(1) + 'deg, rgba(23,20,26,0) ' + SWEEP_WIDTH.toFixed(1) + 'deg)';
    sweep.style.opacity = String(0.8 + mag * 0.4);
    // 傾きに連動した床の影 — 傾いた方向へ影がずれて面積を作る
    var floorShift = Math.cos(angle * Math.PI / 180) * mag * 34;
    var floorScale = 1 + mag * 0.28;
    floor.style.transform = 'translateX(' + floorShift.toFixed(1) + 'px) scaleX(' + floorScale.toFixed(2) + ')';
    floor.style.opacity = (0.08 + mag * 0.88).toFixed(2);
    // 背景の反射光レイヤー — %座標だけで動かすため、はみ出す心配がない(transform不使用)
    var gx = (50 + Math.cos(angle * Math.PI / 180) * mag * 38).toFixed(0);
    var gy = (50 + Math.sin(angle * Math.PI / 180) * mag * 38).toFixed(0);
    glow.style.background = 'radial-gradient(circle at ' + gx + '% ' + gy + '%, rgba(255,255,255,' + (0.18 + mag * 0.5).toFixed(2) + '), rgba(255,255,255,0) 60%)';
  }

03傾き値が積み重ねる影レイヤーのボタン

ボタンを回転させるのと同じnx・ny値が、ループでbox-shadowを3層作り、傾きと反対方向へ層ごとにオフセットを広げ、ぼかしと不透明度も層ごとに変えています。1つのループ変数からオフセット・ぼかし・不透明度をまとめて計算するため、3層は無関係な影ではなく1つの奥行きとして読めます。追加の要素は不要で、1フレームにつき1回、文字列を組み立ててboxShadowに設定するだけです。

傾き値が積み重ねる影レイヤーのボタン
新しいタブで開く

03 · 傾き値が積み重ねる影レイヤーのボタン — 計測 累積面積 6.126% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

MAX_DEGLERPboxShadow
  // 影を3層 — 傾いた反対方向へオフセットが大きくなり、層ごとにぼかし・不透明度が変わって奥行きが読める
  function render(nx, ny) {
    var rotY = nx * MAX_DEG;
    var rotX = -ny * MAX_DEG;
    btn.style.transform = 'rotateX(' + rotX.toFixed(2) + 'deg) rotateY(' + rotY.toFixed(2) + 'deg)';
    var ox = -nx, oy = -ny;
    var layers = [];
    for (var i = 1; i <= 3; i++) {
      var d = i * 7;
      var blur = i * 9;
      var alpha = (0.22 - i * 0.03).toFixed(2);
      layers.push((ox * d).toFixed(1) + 'px ' + (oy * d + i * 3).toFixed(1) + 'px ' + blur + 'px rgba(23,20,26,' + alpha + ')');
    }
    btn.style.boxShadow = layers.join(', ');
  }

04深さ値がアイコンだけを押し出すボタン

アイコンとラベルは同じ座標を受け取りながら、ICON_DEPTH = 22とLABEL_DEPTH = 8という異なる倍率で読むため、両方が毎フレーム同じnx・nyを受け取っていても、アイコンだけが文字から離れて見えます。動くのはtranslate3dだけで、ボタン自体はそれよりずっと小さい別の回転を受け取るため、視差はカードが回転しているのではなく層の奥行きとして読めます。ICON_DEPTHをLABEL_DEPTHと同じ値まで下げると、2つの層は再び1枚の平面に溶け合います。

深さ値がアイコンだけを押し出すボタン
新しいタブで開く

04 · 深さ値がアイコンだけを押し出すボタン — 計測 累積面積 2.401% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

ICON_DEPTHLABEL_DEPTHtranslate3d
  var LERP = 0.17;
  var ICON_DEPTH = 22;   // アイコン層の倍率 — 手前に浮いているためより大きく動く
  var LABEL_DEPTH = 8;   // 文字層の倍率 — 奥にあるため動きが小さい
  var targetNX = 0, targetNY = 0;
  var curNX = 0, curNY = 0;
  var hasPointer = false;

  function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
  function syntheticTarget(t) { return [Math.sin(t * 1.05), Math.sin(t * 0.7 + 2.1)]; }

  function render(nx, ny) {
    btn.style.transform = 'rotateX(' + (-ny * 8).toFixed(2) + 'deg) rotateY(' + (nx * 8).toFixed(2) + 'deg)';
    icon.style.transform = 'translate3d(' + (nx * ICON_DEPTH).toFixed(1) + 'px, ' + (ny * ICON_DEPTH).toFixed(1) + 'px, 0)';
    label.style.transform = 'translate3d(' + (nx * LABEL_DEPTH).toFixed(1) + 'px, ' + (ny * LABEL_DEPTH).toFixed(1) + 'px, 0)';
  }

05角度値が縁を光らせるボタン

傾きベクトルの大きさではなく角度が、縁を回るconic-gradientのベベルを狙うため、明るい帯は常にボタンが傾いた側に出ます。別の側面要素が傾きの大きさに合わせて高さ方向にスケールし、実際には存在しない2枚目の3D面の代わりに、新しく見えるようになった縁の厚みを表現します。床の影はx成分だけを読むため、四方に広がるのではなく横にずれていきます。

角度値が縁を光らせるボタン
新しいタブで開く

05 · 角度値が縁を光らせるボタン — 計測 累積面積 13.927% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

MAX_DEGconic-gradientmask-composite
  // 傾きベクトルの角度・大きさ → conic-gradientの回転角・光の幅。縁の光は傾いた側に出る
  // 幅・コントラストを広げ、縁の帯がボタンの辺全体を横切るようにした — MAX_DEG(傾き角)の値自体は変えていない
  function render(nx, ny) {
    var rotY = nx * MAX_DEG;
    var rotX = -ny * MAX_DEG;
    btn.style.transform = 'rotateX(' + rotX.toFixed(2) + 'deg) rotateY(' + rotY.toFixed(2) + 'deg)';
    var mag = clamp(Math.sqrt(nx * nx + ny * ny), 0, 1);
    var ang = Math.atan2(ny, nx) * 180 / Math.PI + 90;
    var width = 70 + mag * 110;
    var alpha = (0.38 + mag * 0.6).toFixed(2);
    bevel.style.background = 'conic-gradient(from ' + ang.toFixed(1) + 'deg, rgba(91,140,248,0), rgba(91,140,248,' + alpha + ') ' + (width / 2).toFixed(1) + 'deg, rgba(91,140,248,0) ' + width.toFixed(1) + 'deg)';
    // 床の影 — 傾いた方向へ移動する
    var floorShift = nx * 46;
    floor.style.transform = 'translateX(' + floorShift.toFixed(1) + 'px) scale(' + (1 + mag * 0.4).toFixed(2) + ')';
    floor.style.opacity = (0.14 + mag * 0.7).toFixed(2);
    // 側面の厚み — 傾きの大きさぶんだけボタン下の「側面」が伸び、3Dの厚みとして読める(MAX_DEG不変、純粋な表現の一層)
    side.style.transform = 'scaleY(' + (0.4 + mag * 2.6).toFixed(2) + ')';
    side.style.opacity = (0.25 + mag * 0.7).toFixed(2);
  }

06押し込み深さ値がバネで戻るボタン

ポインターを押していた時間がそのまま押し込み深さになり、PRESS_DEPTH_CAP = 480ミリ秒を超えると深さは頭打ちになります。離すとその深さがSTIFFNESSとDAMP_BASEの組み合わせでバネとして戻り、DAMP_DEPTH_RANGEの分だけ押し込みが深いほど減衰が広がるため、短いタップは早く収まり、長押しはより大きくオーバーシュートしてから落ち着きます。この項目だけMAX_DEG = 6と傾きをわざと小さく抑え、z方向のバネの動きを主役にしています。

押し込み深さ値がバネで戻るボタン
新しいタブで開く

06 · 押し込み深さ値がバネで戻るボタン — 計測 累積面積 3.7% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

PRESS_DEPTH_CAPDAMP_BASESTIFFNESS
  var PERIOD = 2.4;              // 合成デモ1周期分(秒)
  var STIFFNESS = 0.28;
  var DAMP_BASE = 0.60;          // 減衰の下限 — 浅く押すと早く収まる
  var DAMP_DEPTH_RANGE = 0.32;   // 押し込み深さが増すほど減衰が広がり、より大きく跳ねる
  var PRESS_DEPTH_CAP = 480;     // この時間(ms)以上押し続けると深さが1になる
  var MAX_DEG = 6;
  var LERP = 0.16;

  var targetNX = 0, targetNY = 0, curNX = 0, curNY = 0;
  var hasPointer = false;
  var pressedReal = false;
  var wasHeld = false;
  var pressStartNow = 0;
  var damping = DAMP_BASE;
  var aimZ = 0, curZ = 0, velZ = 0;

  function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
  function syntheticTarget(t) { return [Math.sin(t * 0.7 + 1.1), Math.sin(t * 0.5)]; }

07傾き値ごとにずれる格子模様のボタン

同じ座標ペアにFAR_DEPTH = 10とNEAR_DEPTH = 26という異なる倍率をかけ、手前側の符号を反転させることで、背景の格子模様が反対方向へ引き裂かれるように動きます。backgroundPositionの2つの値がどちらも同じrender()から計算されるため、手前と奥の格子、そしてボタン自身の回転がずれて食い違うことはありません。2つの深さを同じ値にすると視差は消え、1枚の平らな格子だけが残ります。

傾き値ごとにずれる格子模様のボタン
新しいタブで開く

07 · 傾き値ごとにずれる格子模様のボタン — 計測 累積面積 4.097% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

FAR_DEPTHNEAR_DEPTHbackgroundPosition
  var MAX_DEG = 9;
  var LERP = 0.16;
  var FAR_DEPTH = 10;
  var NEAR_DEPTH = 26;
  var targetNX = 0, targetNY = 0, curNX = 0, curNY = 0;
  var hasPointer = false;

  function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
  function syntheticTarget(t) { return [Math.sin(t * 0.9), Math.sin(t * 1.4 + 1.6)]; }

  function render(nx, ny) {
    btn.style.transform = 'rotateX(' + (-ny * MAX_DEG).toFixed(2) + 'deg) rotateY(' + (nx * MAX_DEG).toFixed(2) + 'deg)';
    gridFar.style.backgroundPosition = (nx * FAR_DEPTH).toFixed(1) + 'px ' + (ny * FAR_DEPTH).toFixed(1) + 'px';
    gridNear.style.backgroundPosition = (-nx * NEAR_DEPTH).toFixed(1) + 'px ' + (-ny * NEAR_DEPTH).toFixed(1) + 'px';
  }

08傾きの大きさ値が強さを決める縁の光

この項目は方向をあえて捨て、傾きの大きさだけをBLUR_BASE = 6からBLUR_BASE + BLUR_RANGEまでのbox-shadowのぼかし幅に変換し、広がりと不透明度も合わせて動かします。方向が光に届かないため、同じ大きさなら左に傾けても右に傾けても縁の光は同じになり、変わるのはポインターが中心からどれだけ離れているかだけです。方向を読む05の縁の光に対し、こちらはわざと大きさだけを読みます。

傾きの大きさ値が強さを決める縁の光
新しいタブで開く

08 · 傾きの大きさ値が強さを決める縁の光 — 計測 累積面積 10.337% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

BLUR_BASEBLUR_RANGEmag
  var MAX_DEG = 10;
  var LERP = 0.16;
  var BLUR_BASE = 6;
  var BLUR_RANGE = 34;
  var targetNX = 0, targetNY = 0, curNX = 0, curNY = 0;
  var hasPointer = false;

  function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
  function syntheticTarget(t) { return [Math.sin(t * 1.1 + 0.3), Math.sin(t * 1.6)]; }

  // 方向は回転にだけ使い、縁の光は大きさ(magnitude)だけを読む — 05の方向性のある光とは違う値
  function render(nx, ny) {
    btn.style.transform = 'rotateX(' + (-ny * MAX_DEG).toFixed(2) + 'deg) rotateY(' + (nx * MAX_DEG).toFixed(2) + 'deg)';
    var mag = clamp(Math.sqrt(nx * nx + ny * ny), 0, 1);
    var blur = BLUR_BASE + mag * BLUR_RANGE;
    var spread = 1 + mag * 5;
    var alpha = (0.28 + mag * 0.5).toFixed(2);
    btn.style.boxShadow = '0 0 ' + blur.toFixed(1) + 'px ' + spread.toFixed(1) + 'px rgba(91,140,248,' + alpha + '), 0 12px 20px rgba(23,20,26,.28)';
  }

09ポインターがない時に値が自ら揺れるボタン

ポインターが近くにない間はidleDrift()が軸ごとに周波数と位相の異なる2つの正弦波を足し合わせた、固定シードの経路を作り出し、Math.randomを一切使わずにボタンをゆっくり漂わせ続けます。同じrender()が漂いにも使われるため、translateYで持ち上げ、ぼかしを広げ、傾いた反対側へ背景の光をずらす処理は、待機時もポインター操作時も1つの連続した動きとして読めます。ポインターが入ると、idleDrift()の寄与は即座に止まり、イベントの座標が同じ2つの変数を引き継ぎます。

ポインターがない時に値が自ら揺れるボタン
新しいタブで開く

09 · ポインターがない時に値が自ら揺れるボタン — 計測 累積面積 6.923% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

MAX_DEGLERPidleDrift
  function render(nx, ny) {
    var rotY = nx * MAX_DEG;
    var rotX = -ny * MAX_DEG;
    var mag = Math.min(1, Math.sqrt(nx * nx + ny * ny));
    var lift = 1 - mag * 0.4;
    btn.style.transform = 'translateY(' + ((1 - lift) * -6).toFixed(2) + 'px) rotateX(' + rotX.toFixed(2) + 'deg) rotateY(' + rotY.toFixed(2) + 'deg)';
    var blur = 16 + Math.abs(nx) * 10 + Math.abs(ny) * 10;
    btn.style.boxShadow = '0 ' + (10 * lift).toFixed(1) + 'px ' + blur.toFixed(1) + 'px rgba(8,6,12,.34)';
    // 床の影 — 傾いた反対側へ離れていき、面積を作る
    floor.style.transform = 'translate(' + (nx * 38).toFixed(1) + 'px, ' + (ny * 16).toFixed(1) + 'px) scale(' + (1 + mag * 0.32).toFixed(2) + ')';
    floor.style.opacity = (0.16 + mag * 0.55).toFixed(2);
    // ハイライト — 反対側(持ち上がった側)で明るくなり、影と対になって動く
    var gx = (50 - nx * 32).toFixed(0);
    var gy = (50 - ny * 32).toFixed(0);
    glow.style.background = 'radial-gradient(circle at ' + gx + '% ' + gy + '%, rgba(255,255,255,' + (0.16 + mag * 0.42).toFixed(2) + '), rgba(255,255,255,0) 62%)';
  }

実際にどこへ付けるか — デモで終わらせない

02のハイライトが通り過ぎるパターンは、プレミアム決済ボタンやプロモバナーのボタンでこそ価値が出ます。ページ全体を作り直さずに「特別感」を伝えられ、ポインターがすでにボタンの上にある以上、追加のコストはほぼかかりません。

05の縁の光はダークモードのUIに向いています。暗い背景ではソフトなドロップシャドウはほとんど見えなくなりますが、縁だけが光る表現ははっきり読めるため、塗りではなく輪郭で「浮いている」ことを伝えるガラスUIのボタンにも同じ理由で合います。

06の押し込み深さのバネは、画面に1つだけある「実際に確定する」ボタン向けです。フォーム送信ボタンや確認ボタンに使えば、長押し後に跳ねて戻る動きが、クリックされたことだけでなく、どれくらい押していたかにアプリが気づいていることまで伝えられます。

ここで判断を下しているのは3種類の値

9つをrender()が実際に読む値の種類でまとめると、パターンの裏にあるパターンが見えてきます。

座標ペアが方向を決める — 01・03・04・05・07・09

9つのうち6つはnxとnyを別々に読むため、どの角が持ち上がるかはポインターがどれだけ離れているかだけでなく、どの方向にあるかにも左右されます。

大きさ一つが強さを決める — 02・08

9つのうち2つは視覚効果に届く前にペアを1つの数値へ畳み込むため、そこから先は方向そのものが意味を持たなくなります。

押していた時間が跳ね返り方を決める — 06

9つのうち1つだけは、看板となる効果のために座標をまったく読みません。読んでいるのはポインターがどれだけ長く押されていたかです。

css ボタン デザイン コピペ で探しているものはここにあるか?

結論から言うと、この記事自体がその答えを兼ねています。9つのパターンはすべてコピー&ペーストでそのまま使え、下のzipには表の3つの調整版も加えて入っているため、別のツールを入れたりアカウントを作ったりする手間はありません。

css ボタン デザイン おしゃれ という方向で探しているなら、05と08の2つが並べて見る価値があります。片方は角度だけで縁を光らせ、もう片方は大きさだけで縁を光らせるため、同じ「縁の光」でも読んでいる値がまったく違うことが一目でわかります。

3つの数値を動かすと感触がどう変わるか

項目 値 動かすと何が起きるか
01 カーソル回転 MAX_DEG 14 → 20 カーソルが少し動くだけでも回転が大きく振れ、角の持ち上がりが強く見える
06 押し込みバネ DAMP_BASE 0.60 → 0.40 減衰が弱まり、指を離した後のオーバーシュートがより長く残る
08 縁の光 BLUR_RANGE 34 → 50 傾きが最大のときの光がより広く柔らかく広がる

9つ全体の測定値

9つをまとめて測っても、累積面積は2〜14%台にとどまります。ボタンという性質上、実際に画面で動くのはボタン本体と影・ハイライトだけで、背景全体が変わる場面型デモより面積が小さく出るのは自然です — コンポーネント編で問われるのは面積ではなく、値ひとつがどれだけの仕事を兼ねているかです。

項目 累積面積 強度平均 動いたフレーム
01 カーソル回転 3.1% 34.0 23/23
02 ハイライトスイープ 12.4% 22.3 23/23
03 影レイヤー 6.1% 30.2 22/23
04 アイコン視差 2.4% 63.2 23/23
05 縁のベベル 13.9% 20.7 23/23
06 押し込みバネ 3.7% 39.1 23/23
07 格子視差 4.1% 22.4 23/23
08 縁の光 10.3% 25.8 23/23
09 アイドルドリフト 6.9% 21.7 23/23

この9つが壊れる場面

同じボタンに2つのパターンを重ねると、それぞれのrender()がbtn.style.transformの文字列をまるごと上書きするため、どちらか一方の変形が消えてしまいます。ホバーのないタッチ端末ではタッチが触れる前にpointermoveが発火しないため、傾きはclickとkeydownの分岐でしか更新されず、これらのデモが前提とする連続した追従はそもそも起きません。離れた直後にポインターが戻ってくると、非常に遅いフレームではイージングの途中で捕まり、なめらかな軌跡の代わりに一度だけ目に見える段差が出ることもあります。

ここまでの9つのindex.htmlと、表にある3つの調整版の値は、すべて下の1つのzipにまとめてあります。qvyanve8で開く一手間を挟むのは、うっかりダウンロードするのではなく、意図して開く行為にしておくためです。

reduced-motion がここで何を止めるか

9つとも、ループが始まる前に一度だけprefers-reduced-motionを確認します。設定が有効なときはボタンを固定の傾きで1回だけ描画します — 完全に平らな0ではなく、そうすると設定ではなく不具合に見えてしまうためです — そして、待機時の合成経路と実際のポインター追従の両方を駆動するrequestAnimationFrameのループ自体が始まりません。ただしクリックと矢印キーは、目標値を更新した直後にrender()をもう一度呼び出すため、補間なしでその場で一度だけ描画されます。

よくある質問 — 開く前に

css ボタン デザイン コピペ をそのまま使えるボタンは、9つのうちどれか?

9つとも本文中のコードだけで動くよう作ってあるため、どれもそのままコピペで使えます。ただ最初に読むなら01と06がおすすめです。片方は座標が回転になる基本の計算、もう片方はそれをバネで戻す計算を示しているため、残り7つの理解が早くなります。

マウスがないと、これらは動かないか?

いいえ。hasPointerがfalseの間はどのパターンも固定シードの合成ポインター経路にフォールバックするため、誰も画面に触れていなくても傾きは決まった軌跡で動き続けます。デモの並びとヘッドレス計測のスクリーンショットも、この合成経路を見て判定しています。

なぜ一部の項目だけ、動いたフレーム数が23ではなく22になっているか?

この計測は24枚のキャプチャからできる23個の差分のうち、見た目のピクセルが実際に判定できる量だけ変化したフレームを数えています。イージングが最後の1〜2フレームで目標値にほぼ収まってしまう項目は、動いたと判定されるフレームが23分の22になることがあり、それ自体は不具合ではありません。9つのうち1つ(影レイヤーを積む03、3層が同じ目標値へ一緒にイージングする項目)が、まさにこの理由で他より少し早く落ち着きます。

関連記事: 傾きではなく別の形の動きを探しているなら、一行でコピペOK ― CSS カード ホバー アニメーション9選はポインター追従ではなく平面的なホバー表現をまとめており、【一行でコピペOK】CSS 3D アニメーション9選は同じ奥行きの計算をボタンではなく回転するキューブに使っていて、マウス カーソル エフェクト 9選 — コピペOKはポインター追従の対象をボタンからカーソルそのものへ移しています。

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

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