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

目標の線に向かって実績の棒グラフが伸び、届いた項目に「達成」の札が付く

デモ

仕様

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

しくみと調整

項目ごとに、淡い 3 段の帯(不足・目標圏・十分)と縦の目標の線を敷き、その上を実績の細い棒が上の行から順に伸びます。棒の先が目標の線を越えた瞬間に、右に「達成」の札が弾んで出ます。届かない項目には札を出さず、達成率の数字だけを残します。下から棒を立てる棒グラフや、針が振れるメーターと違い、1 本の棒の中で「目標に対してどこまで来たか」と「どの段にいるか」を同時に読ませます。

棒は 1 本 1.4 秒の power3.out で伸ばし、次の行は 0.16 秒ずつ遅らせます。札は伸びきってから出すのではなく、棒の先が線を越えた時点で back.out の 0.3 秒で弾ませます。power3.out は終わりほど遅いので、目標に近い実績の項目ほど札の出る時刻が後ろへずれ、ぎりぎりで届いた様子が伝わります。右の達成率と実績の数字は、棒の伸びと同じ進みで 0 から数え上げます。

項目ごとに単位も大きさも違う(万円・人・件・%)ので、帯の境目・目標の線・棒の長さは、どれもその項目の目盛りの上端に対する割合で置きます。すべての項目を同じ目盛りに載せると、率の項目の棒が短くつぶれて読めません。帯の 3 段は 1 本の linear-gradient の色の区切りで塗るので、境目を変えるときは CSS 変数を書き換えるだけで済みます。

値は表としてページに書き、スクリプトはその表を読んで行を組みます。組んだあとの表は見えなくするだけにして、読み上げに残します。帯と棒は図として読み上げから外し、目標・実績・帯の境目は表で届けます。動かすのは画面に入った 1 回だけで、動きを減らす設定では、伸びきって札の出た状態で置きます。

つまみは、帯の境目(不足の上限・目標圏の上限)と目盛りの上端、棒が伸びる時間と行のずれ、札の弾み方です。帯の色は暗いほど「足りない」にして、明るさの差で見分けます。営業の月次報告や KPI のダッシュボード、募金や会員数の目標の進み具合を並べて見せる節に向いています。

コード

HTML
<figure class="bullet">
  <figcaption class="bullet__caption">
    <span class="bullet__eyebrow">Target</span>
    <span class="bullet__title">今期の目標の達成状況</span>
    <span class="bullet__note"><span>2026年度上期(4〜9月)・</span><span>項目ごとの目標と実績</span></span>
  </figcaption>
  <ul class="bullet__legend" aria-hidden="true">
    <li class="is-poor">不足</li>
    <li class="is-fair">目標圏</li>
    <li class="is-good">十分</li>
    <li class="is-target">目標</li>
    <li class="is-actual">実績</li>
  </ul>

  <!-- 行はスクリプトが表から組む(絵なので読み上げからは外し、値は下の表で届ける) -->
  <ol class="bullet__rows" aria-hidden="true"></ol>
  <template class="bullet__template">
    <li class="bullet__row">
      <span class="bullet__label"><b class="bullet__name"></b><small>目標 <span data-target></span></small></span>
      <span class="bullet__track"><span class="bullet__bar"></span><span class="bullet__line"></span><span class="bullet__ticks"></span></span>
      <span class="bullet__result"><b class="bullet__rate">0%</b><small>実績 <span data-actual>0</span></small></span>
      <span class="bullet__badge"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3 8.5 7 12.5 13 4.5" /></svg>達成</span>
    </li>
  </template>

  <!-- 値はこの表が正。帯の境目(不足の上限・目標圏の上限)と目盛りの上端も、ここから読む -->
  <div class="bullet__scroll">
    <table class="bullet__table">
      <caption>項目ごとの目標と実績(2026年度上期)</caption>
      <thead>
        <tr><th scope="col">項目</th><th scope="col">単位</th><th scope="col">実績</th><th scope="col">目標</th><th scope="col">不足の上限</th><th scope="col">目標圏の上限</th><th scope="col">目盛りの上端</th></tr>
      </thead>
      <tbody>
        <tr><th scope="row">売上</th><td>万円</td><td>1,284</td><td>1,200</td><td>800</td><td>1,300</td><td>1,600</td></tr>
        <tr><th scope="row">新規顧客</th><td>人</td><td>212</td><td>240</td><td>160</td><td>260</td><td>320</td></tr>
        <tr><th scope="row">問い合わせ</th><td>件</td><td>486</td><td>450</td><td>300</td><td>480</td><td>600</td></tr>
        <tr><th scope="row">成約率</th><td>%</td><td>24.6</td><td>30</td><td>20</td><td>32</td><td>40</td></tr>
        <tr><th scope="row">継続率</th><td>%</td><td>91</td><td>85</td><td>70</td><td>90</td><td>100</td></tr>
      </tbody>
    </table>
  </div>
</figure>

CSS
.bullet {
  --bg: #f2f1ed;
  --ink: #262626;
  --mute: #898989;
  --mute-small: #6b6b6b; /* 16px 未満の文字 */
  --line: rgb(38 38 38 / 0.12);
  /* 帯の 3 段は、暗いほど「足りない」。明るさの差で見分ける */
  --poor-band: #c0a68a;
  --fair-band: #d5c8b4;
  --good-band: #e6e0d3;
  --bar: #475c6a;
  --target-line: #262626;
  --done: #3f7352; /* 「達成」の札。白い文字で 5.5:1 */
  --track: 24px;
  position: relative;
  display: grid;
  gap: 24px;
  overflow: clip; /* 読み上げ用に隠した表が、狭い画面で横にはみ出さないように */
  margin: 0;
  padding: 48px clamp(16px, 5vw, 96px);
  background: var(--bg);
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}

.bullet__caption { display: grid; gap: 10px; }
.bullet__eyebrow { color: var(--mute-small); font-size: 13px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; }
.bullet__title { font-size: clamp(28px, 6vw, 44px); font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; }
.bullet__note { color: var(--mute); font-size: 16px; letter-spacing: 0.04em; }
.bullet__note span { display: inline-block; } /* 狭い画面でも、語の途中で折り返さない */

.bullet__legend { display: flex; flex-wrap: wrap; gap: 8px 24px; margin: 0; padding: 0; color: var(--mute-small); font-size: 13px; letter-spacing: 0.04em; list-style: none; }
.bullet__legend li { display: flex; align-items: center; gap: 8px; }
.bullet__legend li::before { content: ""; width: 14px; height: 14px; background: var(--c); }
.bullet__legend .is-poor { --c: var(--poor-band); }
.bullet__legend .is-fair { --c: var(--fair-band); }
.bullet__legend .is-good { --c: var(--good-band); }
.bullet__legend .is-target::before { width: 3px; height: 20px; margin-inline: 4px; background: var(--target-line); }
.bullet__legend .is-actual::before { width: 22px; height: 8px; background: var(--bar); }

/* 行: 項目名 | 帯 | 達成率 | 札 */
.bullet__rows { display: grid; gap: 28px; margin: 0; padding: 0; list-style: none; }
.bullet__row { display: grid; grid-template-columns: 180px minmax(0, 1fr) 112px 72px; grid-template-areas: "label track result badge"; align-items: start; column-gap: 24px; padding-bottom: 20px; }
.bullet__label { grid-area: label; display: grid; gap: 6px; }
.bullet__name { font-size: 16px; font-weight: 700; letter-spacing: 0.04em; }
.bullet__label small,
.bullet__result small { color: var(--mute-small); font-size: 13px; letter-spacing: 0.04em; }

/* 帯は 1 本のグラデーションの区切りで 3 段に塗る(境目は項目ごとの割合) */
.bullet__track {
  grid-area: track;
  position: relative;
  height: var(--track);
  background: linear-gradient(to right, var(--poor-band) var(--poor), var(--fair-band) 0 var(--good), var(--good-band) 0);
}
.bullet__bar { position: absolute; top: calc(50% - 5px); left: 0; width: 0; height: 10px; background: var(--bar); }
.bullet__line { position: absolute; top: -8px; bottom: -8px; left: calc(var(--goal) - 1px); width: 3px; background: var(--target-line); }
.bullet__ticks { position: absolute; top: calc(100% + 6px); inset-inline: 0; color: var(--mute-small); font-size: 13px; }
.bullet__ticks span { position: absolute; left: calc(var(--k) * 25%); translate: -50%; }
.bullet__ticks span:first-child { translate: 0; }
.bullet__ticks span:last-child { translate: -100%; }

.bullet__result { grid-area: result; display: grid; gap: 6px; justify-items: end; white-space: nowrap; }
.bullet__rate { font-size: 20px; font-weight: 800; line-height: 1; letter-spacing: -0.01em; }

.bullet__badge {
  grid-area: badge;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 4px;
  height: 28px;
  border-radius: 999px;
  background: var(--done);
  color: #ffffff;
  font-size: 13px;
  font-weight: 700;
  letter-spacing: 0.06em;
  visibility: hidden; /* 棒が目標を越えたら、スクリプトが弾ませて出す */
  opacity: 0;
}
.bullet__badge svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }

/* 狭い画面では、項目名と達成率を上の行に、帯を下の行に置く */
@media (max-width: 640px) {
  .bullet__row { grid-template-columns: minmax(0, 1fr) auto 64px; grid-template-areas: "label result badge" "track track track"; row-gap: 14px; column-gap: 12px; }
  .bullet__badge { gap: 2px; font-size: 12px; }
}

/* 読み上げだけに届ける。表は、スクリプトが行を組むまで見えたまま残す */
.bullet.is-live .bullet__scroll { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.bullet__scroll { overflow-x: auto; }
.bullet__table { width: 100%; border-spacing: 0; }
.bullet__table caption { padding-bottom: 12px; color: var(--mute-small); font-size: 13px; text-align: left; }
.bullet__table th,
.bullet__table td { padding: 10px 12px; border-top: 1px solid var(--line); font-size: 14px; text-align: right; white-space: nowrap; }
.bullet__table th:first-child { text-align: left; }
.bullet__table thead th { border: 0; color: var(--mute-small); font-size: 13px; font-weight: 600; }

JavaScript
import { gsap } from "gsap";

// 動きを減らす設定では、伸びきった絵で置く
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const toNumber = (text) => Number(text.replace(/[^\d.]/g, ""));
const format = (v, digits) => v.toLocaleString("ja-JP", { minimumFractionDigits: digits, maximumFractionDigits: digits });

document.querySelectorAll(".bullet").forEach((chart) => {
  const list = chart.querySelector(".bullet__rows");
  const template = chart.querySelector(".bullet__template");
  // 値は表が正。列は 項目・単位・実績・目標・不足の上限・目標圏の上限・目盛りの上端
  const rows = [...chart.querySelectorAll("tbody tr")].map((tr) => {
    const [name, unit, ...cells] = [...tr.cells].map((cell) => cell.textContent.trim());
    const [actual, target, poor, good, max] = cells.map(toNumber);
    const li = template.content.firstElementChild.cloneNode(true);
    const find = (selector) => li.querySelector(selector);
    find(".bullet__name").textContent = name;
    find("[data-target]").textContent = `${cells[1]}${unit}`;
    const track = find(".bullet__track");
    // 帯の境目と目標の線は、その項目の目盛りの上端に対する割合で置く
    track.style.setProperty("--poor", `${(poor / max) * 100}%`);
    track.style.setProperty("--good", `${(good / max) * 100}%`);
    track.style.setProperty("--goal", `${(target / max) * 100}%`);
    for (let k = 0; k <= 4; k++) {
      const tick = find(".bullet__ticks").appendChild(document.createElement("span"));
      tick.style.setProperty("--k", k);
      tick.textContent = format((max / 4) * k, 0);
    }
    list.append(li);
    return {
      actual, target, max, unit,
      digits: (cells[0].split(".")[1] || "").length, // 表に書いた小数の桁にそろえる
      e: 0, // 伸びの進み(0〜1)
      done: false, // 札を出したか
      bar: find(".bullet__bar"),
      rate: find(".bullet__rate"),
      value: find("[data-actual]"),
      badge: find(".bullet__badge"),
    };
  });
  chart.classList.add("is-live"); // ここから先は、表は読み上げにだけ残す

  const draw = () => {
    for (const row of rows) {
      row.bar.style.width = `${(row.actual / row.max) * row.e * 100}%`;
      row.rate.textContent = `${Math.floor((row.actual / row.target) * row.e * 100 + 1e-9)}%`; // 届く前に 100% と出さない
      row.value.textContent = `${format(row.actual * row.e, row.digits)}${row.unit}`;
      // 棒の先が目標の線を越えた、その 1 回だけ札を弾ませる(伸びきってからではなく、届いた瞬間に出す)
      if (!row.done && row.actual * row.e >= row.target) {
        row.done = true;
        if (reduceMotion) gsap.set(row.badge, { autoAlpha: 1 });
        else gsap.fromTo(row.badge, { scale: 0.4, autoAlpha: 0 }, { scale: 1, autoAlpha: 1, duration: 0.3, ease: "back.out(3)" });
      }
    }
  };

  // 画面に入ったら 1 回だけ、上の行から順に伸ばす
  const play = () => {
    if (reduceMotion) {
      for (const row of rows) row.e = 1;
      draw();
      return;
    }
    const tl = gsap.timeline({ onUpdate: draw });
    rows.forEach((row, i) => tl.to(row, { e: 1, duration: 1.4, ease: "power3.out" }, i * 0.16));
  };
  const io = new IntersectionObserver(([entry]) => entry.isIntersecting && (io.disconnect(), play()), { rootMargin: "0px 0px -20%" });
  io.observe(chart);
});

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

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