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

難易度選択 ui 9種 — 角度と数式で決まる選択まで

難易度選択 ui 9種のうち、1つは手を離すとスライダーのつまみが段階を少し行き過ぎてからばね計算で戻って止まり、1つは5つのバッジの色を固定値ではなく段階番号を入れた色相角の数式で計算し、1つは半円ゲージの針を引く指の角度を値に変換します。

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

この記事では、ドット・カード・スライダー・バー・アイコン・キーボードフォーカス・バッジ・数字ステッパー・半円ゲージという9つの難易度選択画面パターンを、実際に動くデモとソースコードのまま並べます。見た目の違いだけでなく、それぞれがどんな計算式で「今どの段階か」を画面に反映しているかを見ていきます。

デモ

色が段階ごとに変わるドット式難易度セレクター
新しいタブで開く

01 · 色が段階ごとに変わるドット式難易度セレクター — 計測 累積面積 3.112% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

dotHuedotColorpaintUpTo

まずは1番目、色が段階ごとに変わるドット式難易度セレクターです。他の8つも同じ形式で、それぞれ自分のデモとコードブロックがすぐ下に続きます。

9つの難易度セレクターの解剖 — どこで計算の一層が回るか

9種はどれも「クリックした段階を覚えて表示に反映する」という土台は同じですが、その表示を更新する瞬間に何を計算しているかが1つずつ違います。まず、キャプチャした24フレームから求めた累積変化面積・平均強度・動いたフレーム数(いずれも23フレーム中)を並べます。

# パターン 累積面積 平均強度 動いたフレーム
01 色が段階ごとに変わるドット式難易度セレクター 3.11% 68.0 23中13
02 選ぶほど影が深くなるカード型選択UI 8.92% 78.1 23中12
03 段階で弾んで止まる難易度スライダー 4.29% 108.3 23中13
04 光沢が走り抜ける棒型難易度ゲージ 2.77% 77.6 23中12
05 段階ごとにアイコンの形が変形する選択UI 2.07% 81.1 23中11
06 フォーカスリングが慣性で追いかけるキーボード選択UI 5.87% 144.7 23中14
07 色が数式でつながるバッジ型選択UI 4.04% 70.0 23中10
08 数字が走行距離計のように回る難易度ステッパー 0.69% 105.6 23中11
09 ドラッグ角度で値が決まる半円型難易度ゲージ 2.20% 116.6 23中9

面積が最も大きいのは02のカード型(8.92%)で、影のy・ぼかし・浮き上がる高さの3つが同時に変わるぶん画面の変化量が大きくなります。逆に最も小さいのは08の数字ステッパー(0.69%)で、動くのは細い数字リールの帯だけです。平均強度が最も高いのは06のキーボードフォーカス(144.7)で、リングが遅れて追いつく間ずっとピクセル差が出続けるためです。動いたフレームが最も多いのも06(23中14)で、最も少ないのは09の半円ゲージ(23中9)です。

見た目の一属性だけを式で決める系統

01のドット色、02のカード影、07のバッジ色は、どれもクリックのたびにトランジションで滑らかに動くのではなく、選ばれた段階番号を1つの数式に入れて色や影の数値をその場で計算し直します。ドット色は色相角の直線補間、カード影はy・ぼかし・高さの3つの線形式、バッジ色は色相角に間隔を掛けた式です。

時間経過を伴う物理・イージング系統

03のスライダー、04のバーの光沢、05のアイコン変形、06のフォーカスリングは、目標値が決まった後もrequestAnimationFrameで毎フレーム値を更新し続けます。03はばね-減衰方程式で段階を行き過ぎてから戻り、04はeaseOutCubicで光沢を走らせ、05は輪郭の各頂点を線形補間し、06は指数減衰(lerp)でリングを目標に近づけます。

ポインタ座標を角度・比率に変換する系統

03のスライダーはポインタのx座標を割合に、09の半円ゲージはポインタ座標とピボットの間の角度をatan2で求めて0〜4の値に変換します。04のバーもポインタのx位置を段階に変換する点で同じ計算の仲間です。座標をそのまま使わず、いったん角度や比率という別の単位に変換してから難易度の値に戻しているのが共通点です。

パターン別コード

以下の9つはそれぞれのデモ本体index.htmlが実際に実行している計算関数を、変数名も順序もそのまま抜き出したものです。省略や並べ替えは行っていません。

01色が段階ごとに変わるドット式難易度セレクター

ドットを一つ指すとそこまでのドットが緑から赤へ計算された色で満ち、実際に選んだドットまでその色が残ります。アンケートやクイズの前に体感難易度を選ぶ場所で使う想定です。

色が段階ごとに変わるドット式難易度セレクター
新しいタブで開く

01 · 色が段階ごとに変わるドット式難易度セレクター — 計測 累積面積 3.112% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

dotHuedotColorpaintUpTo
  // 計算層: ドットの並び(0〜4)を緑(132°)から赤(0°)へ移る色相角の値に変換する
  function dotHue(i) { return HUE_LOW + (HUE_HIGH - HUE_LOW) * (i / (LEVELS.length - 1)); }
  function dotColor(i) { return 'hsl(' + dotHue(i).toFixed(1) + ', ' + SAT + '%, ' + LIGHT + '%)'; }

  function paintUpTo(i) {
    dots.forEach(function (d, idx) {
      d.style.background = idx <= i ? dotColor(idx) : '';
    });
    fill.style.width = ((i + 1) / LEVELS.length * 100) + '%';
    fill.style.background = dotColor(i);
  }

02選ぶほど影が深くなるカード型難易度選択

カードを選ぶとその影の高さとぼかしが段階番号に比例して計算され大きくなるため、上級カードほど浮いて見えます。アプリの開始画面で上・中・下の難易度を選ぶ場所を想定しています。

選ぶほど影が深くなるカード型難易度選択
新しいタブで開く

02 · 選ぶほど影が深くなるカード型難易度選択 — 計測 累積面積 8.917% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

shadowForpaintselect
  // 計算層: カードの影の y・ぼかし・浮き上がる高さを固定値ではなく段階番号(tier)に比例して求める
  function shadowFor(tier) {
    var y = BASE_Y + tier * Y_STEP;
    var blur = BASE_BLUR + tier * BLUR_STEP;
    var lift = BASE_LIFT + tier * LIFT_STEP;
    return { y: y, blur: blur, lift: lift };
  }
  function paint() {
    cards.forEach(function (c, i) {
      var on = i === selected;
      c.setAttribute('aria-checked', on ? 'true' : 'false');
      c.setAttribute('tabindex', on ? '0' : '-1');
      if (on) {
        var s = shadowFor(i);
        c.style.boxShadow = '0 ' + s.y + 'px ' + s.blur + 'px rgba(23, 20, 26, .38)';
        c.style.transform = 'translateY(-' + s.lift + 'px)';
      } else {
        c.style.boxShadow = '';
        c.style.transform = '';
      }
    });
  }

03段階で弾んで止まる難易度スライダー

手を離すとつまみが最も近い段階を少し行き過ぎたあと、ばね計算で引き戻されその段階でぴたりと止まります。設定画面で指で引いて難易度を決める場所を想定しており、他の難易度セレクターと違って「行き過ぎてから戻る」動き自体が今の段階を強調する役目も持ちます。

段階で弾んで止まる難易度スライダー
新しいタブで開く

03 · 段階で弾んで止まる難易度スライダー — 計測 累積面積 4.291% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

springTotierPctpaintAt
  // 計算層: 目標の段階までばね-減衰方程式(加速度 = -k*(位置-目標) - d*速度)を毎フレーム積分し、
  // 段階を少し行き過ぎてから(オーバーシュート)跳ね返って正確にその段階で止まる
  function springTo(target) {
    cancelAnimationFrame(raf);
    if (reduce) { pos = target; paintAt(pos); return; }
    var vel = 0;
    function step() {
      var force = -SPRING_K * (pos - target) - SPRING_D * vel;
      vel += force * DT;
      pos += vel * DT;
      paintAt(pos);
      if (Math.abs(pos - target) > 0.15 || Math.abs(vel) > 0.15) raf = requestAnimationFrame(step);
      else { pos = target; paintAt(pos); }
    }
    raf = requestAnimationFrame(step);
  }

04光沢が走り抜ける棒型難易度ゲージ

難易度が変わるたびバーの中で明るい帯の左から右への位置を毎フレーム計算して描き、走り抜けるように見せます。ダッシュボードで現在の難易度水準を一目で見せる場所を想定しています。

光沢が走り抜ける棒型難易度ゲージ
新しいタブで開く

04 · 光沢が走り抜ける棒型難易度ゲージ — 計測 累積面積 2.773% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

easeOutCubicsweeppaint
  function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
  // 計算層: 塗りが変わるたびに明るい帯の位置を easeOutCubic(経過比率) で毎フレーム計算し、左から右へ走らせる
  function sweep() {
    cancelAnimationFrame(raf);
    if (reduce) { gloss.style.opacity = '0'; return; }
    t0 = performance.now();
    gloss.style.opacity = '1';
    function step(now) {
      var t = Math.min(1, (now - t0) / SWEEP_MS);
      gloss.style.left = (easeOutCubic(t) * 100) + '%';
      if (t < 1) raf = requestAnimationFrame(step);
      else gloss.style.opacity = '0';
    }
    raf = requestAnimationFrame(step);
  }

05段階ごとにアイコンの形が変形する難易度選択

段階を切り替えるたびアイコンの輪郭座標が次の段階の形へ補間され、形が飛ばずに滑らかにつながります。ゲーム設定で難易度ごとの象徴アイコンを見せる場所を想定しています。

段階ごとにアイコンの形が変形する難易度選択
新しいタブで開く

05 · 段階ごとにアイコンの形が変形する難易度選択 — 計測 累積面積 2.066% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

polygonRadiusbuildPathradiiFor
  // 計算層: 正多角形の辺の数(sides)と角度(theta)からその方向の半径を求める幾何公式。
  // theta が辺の中心から離れるほど(頂点に近いほど)半径が長くなる
  function polygonRadius(theta, sides) {
    var seg = (2 * Math.PI) / sides;
    var a = ((theta % seg) + seg) % seg - seg / 2;
    return R * Math.cos(seg / 2) / Math.cos(a);
  }
  function angleAt(i) { return (i / POINTS) * Math.PI * 2 - Math.PI / 2; }
  function radiiFor(sides) {
    var out = [];
    for (var i = 0; i < POINTS; i++) out.push(polygonRadius(angleAt(i), sides));
    return out;
  }

06フォーカスリングが慣性で追いかけるキーボード難易度選択

矢印キーで段階を移すとフォーカスリングは即座に移動せず、毎フレーム残り距離の一部だけを縮める計算で遅れて追いつき止まります。キーボードのみで操作するアクセシビリティ設定で、この選択UIを使う場所を想定した項目です。

フォーカスリングが慣性で追いかけるキーボード難易度選択
新しいタブで開く

06 · フォーカスリングが慣性で追いかけるキーボード難易度選択 — 計測 累積面積 5.865% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

measuremoveRingTopaintRing
  // 計算層: 毎フレーム残り距離の TRAIL_LERP の割合だけ縮める指数減衰(lerp)で、リングが目標を追いかける
  function loop() {
    ringX += (targetX - ringX) * TRAIL_LERP;
    ringW += (targetW - ringW) * TRAIL_LERP;
    paintRing(ringX, ringW);
    if (Math.abs(targetX - ringX) > 0.4 || Math.abs(targetW - ringW) > 0.4) raf = requestAnimationFrame(loop);
    else { ringX = targetX; ringW = targetW; paintRing(ringX, ringW); }
  }
  function moveRingTo(i) {
    var m = measure(i);
    targetX = m.x; targetW = m.w;
    cancelAnimationFrame(raf);
    if (reduce) { ringX = targetX; ringW = targetW; paintRing(ringX, ringW); return; }
    raf = requestAnimationFrame(loop);
  }

07色が数式でつながるバッジ型難易度選択

5つのバッジは固定された5色ではなく段階番号を色相角の計算式に入れて作った色のため、段階を増やすとバッジ間の色の間隔も再計算されます。管理画面で難易度等級をタグのように付ける場所を想定しています。

色が数式でつながるバッジ型難易度選択
新しいタブで開く

07 · 色が数式でつながるバッジ型難易度選択 — 計測 累積面積 4.045% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

badgeHuebadgeColorpaint
  // 計算層: バッジの色を固定5色ではなく、段階番号を色相角の数式(基準色相 + 番号*間隔)に入れて求める
  function badgeHue(i) { return (BASE_HUE + i * HUE_SPACING) % 360; }
  function badgeColor(i) { return 'hsl(' + badgeHue(i) + ', ' + SAT + '%, ' + LIGHT + '%)'; }
  function paint() {
    badges.forEach(function (b, i) {
      b.style.background = badgeColor(i);
      b.setAttribute('aria-checked', i === selected ? 'true' : 'false');
      b.setAttribute('tabindex', i === selected ? '0' : '-1');
    });
  }

08数字が走行距離計のように回る難易度ステッパー

+/-ボタンを押すと数字の一桁が上下に回って次の値へ移り、その位置は桁の位取り計算から求められ、繰り上がりが起きると左の桁も一緒に回ります。フォームで設問の難易度点数を数字で直接決める場所を想定しており、03のスライダーや09のゲージと違って「今どの段階か」を角度や影ではなく数字そのもので伝える点が特徴です。

数字が走行距離計のように回る難易度ステッパー
新しいタブで開く

08 · 数字が走行距離計のように回る難易度ステッパー — 計測 累積面積 0.688% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

placeDigitpaintset
  // 計算層: 値を10のべき乗で割った商の一の位(placeDigit)で各リールが見せる数字を求め、
  // その桁の値 × 段の高さの分だけリールを押し出して窓にその数字を合わせる — 繰り上がりは商が変わることで自然に起きる
  function placeDigit(v, place) { return Math.floor(v / Math.pow(10, place)) % 10; }
  function paint() {
    var ones = placeDigit(value, 0), tens = placeDigit(value, 1);
    onesStrip.style.transform = 'translateY(-' + (ones * DIGIT_H) + 'px)';
    tensStrip.style.transform = 'translateY(-' + (tens * DIGIT_H) + 'px)';
    display.setAttribute('aria-valuenow', String(value));
    out.textContent = String(value);
  }

09ドラッグ角度で値が決まる半円型難易度ゲージ

針を指で引くと中心から指までの角度を求めてその角度を難易度の値へ変換し、弧の中の色分けされた区間もその値の範囲に合わせて描き直します。設定ダッシュボードで指先で難易度を微調整する場所を想定しており、08のステッパーが1段ずつ動くのに対しこちらはドラッグ中に連続した角度から値を求める点が違います。

ドラッグ角度で値が決まる半円型難易度ゲージ
新しいタブで開く

09 · ドラッグ角度で値が決まる半円型難易度ゲージ — 計測 累積面積 2.203% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

angleToValuezoneCenterDegpaint
  function zoneCenterDeg(i) { return -90 + ZONE_DEG / 2 + i * ZONE_DEG; }
  zones.forEach(function (z, i) { z.style.transform = 'rotate(' + zoneCenterDeg(i) + 'deg)'; });

  // 計算層: ポインタ座標とピボット(底の中央)の間の角度を atan2 で求め、-90〜90度の区間を0〜4の値に写す
  function angleToValue(clientX, clientY) {
    var r = face.getBoundingClientRect();
    var px = r.left + r.width / 2;
    var py = r.bottom;
    var dx = clientX - px;
    var dy = Math.min(clientY - py, -1); // ピボットの上方向に強制し、左端で角度が跳ねないようにする
    var theta = Math.atan2(dy, dx) * 180 / Math.PI; // -180..0
    var t = (theta + 180) / 180;
    t = Math.max(0, Math.min(1, t));
    return t * 4;
  }

値がUIを変える場所 — 段階数・色相角の間隔・ばね減衰

同じ計算式でも定数を変えるだけで感触が変わります。値を変えるとどう見えるかを実測込みで説明します。

03 SPRING_D — ばねの減衰の強さ

springTo()のforce = -SPRING_K*(pos-target) - SPRING_D*velという式で、SPRING_Dは速度に比例するブレーキ役です。数値を上げるほど同じSPRING_Kに対して振動が早く収まり、つまみが段階を行き過ぎる度合いが小さくなります。

07 HUE_SPACING — バッジ間の色相角の間隔

badgeHue(i) = (BASE_HUE + i*HUE_SPACING) % 360という式で、HUE_SPACINGはバッジ番号1つ分あたり色相角がどれだけ離れるかを決めます。数値を広げると5つのバッジの色がより離れ、隣同士の区別がつきやすくなります。

06 TRAIL_LERP — フォーカスリングの追いつく速さ

ringX += (targetX - ringX) * TRAIL_LERPという式で、TRAIL_LERPは毎フレーム残り距離のうち何割を縮めるかを決めます。数値を下げるとリングが1フレームに進む距離が減り、目標までゆっくり滑らかに追いつくようになります。

変形 値 効果
ばねの減衰値を上げてオーバーシュートを減らす 03 SPRING_D 18 → 26 SPRING_D を18から26に上げると同じSPRING_K=210に対して振動がより早く収まり、スライダーのつまみが段階を行き過ぎる度合いが減って落ち着いて止まる — Dが作ったspringTo()の数式(force = -SPRING_K(pos-target) - SPRING_Dvel)で確認済み。
バッジの色相角の間隔を広げる 07 HUE_SPACING 46 → 64 HUE_SPACING を46から64に広げるとbadgeHue(i) = (BASE_HUE + i*HUE_SPACING) % 360の計算値が互いに離れ、5つのバッジの色がはっきり区別できる — Dが作ったbadgeHue()の数式で確認済み。
フォーカスリングの慣性lerp値を下げる 06 TRAIL_LERP 0.18 → 0.10 TRAIL_LERP を0.18から0.10に下げるとフォーカスリングが毎フレーム縮める距離の割合が減り、目標の段階へよりゆっくり滑らかに追いつく — Dが作ったringX += (targetX-ringX)*TRAIL_LERPの数式で確認済み。

どこで壊れるか — アクセシビリティ・reduced-motion・キーボードフォーカス

9つともprefers-reduced-motion: reduceをwindow.matchMediaで読み取り、条件が真ならアニメーションの経路を切って最終状態へ直接切り替えます。デモの自動再生だけを止めるものと、ユーザー操作時のトランジションまで止めるものがあるので、項目ごとに実際のふるまいを挙げます。

  • 01 ドット式: pointerenter・pointerleave・click・keydownを扱い、CSSの.dr__fill, .dr__dot { transition: none; }でreduced-motion時は色の遷移を切ります。
  • 02 カード型: click・keydownを扱い、CSSの.rc__cardにreduced-motion用の遷移解除ルールがあります。
  • 03 スライダー: keydown・pointerdown/pointermove/pointerupを扱い、reduceが真ならspringTo()はばね計算を飛ばして目標値へ即座に移ります。
  • 04 バーゲージ: click・keydown・pointerdownを扱い、reduceが真ならsweep()は光沢を走らせず不透明度を0のままにします。
  • 05 アイコン変形: click・keydownを扱い、CSSの.ims__dotにreduced-motion用の遷移解除ルールがあります。
  • 06 キーボードフォーカス: click・keydownを扱い、reduceが真ならmoveRingTo()はlerpのループを回さずリングを目標位置へ直接置きます。
  • 07 バッジ型: click・keydownを扱い、CSSの.bhs__badgeにreduced-motion用の遷移解除ルールがあります。
  • 08 数字ステッパー: click・keydownを扱い、CSSの.nso__stripにreduced-motion用の遷移解除ルールがあります。
  • 09 半円ゲージ: keydown・pointerdown/pointermove/pointerupを扱い、CSSの.gad__needle, .gad__zoneにreduced-motion用の遷移解除ルールがあります。

zipにはHTML・CSS・JSがそのまま入っていて、パスワード m4ceuurv で解凍するとどの項目のソースもすぐコピーできます。

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

上の3つの変形は数値を書き換えるだけで試せます。SPRING_D・HUE_SPACING・TRAIL_LERPのどれも1か所の定数なので、自分のプロジェクトの雰囲気に合わせて調整してからそのまま使ってください。

難易度選択 ui の実装例はどこで見られる?

「ゲームの難易度設定」で検索すると、ゲーム内設定の見た目だけを真似たキャプチャ画像が多く、実際に動くコードごと持ち帰れる例は多くありません。この記事の9つはどれもライブラリ・CDNなしのHTML・CSS・JSだけで完結していて、デモの右上からコードをコピーするか、zipを解凍してそのままプロジェクトに置けます。

FAQ

なぜ06のキーボードフォーカスリングは平均強度が9種の中で最も高いのですか?

moveRingTo()がrequestAnimationFrameのループで毎フレームringXとringWをTRAIL_LERPの割合だけ動かし続けるため、リングが目標位置に落ち着くまでの間ずっとピクセル差が発生し続けます。この間続くわずかな差の積み重ねが、実測で平均強度144.7という9種中最高の値になっています。

03のスライダーはばねのオーバーシュートで有効な段階の外まで動くことがありますか?

springTo()はposを段階の百分率位置(tierPct()が返す値)に向けて動かしますが、step()内で位置の許容誤差(0.15)まで収束させてからpos = targetで確定させるため、表示上の見た目のオーバーシュートはあっても最終的に選択される段階そのものはsettle()が受け取った整数の段階番号から外れません。

このような計算で作った選択UIを実際の設定画面にそのまま使っても安全ですか?

9つともrole・aria-checked・aria-valuenowなどのARIA属性とキーボード操作、prefers-reduced-motionへの対応をソースに含めていますが、実際のプロダクトで使う前には自分のフォーム構造やラベルの読み上げ順に合わせて調整し、実データでのキーボード・スクリーンリーダー確認を必ず行ってください。

出典: MDN — role="radiogroup" · MDN — role="slider" · MDN — prefers-reduced-motion · MDN — requestAnimationFrame · MDN — Math.atan2()

関連記事: セグメントコントロールUI 9選 — コピペOK · 星評価 UI 9選 — 塗りの値が状態に応じて変わる仕組み · カレンダー 選択 UI 9選 — コピペOK

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

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