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

css ボタン 色 変更 9パターン — ホバー途中の色が濁らないよう計算した場面

ボタンの色をホバーで変えるのは一行で済むが、その一行は途中の色を濁らせ、グラデーションを動かせず、文字を途中で読めなくする — 02番・04番・07番がそれを一つずつ塞ぐ。css ボタン 色 変更は、ホバーや押下に合わせて背景色やグラデーションを一つの色から別の色へ移す作業です。

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

css ボタン 色 変更 9パターン — 色は計算して動かすと途中が澄む

九つは、色の着き方が素直なものから順に並べました。01 は残りの距離の一定割合ずつ目標へ寄る基本のホバーで、02 は同じ二色を素のまま混ぜたボタンと明るさを補正して混ぜたボタンの並べ比べです。03 はグラデーションの境目がバネで押される場面、04 は二枚を重ねて替えるクロスフェード、05 は角度がポインターの方へ向く場面になります。06 は押している間に色相が加速するボタン、07 は地色が明るくなると文字色が反転するボタン、08 は斜めの光の帯が一度なでる光沢、09 は地色に合わせて影の色がにじむボタンです。

こんな画面で使います

購入ボタンのように目立たせたい場所では、03 と 08 のグラデーションが働きます。色を選ぶ道具の画面なら押して回す 06 が、地色が何通りにも変わるテーマ切り替えのボタンなら文字が反転する 07 が先に必要になります。css ボタン 色 グラデーション の動きを入れる場合も、色の組を決めたあとの計算は二つの値の間の進み具合一つに整理できます。

隣の記事とどこが違うか

css ボタン ホバー エフェクト 9選 — 距離の値で変わるは、ポインターの近さでボタンの反応が分かれる記事です。ボタン 押し込み UI 9選 — 押し込みの値が戻る計算の9通りは、押されて戻る動きを扱っています。この記事で動かすのは形ではなく色なので、途中の色をどう計算するかの一点に九つの場面を掛けました。

技法別コードで見る色の動かし方

九つの場面はどれも標準の button 要素で作り(02 と 04 は左右を比べるため二つ置きます)、アニメーションは CSS の transition や keyframes を使わず、requestAnimationFrame の中の JS が値を計算して塗ります。01・02・07・09 は色を CSS 変数 --c-from・--c-to に置いて getComputedStyle で読み、下には各計算の中心の区間だけをソースから連続した形で抜き出しました。入力がないあいだの自動デモは最初の操作で止まり、同じ計算が入力を受け取ります。ライブラリや CDN は使っておらず、マークアップとスタイルの全体は下の圧縮ファイルに入っています。

01色を目標へ引き寄せる基本のホバー

毎フレーム、残りの距離に 1 - exp(-dt * PULL_RATE) を掛けて三つのチャンネルを目標へ寄せ、残りの合計が SETTLE_GAP を下回ったら目標に止めます。時間の曲線を持たないので、ホバーを途中で外してもその時点の色から折り返せます。計測では面積16.2%、強度平均114.6、動いたフレームは22/23でした。

色を目標へ引き寄せる基本のホバー
新しいタブで開く

01 · 色を目標へ引き寄せる基本のホバー — 計測 累積面積 16.203% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

PULL_RATESETTLE_GAPCYCLE_MS
  function tick(ts) {
    if (t0 === null) t0 = ts;
    var now = ts - t0;
    var dt = lastTs === null ? 0.016 : Math.min(0.05, (ts - lastTs) / 1000);
    lastTs = ts;
    var goal = target(now);
    var k = 1 - Math.exp(-dt * PULL_RATE);
    for (var i = 0; i < 3; i += 1) cur[i] += (goal[i] - cur[i]) * k;
    if (gapOf(goal) < SETTLE_GAP) cur = goal.slice();
    paint(goal);
    requestAnimationFrame(tick);
  }

02明るさ補正の混合と素の混合を並べる

左のボタンは sRGB の値をそのまま補間し、右のボタンは toLinear で明るさを線形に戻して混ぜてから toGamma で戻します。進み具合50%で素の混合色 #a6b9b1 の相対輝度は約0.459で、両端のピンク0.493・ミント0.514を下回り、補正した #c1bcb1 は約0.505で二つの間に収まります。計測では面積18.1%、強度平均48.3、動いたフレームは21/23でした。

明るさ補正の混合と素の混合を並べる
新しいタブで開く

02 · 明るさ補正の混合と素の混合を並べる — 計測 累積面積 18.131% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

MIX_RATEtoLineartoGammamixLinear
  function toLinear(c) {
    var s = c / 255;
    return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
  }
  function toGamma(l) {
    var s = l <= 0.0031308 ? l * 12.92 : 1.055 * Math.pow(l, 1 / 2.4) - 0.055;
    return Math.max(0, Math.min(255, s * 255));
  }
  function mixRaw(a, b, t) { return a.map(function (v, i) { return v + (b[i] - v) * t; }); }
  function mixLinear(a, b, t) {
    return a.map(function (v, i) {
      var la = toLinear(v);
      return toGamma(la + (toLinear(b[i]) - la) * t);
    });
  }

03色の境目がバネで押されるグラデーション

グラデーションの停止位置 --stop を STIFFNESS と DAMPING で決めたバネに入れ、1/120秒の固定刻みで積分します。20%から目標の70%へ向かうときに11.1だけ行き過ぎて戻り、離すと20%より下へ逆向きに行き過ぎてから落ち着きます。計測では面積13.7%、強度平均92.8、動いたフレームは20/23でした。

色の境目がバネで押されるグラデーション
新しいタブで開く

03 · 色の境目がバネで押されるグラデーション — 計測 累積面積 13.651% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

STIFFNESSDAMPINGSTOP_RESTSTOP_HOVER
  function tick(ts) {
    if (t0 === null) t0 = ts;
    var now = ts - t0;
    var dt = lastTs === null ? 0.016 : Math.min(0.05, (ts - lastTs) / 1000);
    lastTs = ts;
    var g = goal(now);
    acc += dt;
    while (acc >= STEP) {
      vel += (STIFFNESS * (g - pos) - DAMPING * vel) * STEP;
      pos += vel * STEP;
      acc -= STEP;
    }
    paint();
    requestAnimationFrame(tick);
  }

04二枚を重ねてグラデーションを替えるクロスフェード

左はクラスを付け替えて、進み具合0.5でグラデーションがまるごと切り替わるカットです。右は下の層の上に別のグラデーションの層を重ね、その層の opacity だけを FADE_RATE の割合で動かして二つの色の組み合わせを混ぜます。この場面では不透明度の値も JS が計算しているので、重ねて混ぜる仕組みを見せるものです。計測では面積17.3%、強度平均97.3、動いたフレームは22/23でした。

二枚を重ねてグラデーションを替えるクロスフェード
新しいタブで開く

04 · 二枚を重ねてグラデーションを替えるクロスフェード — 計測 累積面積 17.253% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

FADE_RATESTART_MSON_MS
  function tick(ts) {
    if (t0 === null) t0 = ts;
    var now = ts - t0;
    var dt = lastTs === null ? 0.016 : Math.min(0.05, (ts - lastTs) / 1000);
    lastTs = ts;
    var g = goal(now);
    amount += (g - amount) * (1 - Math.exp(-dt * FADE_RATE));
    if (Math.abs(g - amount) < 0.004) amount = g;
    paint();
    requestAnimationFrame(tick);
  }

05角度がポインターの方へゆっくり向くグラデーション

ボタンの中心からポインターへの向きを atan2(dx, -dy) で角度にし、グラデーションの --ang が FOLLOW_RATE の割合で追いかけて、ポインターが外れると REST_ANGLE の135度へ戻ります。現在の角度との差を ((g - a + 540) % 360) - 180 で折りたたむので、350度から10度へ動くときも逆に一周しません。計測では面積14.7%、強度平均132.5、動いたフレームは23/23でした。

角度がポインターの方へゆっくり向くグラデーション
新しいタブで開く

05 · 角度がポインターの方へゆっくり向くグラデーション — 計測 累積面積 14.651% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

FOLLOW_RATEREST_ANGLEORBIT_SPANORBIT_MS
  function demoGoal(now) {
    if (now < START_MS) return REST_ANGLE;
    var c = (now - START_MS) % CYCLE_MS;
    if (c >= ORBIT_MS) return REST_ANGLE;
    return wrap(60 + (c / ORBIT_MS) * ORBIT_SPAN);
  }

  function tick(ts) {
    if (t0 === null) t0 = ts;
    var now = ts - t0;
    var dt = lastTs === null ? 0.016 : Math.min(0.05, (ts - lastTs) / 1000);
    lastTs = ts;
    var g = root.classList.contains('is-demo') ? demoGoal(now) : goalAngle;
    var diff = ((g - angle + 540) % 360) - 180;
    angle = wrap(angle + diff * (1 - Math.exp(-dt * FOLLOW_RATE)));
    paint();
    requestAnimationFrame(tick);
  }

06押している間に色相が加速して回るボタン

押している間は回転の速さが ACCEL の分だけ MAX_SPEED まで上がり、離すと exp(-FRICTION * dt) の割合で減って、止まった位置の色相がそのまま残ります。ポインターは setPointerCapture で、キーボードは Space・Enter の繰り返しでない keydown から keyup までを押下として扱い、同じ経路を通ります。計測では面積16.0%、強度平均77.4、動いたフレームは23/23でした。

押している間に色相が加速して回るボタン
新しいタブで開く

06 · 押している間に色相が加速して回るボタン — 計測 累積面積 15.999% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

MAX_SPEEDHUE_STARTHUE_STEPHOLD_MS
  function tick(ts) {
    if (t0 === null) t0 = ts;
    var now = ts - t0;
    var dt = lastTs === null ? 0.016 : Math.min(0.05, (ts - lastTs) / 1000);
    lastTs = ts;
    if (demoPressed(now) || pressed) {
      speed = Math.min(MAX_SPEED, speed + ACCEL * dt);
    } else {
      speed *= Math.exp(-FRICTION * dt);
      if (speed < 0.5) speed = 0;
    }
    hue = (hue + speed * dt) % 360;
    paint();
    requestAnimationFrame(tick);
  }

07地色が明るくなると文字色が反転するボタン

地色が動くフレームごとに WCAG の相対輝度を計算し、FLIP_THRESHOLD を超えたら文字を黒に、超えなければ白にします。白と黒が同じコントラストになる輝度は √(1.05×0.05)−0.05 = 0.179 で、そのときのコントラスト比は4.58:1、ピンクからミントへの経路全体の最低は4.61:1です。計測では面積16.1%、強度平均82.1、動いたフレームは23/23でした。

地色が明るくなると文字色が反転するボタン
新しいタブで開く

07 · 地色が明るくなると文字色が反転するボタン — 計測 累積面積 16.139% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

FLIP_THRESHOLDFILL_RATEluminance
  function channel(c) {
    var s = c / 255;
    return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
  }
  function luminance(rgb) {
    return 0.2126 * channel(rgb[0]) + 0.7152 * channel(rgb[1]) + 0.0722 * channel(rgb[2]);
  }
  function ratio(a, b) {
    var hi = Math.max(a, b);
    var lo = Math.min(a, b);
    return (hi + 0.05) / (lo + 0.05);
  }
  function paint() {
    var l = luminance(cur);
    var dark = l > FLIP_THRESHOLD;
    btn.style.background = toHex(cur);
    btn.style.color = toHex(dark ? textDark : textLight);
    lumEl.textContent = l.toFixed(2);
    ratioEl.textContent = ratio(l, dark ? lumDark : lumLight).toFixed(1);
  }

08斜めの光の帯が色の上をなでる光沢

ホバーが入る瞬間ごとに、105度に傾けた明るい帯が BAND_FROM から BAND_TO まで700msで一度だけ通り、強さは PEAK_GLOW * sin(π p) で上がって下がります。位置は smoothstep なので両端でゆっくりになり、通り過ぎると強さが0に戻って元の色だけが残ります。計測では面積13.6%、強度平均72.3、動いたフレームは12/23でした。

斜めの光の帯が色の上をなでる光沢
新しいタブで開く

08 · 斜めの光の帯が色の上をなでる光沢 — 計測 累積面積 13.553% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SWEEP_MSBAND_FROMBAND_TOPEAK_GLOW
  function tick(ts) {
    if (t0 === null) t0 = ts;
    var now = ts - t0;
    var on = demoOn(now) || pointerIn || kbFocus;
    if (on && !wasOn) sweepAt = now;
    wasOn = on;
    var p = sweepAt < 0 ? 1 : Math.min(1, (now - sweepAt) / SWEEP_MS);
    var eased = p * p * (3 - 2 * p);
    if (p >= 1) paint(BAND_FROM, 0);
    else paint(BAND_FROM + (BAND_TO - BAND_FROM) * eased, PEAK_GLOW * Math.sin(Math.PI * p));
    requestAnimationFrame(tick);
  }

09地色に合わせて影の色がにじむボタン

進み具合 p 一つで地色・影の色・影の高さ・ぼかしの半径をまとめて決め、影の色は地色の各チャンネルに SHADOW_DARKEN を掛けた値です。高さは4pxから14px、ぼかしは10pxから28pxへ p に比例して増え、色が目標へ近づくほどボタンが床から浮きます。計測では面積23.9%、強度平均90.1、動いたフレームは21/23でした。

地色に合わせて影の色がにじむボタン
新しいタブで開く

09 · 地色に合わせて影の色がにじむボタン — 計測 累積面積 23.888% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SHADOW_DARKENSHADOW_ALPHALIFT_HOVERBLUR_HOVERGLOW_RATE
  function paint() {
    var fill = from.map(function (v, i) { return v + (to[i] - v) * p; });
    var shade = fill.map(function (v) { return v * SHADOW_DARKEN; });
    var lift = LIFT_REST + (LIFT_HOVER - LIFT_REST) * p;
    var blur = BLUR_REST + (BLUR_HOVER - BLUR_REST) * p;
    btn.style.background = toHex(fill);
    btn.style.boxShadow = '0 ' + lift.toFixed(1) + 'px ' + blur.toFixed(1) + 'px rgba(' +
      Math.round(shade[0]) + ', ' + Math.round(shade[1]) + ', ' + Math.round(shade[2]) + ', ' + SHADOW_ALPHA + ')';
    hexEl.textContent = toHex(shade);
    yEl.textContent = String(Math.round(lift));
  }

値が場面を変えるところ — 引き寄せの割合・バネの強さ・明るさの基準

css ボタン グラデーション ホバー で色が着く速さと揺れ、文字が読める区間は、ほぼ三つの定数で決まります。表の数値は九項目をそれぞれ描画し、23個のフレーム間隔ごとに測った値です。

項目 累積面積 % 強度平均 動いたフレーム
01 基本の引き寄せ 16.2% 114.6 22/23
02 明るさ補正の混合 18.1% 48.3 21/23
03 バネの境目 13.7% 92.8 20/23
04 層のクロスフェード 17.3% 97.3 22/23
05 ポインターの角度 14.7% 132.5 23/23
06 加速する色相 16.0% 77.4 23/23
07 文字の反転 16.1% 82.1 23/23
08 光の帯の光沢 13.6% 72.3 12/23
09 影のにじみ 23.9% 90.1 21/23

累積面積が10%を下回る項目はありません。08 の12/23が低いのは、ホバー一回で帯が700msのあいだ一度だけ通り、その後は止まるからです。02 の強度が最も低いのも、二つのボタンの色の差が小さい組だからで、異常ではありません。

引き寄せの割合が着く速さを決める

01 の PULL_RATE 7 は時定数が約143msで、色は半分ほどまで早く着き、終わりが長く尾を引きます。値を上げるほど着くのは速くなりますが、色の変化が目で追える区間は短くなります。

バネの強さが行き過ぎを決める

03 は STIFFNESS 170 で目標の70%を11.1超え、最高81.1まで行ってから約0.76秒で落ち着きます。DAMPING 11 のまま強さを400に上げると行き過ぎは19.9に増え、50に下げると0.8しか超えません。

明るさの基準が読める区間を決める

07 の FLIP_THRESHOLD は、白と黒が同じコントラストになる0.179が基準です。この値から離れるほど、文字が反転する前にどちらかの色の上でコントラストが急に落ちます。

どこで壊れるか — 速いホバーの繰り返し・濁った中間色・読めない文字

速いホバーの繰り返しで行き過ぎが重なる

01 と 09 は状態がいつも現在の値から続くので、出し入れを繰り返しても途切れません。03 では速度 vel が残ったまま再び入ると行き過ぎが重なり、最初より大きく跳ねます。

素の混合色は両端より暗くなる

02 の左のボタンは、途中で明るさが両端の下へ沈みます。ホバーの途中で色がくすんで見えるのは、この明るさの沈みと重なる現象です。彩度は途中でも約0.12と0.11でほぼ同じなので、補正の効果は鮮やかさではなく、途中で明るさが沈まないことにあります。

トークンのインク色の文字は中間で4.5に届かない

07 は最初にサイトのトークンのインク色を文字に使ったところ、クロスオーバー付近のコントラストが4.15〜4.27:1で基準の4.5に届かず、純白と黒に替えました。基準を0.08に下げると最低2.64:1、0.30に上げると3.01:1まで落ちて、読みにくい区間が生まれます。

角度の境目を折りたたまないと逆に回る

05 で折りたたみの式を抜くと、350度から10度へ向かう道で20度ではなく340度を逆回りして、グラデーションが一周します。角度を使う補間には、この折りたたみを必ず一緒に置いてください。

コード全体と SCSS を自分で動かすなら、圧縮ファイルを落としてパスワード 82z8gdmt で開きます。九つのフォルダそれぞれにデモのページとスタイルが入っています。

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

三つの定数を動かした結果

項目 定数 値 感じ
01 PULL_RATE 7 → 14 時定数が約143msから71msに縮み、色がほぼ即座に着きます。下げると終わりが長く尾を引きます
03 STIFFNESS 170 → 400 境目の行き過ぎが11.1から19.9に増え、50にすると0.8しか超えず、ほとんど揺れずに止まります
07 FLIP_THRESHOLD 0.179 → 0.08 文字が早く黒になり、最低コントラストは2.64:1まで落ちます。0.30なら3.01:1まで下がります

キーボードと reduce のときの動き

どの場面も標準の button 要素なので Tab で届き、01・02・03・04・07・08・09 は :focus-visible のときにホバーと同じ目標へ動き、06 は Space・Enter で押します。05 はポインターの向きそのものが情報なのでキーボードの目標を持たず、文字と3pxのフォーカスリングだけを保ちます。prefers-reduced-motion が reduce なら、ループを回さず入力が来た瞬間に目標の値をそのまま塗ります。そのため 01・02・03・04・07・09 は最終の色へ跳び、05 はポインター方向の角度に即座に変わり、06 は押すたびに60度ずつ跳び、08 はホバー中に止まった帯だけが残ります。色の変化は飾りなので aria-live は使っておらず、読む必要のある情報である文字のコントラストは、07 が linear-gradient の上ではなく計算の側で守っています。

ボタンの色変更は transition 一行で足りるのか?

この記事の九つの場面は CSS の transition を使わず、JS で計算した数値で色を動かしています。途中の明るさ、グラデーションの境目、文字のコントラストがそのまま見えるのが 02・04・07 で、色の組を一つ替えるだけの画面なら、まずその三つを見て計算が必要か判断してください。

色以外の反応が欲しいときは、隣の記事へ進んでください。押したあとの状態を扱う css ボタン 押したら変わる 9選 — 状態遷移の実装と、キーフレームで作るコピペ向けのボタン集 【コピペOK】CSS ボタン アニメーション 9選(SCSS + React)が合います。

ボタンの色を変える前によくある質問

グラデーションのボタンは transition で動かせますか?

この記事の場面は、どれも JS で計算した数値で色を動かしています。グラデーションの二つの組をそのまま移す代わりに、04 は層を重ねて上の層の不透明度を動かし、03 は停止位置を数値で動かし、05 は角度を数値で動かしています。

css ボタン 色 変わる動きで、ホバー後の色はどう選びますか?

二色の相対輝度が近いと途中が暗くなりやすいので、まず 02 のように明るさ補正の混合を見て選びます。地色が大きく明るくなる組なら、07 のように文字色まで計算に任せます。

ホバー以外の入力にも色変更を使えますか?

ポインター以外の入力は目標の値を変えるだけで済み、06 は押下を、05 はポインターの向きを入力に受けます。どんな入力でも、現在の値から目標へ移す計算は 01 と同じです。

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

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