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

工程表のグラフの帯が順に伸び、今日の線が進んで済んだ分が濃く塗られる

デモ

操作:カーソルを重ねる。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。

仕様

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

しくみと調整

工程の行ごとに、始まりの日から終わりの日までの帯が、上の行から順に左から右へ伸びます。次の工程は前の工程の終わりの日から始まるので、帯は右下へ階段のように続き、前の帯の終わりから次の帯の頭へ点線が下ります。出そろうと、縦の「今日」の線が上から下りて、左の端から今日の日付まで進みます。線を越えた部分の帯が濃く塗られ、右の列の済んだ割合が数え上がります。値の高さを比べる棒グラフと違い、横は日付の目盛りで、伸びる動きのあとにもう一段、時間の進みを見せる動きが続きます。

1 本の帯は 0.5 秒の power2.out で伸び、次の帯は 0.46 秒後に始めます。前の帯がほぼ伸びきってから次が始まるので、工程のつながりが順に読めます。今日の線は 2 秒の power1.inOut で進み、下の札の日付も線の位置の日に合わせて進んで、止まったところで今日の日付になります。帯の位置と長さは日付の差に 1 日の幅をかけて出すだけなので、期間や目盛りを変えても計算は変わりません。

日付は datetime や data 属性に 2026-10-01 の形で書き、Date.parse の結果を 1 日のミリ秒で割って日数にします。終わりの日を含めて数えるので、終わりの日数に 1 を足してから長さを出してください。足し忘れると、帯が 1 日ぶん短くなり、済んだ割合もずれます。札の日付を書き出すときは、timeZone を UTC にします。そのままだと、時差のある地域で前の日の日付が出ます。

工程・期間・済んだ割合は表としてページに書き、スクリプトはその表を読んで行を組みます。組んだあとの表は見えなくするだけにして読み上げに残します。行に重ねると地が明るくなり、帯の横にその工程の期間が出ます。枠は焦点を受け、上下の矢印キーで工程を移ると、期間と済んだ割合を見えない status に書きます。動きを減らす設定では、帯も今日の線も出そろった状態で置きます。

つまみは、帯が伸びる時間と間、今日の線が進む時間、行の高さと帯の太さです。前の工程の終わりの日から始まらない工程は、点線でつながずに帯だけを伸ばします。工程が 8 つを超えると順番を待つ時間が長くなるので、間を詰めるか、同じ時期の工程をまとめて伸ばしてください。今日の日付は、サーバーで今日の日付を入れて渡します。制作の進み具合を依頼主と共有するページや、イベント・募集の日程を知らせる節に向いています。

コード

HTML
<figure class="gantt" data-start="2026-10-01" data-end="2026-11-26">
  <figcaption class="gantt__caption">
    <span class="gantt__eyebrow">Schedule</span>
    <span class="gantt__title">サイト公開までの工程</span>
    <span class="gantt__note"><span>2026年10月〜11月・</span><span>1マス1週間</span></span>
  </figcaption>
  <ul class="gantt__legend" aria-hidden="true">
    <li class="gantt__key gantt__key--plan">予定</li>
    <li class="gantt__key gantt__key--done">済み</li>
  </ul>

  <!-- 目盛りの文字は HTML に置き、行はスクリプトが表から組む。枠は焦点を受け、上下の矢印キーで工程を移れる -->
  <div class="gantt__chart" tabindex="0" role="group" aria-label="工程表。上下の矢印キーで工程を移れます">
    <div class="gantt__scale" aria-hidden="true">
      <ul class="gantt__months">
        <li style="--d: 0">10月</li>
        <li style="--d: 31">11月</li>
      </ul>
      <ul class="gantt__days">
        <li style="--d: 0">1日</li>
        <li style="--d: 7">8日</li>
        <li style="--d: 14">15日</li>
        <li style="--d: 21">22日</li>
        <li style="--d: 28">29日</li>
        <li style="--d: 35">5日</li>
        <li style="--d: 42">12日</li>
        <li style="--d: 49">19日</li>
      </ul>
    </div>
    <span class="gantt__rate-head" aria-hidden="true">済み</span>
    <ol class="gantt__rows" aria-hidden="true"></ol>
    <!-- 今日の日付は datetime に書く(サーバーで今日の日付を入れる) -->
    <p class="gantt__now" aria-hidden="true"><span class="gantt__chip">今日 <time datetime="2026-11-03">11月3日</time></span></p>
  </div>
  <p class="gantt__status" role="status"></p>

  <!-- 値はこの表が正。data-start と data-end は始まりと終わりの日(終わりの日を含む) -->
  <table class="gantt__table">
    <caption>サイト公開までの工程(2026年11月3日時点)</caption>
    <thead>
      <tr><th scope="col">工程</th><th scope="col">期間</th><th scope="col">済み</th></tr>
    </thead>
    <tbody>
      <tr data-start="2026-10-01" data-end="2026-10-10"><th scope="row">要件定義</th><td>10月1〜10日・10日間</td><td>100%</td></tr>
      <tr data-start="2026-10-11" data-end="2026-10-24"><th scope="row">デザイン</th><td>10月11〜24日・14日間</td><td>100%</td></tr>
      <tr data-start="2026-10-25" data-end="2026-11-11"><th scope="row">実装</th><td>10月25日〜11月11日・18日間</td><td>50%</td></tr>
      <tr data-start="2026-11-12" data-end="2026-11-19"><th scope="row">検証</th><td>11月12〜19日・8日間</td><td>0%</td></tr>
      <tr data-start="2026-11-20" data-end="2026-11-23"><th scope="row">公開</th><td>11月20〜23日・4日間</td><td>0%</td></tr>
    </tbody>
  </table>
</figure>

CSS
.gantt {
  --bg: #f2f1ed;
  --surface: #ffffff;
  --ink: #262626;
  --mute: #898989;
  --mute-small: #6b6b6b; /* 16px 未満の文字 */
  --line: rgb(38 38 38 / 0.12);
  --plan: #98adc0; /* 予定の帯 */
  --done: #475c6a; /* 済んだ分 */
  --name: 120px; /* 工程の名前の列 */
  --rate: 64px; /* 済んだ割合の列 */
  --row: 60px; /* 行の高さ */
  --bar: 24px; /* 帯の太さ */
  --weeks: linear-gradient(to right, var(--line) 1px, transparent 1px) 0 0 / calc(700% / var(--days)) 100%; /* 週ごとの縦線 */
  position: relative;
  display: grid;
  gap: 24px;
  overflow: clip; /* 読み上げ用に隠した表が、狭い画面で横にはみ出さないように */
  margin: 0;
  padding: 48px clamp(16px, 5vw, 96px) 72px;
  background: var(--bg);
  color: var(--ink);
  font-variant-numeric: tabular-nums;
  grid-template-columns: minmax(0, 1fr) auto; /* 見出しの右に凡例 */
}
.gantt > :not(.gantt__caption, .gantt__legend) { grid-column: 1 / -1; }

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

.gantt__legend { display: flex; gap: 24px; align-self: end; margin: 0; padding: 0; color: var(--mute-small); font-size: 13px; list-style: none; }
.gantt__key::before { content: ""; display: inline-block; width: 14px; height: 14px; margin-right: 8px; border-radius: 2px; background: var(--plan); vertical-align: -2px; }
.gantt__key--done::before { background: var(--done); }

/* 名前・帯・割合の 3 列。行は subgrid で列をそろえる */
.gantt__chart { position: relative; display: grid; grid-template-columns: var(--name) minmax(0, 1fr) var(--rate); }
.gantt__chart:focus-visible { outline: 2px solid var(--ink); outline-offset: 8px; }
.gantt__scale { grid-column: 2; position: relative; height: 64px; }
.gantt__months, .gantt__days { margin: 0; padding: 0; list-style: none; }
.gantt__scale li { position: absolute; left: calc(var(--d) / var(--days) * 100%); padding-left: 6px; white-space: nowrap; }
.gantt__months li { top: 4px; font-size: 13px; font-weight: 700; letter-spacing: 0.04em; }
.gantt__months li + li { border-left: 1px solid var(--mute); }
.gantt__days { position: absolute; inset: auto 0 0; height: 36px; background: var(--weeks); }
.gantt__days li { bottom: 12px; color: var(--mute-small); font-size: 13px; }
.gantt__rate-head { grid-column: 3; align-self: end; padding-bottom: 12px; color: var(--mute-small); font-size: 13px; text-align: right; }

.gantt__rows { grid-column: 1 / -1; display: grid; grid-template-columns: subgrid; margin: 0; padding: 0; border-top: 1px solid var(--line); list-style: none; }
.gantt__row { box-sizing: border-box; position: relative; grid-column: 1 / -1; display: grid; grid-template-columns: subgrid; align-items: center; height: var(--row); border-bottom: 1px solid var(--line); }
/* 重ねた行の地。左右に 8px はみ出す */
.gantt__row::before { content: ""; position: absolute; inset: 2px -8px; border-radius: 4px; background: var(--surface); opacity: 0; transition: opacity 0.25s; }
.gantt__row.is-active::before { opacity: 1; }
.gantt__name { position: relative; padding-left: 16px; font-size: 15px; font-weight: 600; letter-spacing: 0.04em; }
.gantt__row.is-active .gantt__name { font-weight: 700; }
.gantt__rate { position: relative; color: var(--mute-small); font-size: 15px; font-weight: 700; text-align: right; opacity: var(--drop, 0); }
.gantt__rate.is-started { color: var(--ink); }

/* 帯の列。週ごとの縦線は背景で引く */
.gantt__track { position: relative; height: 100%; background: var(--weeks); box-shadow: inset -1px 0 var(--line); }
.gantt__plan,
.gantt__done { position: absolute; top: calc((var(--row) - var(--bar)) / 2); left: calc(var(--s) * 100%); height: var(--bar); border-radius: 3px; background: var(--plan); }
.gantt__done { background: var(--done); }
/* 前の帯の終わりから、この帯の頭へ下りる線 */
.gantt__plan::before { content: ""; position: absolute; top: calc(var(--bar) - var(--row)); left: 0; height: calc((var(--row) - var(--bar)) * var(--k, 0)); border-left: 1px dashed var(--done); }
.gantt__period { position: absolute; z-index: 1; top: 50%; left: calc(var(--e) * 100% + 12px); padding: 2px 4px; margin: 0 -4px; background: var(--surface); font-size: 13px; letter-spacing: 0.02em; white-space: nowrap; opacity: 0; transform: translateY(-50%); transition: opacity 0.25s; }
.gantt__period.is-left { left: auto; right: calc((1 - var(--s)) * 100% + 12px); }
.gantt__row.is-active .gantt__period { opacity: 1; }

/* 今日の線。帯の列の上に重ね、札は下の端に付ける */
.gantt__now { position: absolute; top: 64px; bottom: 0; left: var(--name); right: var(--rate); margin: 0; pointer-events: none; }
.gantt__now::before { content: ""; position: absolute; top: 0; left: calc(var(--today, 0) * 100%); width: 2px; height: calc(var(--drop, 0) * (100% + 8px)); margin-left: -1px; background: var(--ink); opacity: var(--shown, 0); }
.gantt__chip { position: absolute; top: calc(100% + 8px); left: calc(var(--today, 0) * 100%); padding: 6px 14px; border-radius: 999px; background: var(--ink); color: var(--surface); font-size: 13px; font-weight: 700; letter-spacing: 0.06em; white-space: nowrap; opacity: var(--shown, 0); transform: translateX(-50%); }

/* 狭い画面では列を詰め、期間は帯の下に出す。日付は 1 つおきにする */
@media (max-width: 640px) {
  .gantt { --name: 76px; --rate: 48px; --row: 64px; --bar: 20px; grid-template-columns: minmax(0, 1fr); }
  .gantt__name { padding-left: 8px; font-size: 13px; }
  .gantt__rate { font-size: 13px; }
  .gantt__days li:nth-child(even) { display: none; }
  .gantt__plan, .gantt__done { top: 12px; }
  .gantt__period,
  .gantt__period.is-left { top: auto; bottom: 6px; left: 0; right: auto; font-size: 12px; transform: none; }
}

@media (prefers-reduced-motion: reduce) {
  .gantt__row::before, .gantt__period { transition: none; }
}

/* 読み上げだけに届ける。表は、スクリプトが行を組むまで見えたまま残す */
.gantt__status,
.gantt.is-live .gantt__table { position: absolute; width: 1px; height: 1px; margin: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

.gantt__table { width: 100%; border-spacing: 0; }
.gantt__table caption { padding-bottom: 12px; color: var(--mute-small); font-size: 13px; text-align: left; }
.gantt__table th, .gantt__table td { padding: 10px 12px; border-top: 1px solid var(--line); font-size: 14px; text-align: left; }
.gantt__table td:last-child { text-align: right; }
.gantt__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 DAY = 86400000;
const make = (tag, className, parent) => parent.appendChild(Object.assign(document.createElement(tag), { className }));

document.querySelectorAll(".gantt").forEach((chart) => {
  const start = Date.parse(chart.dataset.start);
  const dayOf = (text) => (Date.parse(text) - start) / DAY; // 範囲の頭からの日数
  const days = dayOf(chart.dataset.end);
  const grid = chart.querySelector(".gantt__chart");
  const list = chart.querySelector(".gantt__rows");
  const status = chart.querySelector(".gantt__status");
  const time = chart.querySelector(".gantt__now time");
  const rateHead = chart.querySelector(".gantt__rate-head").textContent;
  const today = dayOf(time.dateTime);
  const format = new Intl.DateTimeFormat("ja-JP", { month: "long", day: "numeric", timeZone: "UTC" });
  const rateAt = (row, day) => `${Math.round(gsap.utils.clamp(0, 1, (day - row.s) / (row.e - row.s)) * 100)}%`;
  chart.style.setProperty("--days", days);
  // 値は表が正。行ごとに名前・帯・期間・割合を組む
  let last = null;
  const rows = [...chart.querySelectorAll("tbody tr")].map((tr) => {
    const [s, e] = [dayOf(tr.dataset.start), dayOf(tr.dataset.end) + 1]; // 終わりの日を含む
    const row = { s, e, name: tr.cells[0].textContent, period: tr.cells[1].textContent, g: 0, k: 0 };
    row.li = make("li", "gantt__row", list);
    row.li.style.cssText = `--s: ${s / days}; --e: ${e / days}`;
    make("span", "gantt__name", row.li).textContent = row.name;
    const track = make("span", "gantt__track", row.li);
    row.plan = make("span", "gantt__plan", track);
    row.done = make("span", "gantt__done", track);
    const period = make("span", "gantt__period", track);
    period.textContent = row.period;
    period.classList.toggle("is-left", e / days > 0.76); // 右の列にかかるときは、帯の左に出す
    row.rate = make("span", "gantt__rate", row.li);
    row.link = s === last; // 前の工程の終わりから始まる工程だけ、線でつなぐ
    row.final = tr.cells[2].textContent = rateAt(row, today); // 表には今日の時点の割合を書く
    last = e;
    return row;
  });
  chart.classList.add("is-live"); // ここから先は、表は読み上げにだけ残す

  const now = { day: 0, drop: 0 };
  const draw = () => {
    for (const row of rows) {
      const w = (row.e - row.s) * row.g;
      row.plan.style.width = `${(w / days) * 100}%`;
      // 今日の線より左の部分だけ濃く塗る(伸びきっていない帯の先は越えない)
      row.done.style.width = `${(Math.min(w, Math.max(0, now.day - row.s)) / days) * 100}%`;
      if (row.link) row.plan.style.setProperty("--k", row.k);
      row.rate.textContent = rateAt(row, now.day);
      row.rate.classList.toggle("is-started", now.day > row.s);
    }
    chart.style.setProperty("--today", now.day / days);
    chart.style.setProperty("--drop", now.drop);
    chart.style.setProperty("--shown", Math.min(1, now.drop * 3));
    time.textContent = format.format(start + Math.floor(now.day + 1e-6) * DAY); // 札の日付は線の位置の日
  };
  draw();

  // 画面に入ったら 1 回だけ。帯が上から順に伸び、出そろったら今日の線が下りて今日まで進む
  const play = () => {
    const tl = gsap.timeline({ onUpdate: draw });
    rows.forEach((row, i) => {
      tl.to(row, { g: 1, duration: 0.5, ease: "power2.out" }, 0.46 * i);
      if (row.link) tl.to(row, { k: 1, duration: 0.14, ease: "power2.out" }, 0.46 * i - 0.14);
    });
    tl.to(now, { drop: 1, duration: 0.3, ease: "power2.out" }, 0.46 * rows.length + 0.2)
      .to(now, { day: today, duration: 2, ease: "power1.inOut" }, ">0.05");
    if (reduceMotion) tl.progress(1);
  };
  const io = new IntersectionObserver(([entry]) => entry.isIntersecting && (io.disconnect(), play()), { rootMargin: "0px 0px -20%" });
  io.observe(chart);

  // 重ねた行の地を明るくし、帯の横に期間を出す
  let active = -1;
  const show = (index) => {
    if (index === active) return;
    active = index;
    rows.forEach((row, i) => row.li.classList.toggle("is-active", i === index));
    const row = rows[index];
    status.textContent = row ? `${row.name} ${row.period} ${rateHead}${row.final}` : "";
  };
  rows.forEach((row, i) => {
    row.li.addEventListener("pointerenter", (e) => e.pointerType !== "touch" && show(i));
    row.li.addEventListener("pointerdown", () => show(i)); // 指で触れたときも、その行を出す
  });
  list.addEventListener("pointerleave", (e) => e.pointerType !== "touch" && show(-1));
  // キーボードでは最初の工程から出し、上下の矢印キーで移る。Esc で閉じる
  grid.addEventListener("focus", () => grid.matches(":focus-visible") && show(Math.max(active, 0)));
  grid.addEventListener("blur", () => show(-1));
  grid.addEventListener("keydown", (e) => {
    const step = { ArrowDown: 1, ArrowRight: 1, ArrowUp: -1, ArrowLeft: -1 }[e.key];
    if (!step) return e.key === "Escape" ? show(-1) : undefined;
    e.preventDefault();
    show(active < 0 ? 0 : (active + step + rows.length) % rows.length);
  });
});

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

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