CSS Gridでガントチャートを自作する方法
ライブラリ不使用・動くデモ付き

結論:日付軸はgrid-template-columns、バーはgrid-column

ガントチャートは「横軸に日付が並び、各タスクの帯(バー)が開始日から終了日まで伸びる」表です。CSS Gridで作るなら考え方はシンプルで、grid-template-columns で1日を1列とした日付軸を作り、各バーを grid-column: 開始日の列 / span 日数 で置くだけです。ライブラリは要りません。

実際に動くのが次のデモです。土日の色分けと今日線を入れた、そのまま業務画面に置ける最小構成にしています。

サイト公開プロジェクト

要件定義から公開まで5工程を、CSS Gridだけで並べています。横に長いときはスクロールします。

予定バー 土日 今日線

このデモは、次の3つを空のHTMLファイルに貼るだけで動きます。まず表示先の入れ物です。

<div class="cg-chart-wrap">
  <div class="cg-chart" id="cg-chart"></div>
</div>

次にスタイルです。列や行の数はJS側で決めるので、ここでは見た目だけを定義します。

.cg-chart-wrap { overflow-x: auto; }
.cg-chart {
  --cg-name-w: 116px;   /* タスク名の列幅 */
  --cg-day-w: 34px;     /* 1日ぶんの列幅 */
  display: grid;
  min-width: max-content;
  border: 1px solid #e5e7eb;
  font-size: 13px;
  color: #334155;
}
.cg-corner, .cg-month {
  display: flex; align-items: center; padding: 0 8px;
  background: #f4f7fb; font-weight: 700; color: #1a2332;
  border-right: 1px solid #d8e0ea; border-bottom: 1px solid #d8e0ea;
}
.cg-corner { position: sticky; left: 0; z-index: 6; }
.cg-day {
  display: flex; align-items: center; justify-content: center;
  border-right: 1px solid #eef2f7; border-bottom: 1px solid #d8e0ea; color: #64748b;
}
.cg-day--weekend { background: rgba(148,163,184,.14); color: #94a3b8; }
.cg-col { border-right: 1px solid #eef2f7; }
.cg-col--weekend { background: rgba(148,163,184,.14); }
.cg-lane { border-bottom: 1px solid #eef2f7; }
.cg-name {
  position: sticky; left: 0; z-index: 5;
  display: flex; align-items: center; padding: 0 10px;
  background: #fff; border-right: 1px solid #d8e0ea; border-bottom: 1px solid #eef2f7;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.cg-bar {
  align-self: center; z-index: 2; height: 24px; margin: 0 3px; padding: 0 8px;
  display: flex; align-items: center; background: #2b7fe8; border-radius: 6px; overflow: hidden;
}
.cg-bar span {
  font-size: 12px; font-weight: 600; color: #fff;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.cg-today { z-index: 3; pointer-events: none; border-left: 2px solid #e5484d; justify-self: start; }
.cg-today span {
  display: inline-block; margin-top: 2px; padding: 1px 6px;
  background: #e5484d; color: #fff; font-size: 11px; font-weight: 700; border-radius: 0 4px 4px 0;
}

最後にJavaScriptです。タスク配列と表示範囲を渡すと、日付軸とバーを組み立てます。心臓部は後ろのセクションで分解して説明します。

var DAY = 86400000;

var TASKS = [
  { name: '要件定義', start: '2026-08-03', end: '2026-08-07' },
  { name: '基本設計', start: '2026-08-06', end: '2026-08-13' },
  { name: '実装',     start: '2026-08-12', end: '2026-08-25' },
  { name: 'テスト',   start: '2026-08-24', end: '2026-08-28' },
  { name: 'リリース', start: '2026-08-31', end: '2026-09-01', color: '#16a34a' }
];
var RANGE_START = '2026-08-03';
var RANGE_END   = '2026-09-04';
var TODAY       = '2026-08-14';   // デモ用の固定「今日」。本番は new Date() でよい

// 'YYYY-MM-DD' をローカル0時のDateにする(文字列そのままのUTC解釈ずれを避ける)
function parseDate(s) {
  var p = s.split('-');
  return new Date(+p[0], +p[1] - 1, +p[2]);
}
// 日数差。時刻成分やDST(夏時間)の影響を避けるため round する
function diffDays(from, to) {
  return Math.round((to.getTime() - from.getTime()) / DAY);
}
function addDays(base, n) {
  return new Date(base.getFullYear(), base.getMonth(), base.getDate() + n);
}
// date がグリッドの何列目か(左のタスク名列ぶん +1 する)
function columnOf(rangeStart, date) {
  return 2 + diffDays(rangeStart, date);
}

function build(el) {
  var start = parseDate(RANGE_START);
  var end = parseDate(RANGE_END);
  var today = parseDate(TODAY);
  var totalDays = diffDays(start, end) + 1;

  // 列 = タスク名 + 日数ぶん / 行 = 月・日ヘッダー + タスク件数ぶん
  el.style.gridTemplateColumns = 'var(--cg-name-w) repeat(' + totalDays + ', var(--cg-day-w))';
  el.style.gridTemplateRows = '26px 30px repeat(' + TASKS.length + ', 40px)';

  var frag = document.createDocumentFragment();

  var corner = document.createElement('div');
  corner.className = 'cg-corner';
  corner.style.gridColumn = '1';
  corner.style.gridRow = '1 / 3';
  corner.textContent = '工程';
  frag.appendChild(corner);

  // 日ヘッダー・本体の列帯・月ヘッダーをまとめて作る
  var monthStartIdx = 0;
  for (var i = 0; i < totalDays; i++) {
    var d = addDays(start, i);
    var col = 2 + i;
    var weekend = (d.getDay() === 0 || d.getDay() === 6);

    var dayHead = document.createElement('div');
    dayHead.className = 'cg-day' + (weekend ? ' cg-day--weekend' : '');
    dayHead.style.gridColumn = col;
    dayHead.style.gridRow = '2';
    dayHead.textContent = d.getDate();
    frag.appendChild(dayHead);

    var colBg = document.createElement('div');
    colBg.className = 'cg-col' + (weekend ? ' cg-col--weekend' : '');
    colBg.style.gridColumn = col;
    colBg.style.gridRow = '3 / -1';
    frag.appendChild(colBg);

    // 月が変わる直前、または最終日に月ヘッダーを1枚描く
    if (i === totalDays - 1 || addDays(start, i + 1).getDate() === 1) {
      var mStart = addDays(start, monthStartIdx);
      var month = document.createElement('div');
      month.className = 'cg-month';
      month.style.gridColumn = (2 + monthStartIdx) + ' / span ' + (i - monthStartIdx + 1);
      month.style.gridRow = '1';
      month.textContent = mStart.getFullYear() + '年' + (mStart.getMonth() + 1) + '月';
      frag.appendChild(month);
      monthStartIdx = i + 1;
    }
  }

  // タスク行:横罫線・タスク名・バー
  TASKS.forEach(function (t, idx) {
    var row = 3 + idx;

    var lane = document.createElement('div');
    lane.className = 'cg-lane';
    lane.style.gridColumn = '2 / -1';
    lane.style.gridRow = row;
    frag.appendChild(lane);

    var name = document.createElement('div');
    name.className = 'cg-name';
    name.style.gridColumn = '1';
    name.style.gridRow = row;
    name.textContent = t.name;
    frag.appendChild(name);

    var s = parseDate(t.start);
    var e = parseDate(t.end);
    var bar = document.createElement('div');
    bar.className = 'cg-bar';
    bar.style.gridColumn = columnOf(start, s) + ' / span ' + (diffDays(s, e) + 1);
    bar.style.gridRow = row;
    if (t.color) { bar.style.background = t.color; }
    var label = document.createElement('span');
    label.textContent = t.name;
    bar.appendChild(label);
    frag.appendChild(bar);
  });

  // 今日線(範囲内のときだけ引く)
  var todayCol = columnOf(start, today);
  if (todayCol >= 2 && todayCol <= 1 + totalDays) {
    var line = document.createElement('div');
    line.className = 'cg-today';
    line.style.gridColumn = todayCol;
    line.style.gridRow = '1 / -1';
    var pill = document.createElement('span');
    pill.textContent = '今日';
    line.appendChild(pill);
    frag.appendChild(line);
  }

  el.innerHTML = '';
  el.appendChild(frag);
}

build(document.getElementById('cg-chart'));

心臓部:日付からグリッド列番号を求める

この自作ガントで唯一むずかしいのは「日付をグリッドの何列目に置くか」です。ここさえ関数にできれば、あとはバーもヘッダーも今日線も同じ計算で置けます。

基本は、表示範囲の開始日を1日目として、そこから何日離れているかを数えるだけです。左端にタスク名の列が1本あるので、その1列ぶんを足して列番号にします。

var DAY = 86400000; // 1日のミリ秒

function parseDate(s) {
  var p = s.split('-');
  return new Date(+p[0], +p[1] - 1, +p[2]);
}
function diffDays(from, to) {
  return Math.round((to.getTime() - from.getTime()) / DAY);
}
function columnOf(rangeStart, date) {
  return 2 + diffDays(rangeStart, date); // 2列目が範囲初日
}

バーを置くときは、開始日の列を求め、日数ぶんだけ列をまたがせます。終了日も含めたいので、日数差に1を足すのがポイントです。

var start = parseDate('2026-08-12');
var end   = parseDate('2026-08-25');

bar.style.gridColumn = columnOf(rangeStart, start) + ' / span ' + (diffDays(start, end) + 1);

日付は文字列から直接作らない

ここで見落としやすいのが日付の作り方です。new Date('2026-08-03') のように日付だけの文字列をDateに渡すと、その時刻はUTCの0時として解釈されます。日本のように協定世界時より進んだ地域では同じ日付になりますが、協定世界時より遅れた地域では1日前にずれます。parseDate のように年・月・日の数値からDateを組み立てれば、実行環境の0時に固定でき、このずれを避けられます。

差分を Math.round で丸めているのも同じ発想です。夏時間の切り替わりをまたぐと1日が23時間や25時間になり、単純な割り算では小数が出ます。四捨五入しておけば、日数としては正しい整数に収まります。

月をまたぐ2段ヘッダー

ヘッダーは上段に月、下段に日を並べる2段構成にします。下段は1日1列でそのまま置けます。上段の月は、その月が範囲内に含む日数ぶんだけ列をまたがせて1枚のセルにします。

日付を先頭から順に見ていき、「翌日が1日になる」または「最後の日に着いた」タイミングで、そこまでの1か月ぶんの月セルを描くと、月の境目でぴったり区切れます。

if (i === totalDays - 1 || addDays(start, i + 1).getDate() === 1) {
  var mStart = addDays(start, monthStartIdx);
  month.style.gridColumn = (2 + monthStartIdx) + ' / span ' + (i - monthStartIdx + 1);
  month.style.gridRow = '1';
  month.textContent = mStart.getFullYear() + '年' + (mStart.getMonth() + 1) + '月';
  monthStartIdx = i + 1; // 次の月の起点を記録
}

デモの範囲は8月3日から9月4日なので、上段には「2026年8月」と「2026年9月」の2枚が、それぞれの日数ぶんの幅で並びます。

今日線と土日の色分け

今日線

今日線は、今日の日付の列を求めて、その列の左端に縦線を引くだけです。ヘッダーからタスク行の最後までを縦につなぎたいので、行は 1 / -1 で全体をまたがせ、justify-self: start で列の左端に寄せます。

var todayCol = columnOf(start, today);
line.style.gridColumn = todayCol;
line.style.gridRow = '1 / -1'; // ヘッダーから最終行まで縦断

デモでは TODAY という固定の日付を「今日」として扱っています。日付を固定しないと、実際の今日が表示範囲の外に出たときに線が見えなくなるためです。自分の画面に組み込むときは new Date() に置き換えれば、その日の位置に線が動きます。

土日の色分け

土日は、列を作るときに曜日を調べて背景色のクラスを付けるだけです。getDay() は日曜が0、土曜が6を返すので、この2つを塗ります。ヘッダーの日付セルと、本体の縦帯の両方に同じ判定を使うと、色が縦にそろってカレンダーらしく見えます。

var weekend = (d.getDay() === 0 || d.getDay() === 6);
colBg.className = 'cg-col' + (weekend ? ' cg-col--weekend' : '');

進捗率を足す(発展)

表示だけなら以上で完成ですが、各タスクに進捗率を持たせて、バー内に済んだぶんの塗りを重ねることもできます。バーを position: relative にし、進捗ぶんの幅を持つ子要素を左から重ねます。

.cg-bar { position: relative; }
.cg-bar__fill {
  position: absolute; left: 0; top: 0; bottom: 0;
  background: rgba(0, 0, 0, 0.18);
  border-radius: 6px 0 0 6px;
}
// タスクに progress(0〜100)を持たせておく
var fill = document.createElement('span');
fill.className = 'cg-bar__fill';
fill.style.width = t.progress + '%';
bar.appendChild(fill);

ここから先、バーをドラッグして日付を変えたり、タスク間に依存線を引いたりし始めると、自作の手間が一気に増えます。その線引きを次にまとめます。

Frappe Ganttとどちらを使うか

CSS Gridの自作が向くのは「予定を表示できれば十分」なケースです。ドラッグでの日程変更や依存線、大量タスクの仮想スクロールまで欲しくなったら、ライブラリに任せたほうが早く安定します。判断の目安が次の表です。

観点 自作(CSS Grid) Frappe Gantt
実装量 表示だけなら数十行で済む CDN1行とタスク配列で動く
ドラッグで日程変更 自前実装が必要で手間が大きい 標準で対応
タスクの依存線 線の描画を自前で作る必要がある dependencies で表示できる
カスタマイズの自由度 高い。CSSで見た目を完全に握れる 用意されたテーマの範囲が中心
学習コスト CSS Gridの知識だけで足りる オプション仕様の把握が要る
依存・読み込み なし ライブラリを読み込む

表示だけ、タスク数が少ない、見た目を細部まで合わせたい、という条件なら自作で十分です。逆にドラッグ編集や依存線が要件に入ってきたら、動くサンプルを用意している「ガントチャート(Frappe Gantt)」から始めるのが近道です。

まとめ

CSS Gridのガントチャートは、日付を列番号に変換する関数さえ用意すれば、バーも月ヘッダーも今日線もその1本で置けます。日付を文字列から直接Dateにしない点と、日数差を丸めておく点を押さえれば、環境によるずれもありません。

ライブラリ版の実装は「ガントチャート(Frappe Gantt)」に、日本語化は「Frappe Ganttを日本語化して日付表示を設定する方法」、進捗率や依存線は「Frappe Ganttで進捗率とタスクの依存関係を表示する方法」にまとめています。要件に合わせて使い分けてください。