本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
グラフ

メーター型のグラフの針が振れて行き過ぎ、値で止まる

デモ

仕様

用途
グラフ
動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

色で区分を分けた半円のゲージで、針がばねのように振れて値を少し行き過ぎ、戻って止まります。針は値 × 1.8° だけ回し、区分の帯は薄く敷いておいて、値までを濃い円弧で塗ります。区分の範囲と色は凡例の行から読むので、行を直せばゲージの帯も直ります。

針の動きは、減衰振動(ばね)の式をそのまま GSAP の ease に渡しています。減衰 0.5・速さ 12 で、振れ幅の 16% だけ行き過ぎ、1.8 秒で止まります。elastic.out より立ち上がりが柔らかく、計器の針らしく見えます。振れ始めて 1.1 秒後、針がほぼ止まったころに状態のバッジを左から開き、凡例の該当する行を色づけます。

塗りと中央の数字を針にそのまま合わせると、行き過ぎから戻るあいだに、数字が 93 → 92 → 93 と揺れます。針が値を初めて越えた時点で、塗りと数字を値に止めると、数字は値へ向かって一方向にだけ動きます。

値は、ゲージの枠に meter の役割(role)と aria-valuenow を持たせて、ここを正にします。読み上げには、針の動きを待たずに aria-valuetext で「93点(非常に良好)」を届け、動いている数字と針は読み上げから外します。

揺れ方は、ばねの減衰と速さで変えられます。減衰を小さくすると大きく揺れ、速さを上げると小刻みになります。区分の数や範囲を変えるときは、凡例の行を足し引きするだけで済みます。区分の色は、エラーから成功までの意味の色を、彩度を少し落として段階に並べています。バッジの塗りは区分の色を文字の色へ 25% 寄せ、地の色の文字でも読める明るさにします。スコア・速度・健康度・達成率の表示に向いています。

コード

HTML
<section class="gauge" aria-labelledby="gauge-title">
  <h2 class="gauge__title" id="gauge-title">サイトの健康度</h2>
  <p class="gauge__lead" id="gauge-lead">監視しているすべてのページの総合スコア</p>

  <div class="gauge__body">
    <!-- 値はここが正(aria-valuenow)。読み上げには、針の動きを待たずに値と区分の名前が届く -->
    <div class="gauge__meter" role="meter" aria-labelledby="gauge-title" aria-describedby="gauge-lead"
         aria-valuemin="0" aria-valuemax="100" aria-valuenow="93" aria-valuetext="93点(非常に良好)">
      <svg class="gauge__dial" viewBox="0 0 440 256" aria-hidden="true">
        <!-- 帯と目盛りは、下の区分から JS がここに描く -->
        <g class="gauge__scale"></g>
        <!-- 針は左向きに描いて rotate で振る。影は同じ形をずらして重ねる(ぼかしのフィルターは使わない) -->
        <path class="gauge__shadow" d="M246,209L58,212.4Q55,214 58,215.6L246,219Z" />
        <path class="gauge__needle" d="M246,209L58,212.4Q55,214 58,215.6L246,219Z" />
        <circle class="gauge__hub" cx="220" cy="214" r="17" />
        <circle class="gauge__pin" cx="220" cy="214" r="6" />
      </svg>
      <p class="gauge__readout" aria-hidden="true"><span class="gauge__number">0</span><span class="gauge__max">/100</span></p>
      <p class="gauge__badge" aria-hidden="true"></p>
    </div>

    <!-- 区分。範囲の上限(data-to)と色(--color)は、ここから読んでゲージを描く -->
    <div class="gauge__ranges">
      <p class="gauge__label">スコアの区分</p>
      <ul class="gauge__list">
        <li style="--color: #b3604e" data-to="40"><span>要改善</span><span>0〜40</span></li>
        <li style="--color: #af7b4b" data-to="60"><span>普通</span><span>40〜60</span></li>
        <li style="--color: #9a833d" data-to="80"><span>良好</span><span>60〜80</span></li>
        <li style="--color: #568366" data-to="100"><span>非常に良好</span><span>80〜100</span></li>
      </ul>
    </div>
  </div>
</section>

CSS
.gauge {
  --bg: #111111;                     /* ページの地。バッジの文字にも使う */
  --surface: #262626;                /* カードの面 */
  --ink: #eeece6;
  --mute: #898989;                   /* 10 ごとの目盛りと「/100」 */
  --mute-small: #a8a6a0;             /* 16px 未満の文字 */
  --line: rgba(238, 236, 230, 0.14); /* 2 ごとの目盛り */
  padding: clamp(24px, 5vw, 48px);
  border-radius: 4px;
  background: var(--surface);
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}

.gauge__title { margin: 0; font-size: 24px; font-weight: 700; line-height: 1.3; letter-spacing: -0.01em; }
.gauge__lead { margin: 4px 0 40px; color: var(--mute-small); font-size: 14px; letter-spacing: 0.04em; }

.gauge__body { display: flex; flex-wrap: wrap; align-items: center; gap: 48px 64px; }
.gauge__meter { flex: 0 1 440px; text-align: center; }
.gauge__dial { display: block; width: 100%; height: auto; }

/* 目盛りと帯(帯の色は、JS が区分の色を stroke に入れる) */
.gauge__track,
.gauge__fill { fill: none; stroke-width: 26; }
.gauge__track { stroke-opacity: 0.2; }
.gauge__tick { stroke: var(--line); stroke-width: 1.5; stroke-linecap: round; }
.gauge__tick.is-major { stroke: var(--mute); stroke-width: 2; }
.gauge__dial text { fill: var(--mute-small); font-size: 14px; font-weight: 600; text-anchor: middle; }

/* 針と影は、JS が入れる --turn(度)で回す。回す中心は、viewBox の中の軸の位置 */
.gauge__needle,
.gauge__shadow { transform-origin: 220px 214px; transform: rotate(calc(var(--turn, 0) * 1deg)); }
.gauge__needle,
.gauge__hub { fill: var(--ink); }
.gauge__shadow { translate: 0 4px; fill: rgba(0, 0, 0, 0.4); }
.gauge__pin { fill: var(--surface); }

/* 数字の箱を右寄せの固定幅にして、桁が変わっても「/100」が動かないようにする */
.gauge__readout { margin: 8px 0 0; white-space: nowrap; }
.gauge__number { display: inline-block; width: 3ch; font-size: 64px; font-weight: 800; line-height: 1; letter-spacing: -0.02em; text-align: right; }
.gauge__max { margin-left: 8px; color: var(--mute); font-size: 20px; font-weight: 600; }

/* 状態のバッジ。区分の色を文字の色へ 25% 寄せた塗りに、地の色の文字(エラーの色のままでは 4.5:1 に届かない)。JS が clip-path で左から開く */
.gauge__badge {
  display: inline-block;
  min-width: 128px;
  margin: 16px 0 0;
  padding: 0 16px;
  box-sizing: border-box;
  border-radius: 999px;
  background: color-mix(in srgb, var(--zone, transparent) 75%, var(--ink));
  color: var(--bg);
  font-size: 13px;
  font-weight: 700;
  line-height: 32px;
  letter-spacing: 0.06em;
  clip-path: inset(0 100% 0 0 round 99px);
}

.gauge__ranges { flex: 1 1 320px; }
.gauge__label { margin: 0 0 16px; color: var(--mute-small); font-size: 13px; font-weight: 700; line-height: 1; letter-spacing: 0.06em; }
.gauge__list { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }

.gauge__list li {
  display: flex;
  align-items: center;
  gap: 16px;
  height: 52px;
  padding: 0 16px;
  border-radius: 4px;
  font-size: 16px;
  font-weight: 600;
  letter-spacing: 0.04em;
  transition: background-color 0.4s;
}

.gauge__list li::before { content: ""; width: 12px; height: 12px; border-radius: 2px; background: var(--color); }
.gauge__list li span:last-child { margin-left: auto; color: var(--mute-small); font-size: 14px; font-weight: 400; }

/* 針が止まった区分の行。半透明にせず、面に区分の色を 20% 混ぜた不透明な色で塗る */
.gauge__list [aria-current] { background-color: color-mix(in srgb, var(--color) 20%, var(--surface)); }

@media (prefers-reduced-motion: reduce) {
  .gauge__list li { transition: none; }
}

JavaScript
import { gsap } from "gsap";

const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const C = { x: 220, y: 214, r: 196, inner: 183 }; // 中心・帯の中心の半径・帯の内側の縁の半径(viewBox の中の値)
const GAP = 0.6; // 区分の間のすき間(値の単位)
const SHOW = 1.1; // 振れ始めてから、状態のバッジが開くまで(秒)

// 減衰振動(減衰 0.5・速さ 12 のばね)。値を振れ幅の 16% 行き過ぎてから戻る。GSAP の ease にそのまま渡せる
function spring(p) {
  const [zeta, omega] = [0.5, 12];
  const wd = omega * Math.sqrt(1 - zeta * zeta);
  return p >= 1 ? 1 : 1 - Math.exp(-zeta * omega * p) * (Math.cos(wd * p) + ((zeta * omega) / wd) * Math.sin(wd * p));
}

// 値 v(0〜100)の、半径 r の円周上の点。0 が左端、50 が真上、100 が右端
function point(v, r) {
  const a = Math.PI * (1 + v / 100);
  return [(C.x + r * Math.cos(a)).toFixed(2), (C.y + r * Math.sin(a)).toFixed(2)];
}
const arc = (a, b) => `M${point(a, C.r)}A${C.r},${C.r} 0 0 1 ${point(b, C.r)}`;

function add(parent, name, attrs) {
  const node = parent.appendChild(document.createElementNS("http://www.w3.org/2000/svg", name));
  for (const [key, value] of Object.entries(attrs)) node.setAttribute(key, value);
  return node;
}

function createGauge(root) {
  const [meter, dial, scale, number, badge] = ['[role="meter"]', ".gauge__dial", ".gauge__scale", ".gauge__number", ".gauge__badge"].map((s) => root.querySelector(s));

  // 区分は凡例の行から読む(行を直せば、帯も直る)。帯は薄く敷いておき、値までを濃く塗る
  let from = 0;
  const zones = [...root.querySelectorAll(".gauge__list li")].map((row) => {
    const zone = { row, from, to: Number(row.dataset.to), name: row.firstElementChild.textContent };
    zone.color = getComputedStyle(row).getPropertyValue("--color").trim();
    add(scale, "path", { class: "gauge__track", d: arc(from + GAP, zone.to - GAP), stroke: zone.color });
    zone.path = add(scale, "path", { class: "gauge__fill", stroke: zone.color });
    from = zone.to;
    return zone;
  });
  // 目盛り(2 ごと。10 ごとは長く濃く)と、20 ごとの数字。0 と 100 の数字は針の休む位置にかかるので、帯の端の下に置く
  for (let v = 0; v <= 100; v += 2) {
    const [x1, y1] = point(v, C.inner - 8);
    const [x2, y2] = point(v, C.inner - (v % 10 ? 13 : 20));
    add(scale, "line", { class: v % 10 ? "gauge__tick" : "gauge__tick is-major", x1, y1, x2, y2 });
    const [x, y] = v % 100 ? point(v, C.inner - 38) : [point(v, C.r)[0], C.y + 29];
    if (v % 20 === 0) add(scale, "text", { x, y: +y + 5 }).textContent = v;
  }

  const state = { needle: 0, badge: 0 };
  let score = 0, start = 0, reached = false, tl = null;

  function draw() {
    // 塗りと数字は、針が値を初めて越えるまで針に合わせ、越えたら値で止める(行き過ぎと戻りで、数字が揺れない)
    if ((state.needle - score) * Math.sign(score - start) >= 0) reached = true;
    const value = reached ? score : state.needle;
    dial.style.setProperty("--turn", (state.needle * 1.8).toFixed(2)); // 針は CSS で、値 × 1.8° 回す
    for (const z of zones) {
      const end = Math.min(z.to - GAP, value);
      z.path.setAttribute("d", end > z.from + GAP ? arc(z.from + GAP, end) : "");
    }
    number.textContent = Math.round(value);
    badge.style.clipPath = `inset(0 ${(100 - state.badge * 100).toFixed(1)}% 0 0 round 99px)`;
  }

  // 値を変える。読み上げには、針の動きを待たずに、新しい値と区分の名前を渡す
  function set(next) {
    [score, start, reached] = [Math.round(gsap.utils.clamp(0, 100, next)), state.needle, false];
    const zone = zones.find((z) => score <= z.to) ?? zones.at(-1);
    meter.setAttribute("aria-valuenow", score);
    meter.setAttribute("aria-valuetext", `${score}点(${zone.name})`);
    zones.forEach((z) => z.row.removeAttribute("aria-current"));
    // 前のバッジを閉じておき、針が止まるころに新しい区分の文言と色で左から開く。凡例の行も、そこで色づける
    tl?.kill();
    tl = gsap
      .timeline({ onUpdate: draw })
      .to(state, { needle: score, duration: 1.8, ease: spring }, 0)
      .to(state, { badge: 0, duration: 0.2, ease: "power2.in" }, 0)
      .call(() => {
        badge.textContent = zone.name;
        badge.style.setProperty("--zone", zone.color);
        zone.row.setAttribute("aria-current", "true");
      }, null, SHOW)
      .to(state, { badge: 1, duration: 0.45, ease: "power3.out" }, SHOW);
    if (reduceMotion) tl.progress(1); // 動きを減らす設定では、止まった状態で見せる
  }

  draw();
  // 画面に入ったら、書いてある値まで振る。動きを減らす設定では、見張りを始めた直後の 1 回目で止まった状態にする
  const io = new IntersectionObserver(([entry]) => {
    if (!entry.isIntersecting && !reduceMotion) return;
    io.disconnect();
    if (!tl) set(Number(meter.getAttribute("aria-valuenow")));
  }, { rootMargin: "0px 0px -20%" });
  io.observe(root);
  return { set };
}

export const gauges = [...document.querySelectorAll(".gauge")].map(createGauge); // 例: 値が変わったら gauges[0].set(88)

インタラクションデザインについて、
お気軽にご相談ください。

ここに並べているのは、Web サイトの中で動く UI のサンプルです。新しく作るサイトに組み込むことも、いま公開中のサイトに、ボタンやヒーロー、スクロールの演出だけを足すこともできます。設計から実装までの進め方はWeb サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。