デモ
デモを読み込めませんでした。
仕様
- 用途
- グラフ
- 動作
- 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 つを超えると順番を待つ時間が長くなるので、間を詰めるか、同じ時期の工程をまとめて伸ばしてください。今日の日付は、サーバーで今日の日付を入れて渡します。制作の進み具合を依頼主と共有するページや、イベント・募集の日程を知らせる節に向いています。
コード
<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>
.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; }
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);
});
});