Reactで、予定を横棒で並べるガントチャートを作る方法
予定の横棒は、1日を1列にした CSS Grid の上に、開始日と終了日から求めた列の番号を grid-column で指定して置くと、ピクセルの計算をせずに描けます。日付は Date.UTC で「1970年1月1日から何日たったか」という整数に直して数えると、ブラウザのタイムゾーンで列がずれません。表示している期間からはみ出す予定は、期間の端で切り落とします。
検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で Chromium を操作して、棒の位置や作業名の列の位置を確かめました。Firefox と Safari、スマートフォンの実機、スクリーンリーダーでの読み上げは試していません。棒をドラッグして日付を変える操作や、予定どうしの依存関係の矢印は扱いません。

日付を、1970年1月1日からの日数に直す
棒を置く列は「表示期間の初日から何日後か」で決まります。そこで、'2026-11-02' のような日付の文字列を、1970年1月1日から数えた日数に直す関数を用意しました。
const DAY = 24 * 60 * 60 * 1000;
// 'YYYY-MM-DD' を、1970-01-01 から数えた日数にする。
// Date.UTC で計算するので、ブラウザのタイムゾーンや夏時間の影響を受けない
export function toDay(date: string): number {
const [y, m, d] = date.split('-').map(Number);
return Date.UTC(y, m - 1, d) / DAY;
}
// 日数から、見出しに出す月・日・曜日(0 が日曜)を取り出す
export function dayParts(day: number) {
const dt = new Date(day * DAY);
return { month: dt.getUTCMonth() + 1, date: dt.getUTCDate(), weekday: dt.getUTCDay() };
}
Date.UTC は、年・月・日を UTC として扱い、1970年1月1日 00:00 UTC からのミリ秒数を返します。MDN の Date の説明にあるとおり、この数え方はうるう秒を無視するので、0時どうしの差は1日のミリ秒数でちょうど割り切れます。
ローカル時刻の new Date(年, 月, 日) どうしの差で日数を求めると、夏時間の切り替わる日をまたいだときに整数になりません。この落とし穴は、開始日と終了日を選ぶ入力欄の記事で詳しく確かめました。ここでは、ガントチャートで問題になる点に絞ります。Node.js でタイムゾーンを America/New_York にして比べた結果です。
$ TZ=America/New_York node dst.mjs
TZ = America/New_York
2026-03-07 → 2026-03-09: ローカル時刻の差 1.9583333333333333(Math.floor 1、Math.ceil 2)、Date.UTC の差 2
2026-10-31 → 2026-11-02: ローカル時刻の差 2.0416666666666665(Math.floor 2、Math.ceil 3)、Date.UTC の差 2
new Date('2026-11-02') の getDate() 1、getDay() 0、getUTCDate() 2、getUTCDay() 1
2つの日付の差は、本来どちらも 2 です。ところが 10/31 と 11/2 の差は 2.041…日になり、Math.ceil で切り上げると 3 になります。この記事のように最終日を含めて1を足すと、10/31〜11/2 の3列のはずの棒が4列に伸びます。切り捨てにしても、3/7 と 3/9 の差は 1.958…日なので 1 になり、棒が1列足りなくなります。Date.UTC で数えた差は、どちらも 2 でした。同じスクリプトをタイムゾーン Asia/Tokyo で実行すると、どの計算も 2 になりました。
最後の行は別の落とし穴です。MDN の Date の説明にあるとおり、'2026-11-02' のように日付だけの文字列は UTC の0時として読まれます。それを getDate() や getDay() のようにローカル時刻で読むと、America/New_York では前の日の「1日」「日曜」になりました。dayParts が getUTCDate() と getUTCDay() を使っているのはこのためです。
1日を1列にしたグリッドに棒を置く
ガントチャート本体のコンポーネントです。表示期間の初日(firstDay)と日数(days)を受け取り、1列目を作業名、2列目から先を1日ずつの列にしたグリッドを作ります。
import { Fragment } from 'react';
import type { CSSProperties } from 'react';
import { dayParts, toDay } from './dates';
// end は最終日(その日を含む)
export type Task = { id: string; name: string; start: string; end: string };
type Props = { tasks: Task[]; firstDay: number; days: number };
const WEEKDAYS = ['日', '月', '火', '水', '木', '金', '土'];
export function Gantt({ tasks, firstDay, days }: Props) {
const lastDay = firstDay + days - 1;
const columns = Array.from({ length: days }, (_, i) => firstDay + i);
const grid: CSSProperties = {
// 1列目は作業名、2列目からが1日ずつの列
gridTemplateColumns: `8em repeat(${days}, minmax(32px, 1fr))`,
gridTemplateRows: `auto repeat(${tasks.length}, 40px)`,
};
return (
<div className="gantt-scroll">
<div className="gantt" style={grid}>
<div className="corner" style={{ gridColumn: 1, gridRow: 1 }}>作業</div>
{columns.map((day, i) => {
const { month, date, weekday } = dayParts(day);
const weekend = weekday === 0 || weekday === 6;
return (
<Fragment key={day}>
<div className={weekend ? 'day weekend' : 'day'} style={{ gridColumn: i + 2, gridRow: 1 }}>
{i === 0 || date === 1 ? `${month}/${date}` : date}
<br />
{WEEKDAYS[weekday]}
</div>
{weekend && <div className="weekend-bg" style={{ gridColumn: i + 2, gridRow: '2 / -1' }} />}
</Fragment>
);
})}
{tasks.map((task, r) => {
const row = r + 2;
const start = toDay(task.start);
const end = toDay(task.end);
// 表示している期間からはみ出す部分を切り落とす
const from = Math.max(start, firstDay);
const to = Math.min(end, lastDay);
const cut = [from > start && 'cut-start', to < end && 'cut-end'].filter(Boolean).join(' ');
return (
<Fragment key={task.id}>
<div className="row-line" style={{ gridColumn: '1 / -1', gridRow: row }} />
<div className="name" style={{ gridColumn: 1, gridRow: row }}>{task.name}</div>
{from <= to && (
<div
className={`bar ${cut}`.trim()}
style={{ gridColumn: `${from - firstDay + 2} / ${to - firstDay + 3}`, gridRow: row }}
title={`${task.name}:${task.start}〜${task.end}`}
>
{end - start + 1}日
</div>
)}
</Fragment>
);
})}
</div>
</div>
);
}
grid-column には、列の番号ではなく列と列の間の線の番号を「始まりの線 / 終わりの線」の形で書きます。期間の初日の列は2列目なので、その左の線は 2 です。そのため始まりの線は「初日から何日後か(初日なら 0)+ 2」になります。終わりの線は、最終日の列の右の線なので、さらに1を足して「+ 3」です。end を最終日そのもの(その日を含む)にしておくと、1日だけの予定でも start と end が同じ日付になり、データが読みやすくなります。
土日の列の色は、棒とは別の要素を gridRow: '2 / -1' で見出しの下から最後の行まで縦に伸ばして付けています。MDN の線を使った配置のガイドにあるとおり、-1 が指すのは明示的なグリッドの最後の線です。行の数を gridTemplateRows で決めているので、-1 が最後の予定の行の下の線になります。
同じマスに置いた要素は重なります。MDN のグリッドの基本のガイドにあるとおり、z-index を指定しなければ、ソースコードで後に書いた要素が上に描かれます。土日の背景、行の罫線、棒の順に書いているので、棒が背景の上に出ます。
1つの予定から複数の要素を返すので、map の中では <> ではなく <Fragment key={...}> を使います。React の Fragment のリファレンスにあるとおり、key を渡せるのは Fragment と明示して書いたときだけです。
表示期間を週ごとに動かす部分は、表示期間の初日を toDay の値(日数)で state に持ち、ボタンで7ずつ増やしたり減らしたりするだけです。
import { useState } from 'react';
import { Gantt } from './Gantt';
import type { Task } from './Gantt';
import { dayParts, toDay } from './dates';
const tasks: Task[] = [
{ id: 'req', name: '要件の整理', start: '2026-11-02', end: '2026-11-04' },
{ id: 'design', name: 'デザイン', start: '2026-11-04', end: '2026-11-10' },
{ id: 'impl', name: '実装', start: '2026-11-09', end: '2026-11-20' },
{ id: 'test', name: 'テスト', start: '2026-11-16', end: '2026-11-24' },
{ id: 'release', name: '公開準備', start: '2026-11-23', end: '2026-11-25' },
{ id: 'retro', name: '振り返り', start: '2026-11-27', end: '2026-11-27' },
];
const DAYS = 14;
const label = (day: number) => {
const { month, date } = dayParts(day);
return `${month}/${date}`;
};
export function App() {
const [firstDay, setFirstDay] = useState(() => toDay('2026-11-02'));
return (
<main>
<div className="toolbar">
<button onClick={() => setFirstDay((d) => d - 7)}>‹ 前の週</button>
<span className="range">{label(firstDay)}〜{label(firstDay + DAYS - 1)}</span>
<button onClick={() => setFirstDay((d) => d + 7)}>次の週 ›</button>
</div>
<Gantt tasks={tasks} firstDay={firstDay} days={DAYS} />
</main>
);
}
期間からはみ出す予定は、端で切り落とす
表示期間は14日なので、長い予定は期間の外へはみ出します。Math.max(start, firstDay) と Math.min(end, lastDay) で期間の中に収まる部分だけを残し、残りがなければ(from > to)棒を出しません。棒は、左右に 2px の余白を空けた、角の丸い水色の帯にしています。
.bar {
align-self: center;
height: 24px;
margin: 0 2px;
border-radius: 6px;
background: #00c8e8;
color: #1f2a30;
font-size: 12px;
line-height: 24px;
text-align: center;
white-space: nowrap;
overflow: hidden;
}
切り落とした側には cut-start や cut-end のクラスを付け、その側の角の丸みと余白をなくします。
/* 期間の外へ続いている側は、角を丸めず端まで伸ばす */
.bar.cut-start { margin-left: 0; border-top-left-radius: 0; border-bottom-left-radius: 0; }
.bar.cut-end { margin-right: 0; border-top-right-radius: 0; border-bottom-right-radius: 0; }
棒が期間の端の線までまっすぐ伸びるので、期間の外へ続いていることが見て分かります。
Playwright で、最初の表示と「次の週」を押した後の棒を調べました。日付は、棒の左右の端がどの日の列にあるかを、見出しの文字から読んだものです。
[最初の表示] 範囲 11/2〜11/15
要件の整理: grid-column 2 / 5, 11/2(月)〜4(水), class="bar", 文字「3日」
デザイン: grid-column 4 / 11, 4(水)〜10(火), class="bar", 文字「7日」
実装: grid-column 9 / 16, 9(月)〜15(日), class="bar cut-end", 文字「12日」
土日の列 7土 8日 14土 15日
[次の週を1回押したあと] 範囲 11/9〜11/22
デザイン: grid-column 2 / 4, 11/9(月)〜10(火), class="bar cut-start", 文字「7日」
実装: grid-column 2 / 14, 11/9(月)〜20(金), class="bar", 文字「12日」
テスト: grid-column 9 / 16, 16(月)〜22(日), class="bar cut-end", 文字「9日」
[次の週を2回押したあと] 範囲 11/16〜11/29
実装: grid-column 2 / 7, 11/16(月)〜20(金), class="bar cut-start", 文字「12日」
テスト: grid-column 2 / 11, 11/16(月)〜24(火), class="bar", 文字「9日」
公開準備: grid-column 9 / 12, 23(月)〜25(水), class="bar", 文字「3日」
振り返り: grid-column 13 / 14, 27(金)〜27(金), class="bar", 文字「1日」
最初の表示では、11/20 までの「実装」が 11/15 の列で切れて cut-end になり、11/16 から始まる「テスト」は棒が出ていません。1回押した後は、11/4 から始まる「デザイン」が 11/9 の列から始まって cut-start になりました。棒の中の「7日」「12日」は予定の全体の日数で、切り落とした後の長さではありません。見えている長さと数字が違うので、期間の外へ続いていることを数字でも伝えられます。
Playwright でブラウザのタイムゾーン(timezoneId)を Asia/Tokyo、America/New_York、Pacific/Honolulu にして同じページを開いても、先頭の見出しは 11/2(月)、3(火)、4(水)、棒の grid-column は 2/5 4/11 9/16 で、どれも同じでした。
作業名の列を、横にスクロールしても残す
スマートフォンの幅では14日分の列が入りきらないので、外側の .gantt-scroll を overflow-x: auto にして横にスクロールさせます。作業名の列は position: sticky と left: 0 で左端に残します。sticky の基本的な動きは、スクロールすると上に固定されるヘッダーの記事で縦方向について確かめています。ここでは、グリッドに特有の点を扱います。
.gantt-scroll { overflow-x: auto; border: 1px solid #9aa5ab; }
.gantt {
display: grid;
min-width: max-content; /* 幅を列の合計に合わせる(作業名の sticky が途中で外れないように) */
font-size: 13px;
}
.corner, .day { padding: 4px 0; border-bottom: 1px solid #9aa5ab; text-align: center; line-height: 1.3; }
.day.weekend { color: #067a98; }
.weekend-bg { background: #eefafd; }
.row-line { border-bottom: 1px solid #e3e8ea; }
.corner, .name {
position: sticky; /* 横にスクロールしても、作業名は左端に残す */
left: 0;
z-index: 1;
background: #fff;
border-right: 1px solid #9aa5ab;
}
ここで欠かせないのが .gantt の min-width: max-content です。これがないと、.gantt の幅はスクロール枠と同じになり、列だけがその外へはみ出します。MDN の position の説明にあるとおり、sticky の要素は包含ブロックの反対の端が来るまでしか留まりません。グリッドの中の作業名にとっては .gantt の幅が限界になり、そこを過ぎると一緒に流れていきます。
横幅 360px の画面で、表示する日数を 14 日と 31 日にして、min-width: max-content があるときとないとき(min-width: auto で上書き)を比べました。「scrollLeft→作業名の左端」は、スクロールした量と、そのときの作業名の左端がスクロール枠の左から何 px にあるかです。
[14日、min-width: max-content あり] 枠の幅 326px、中身の幅 552px、.gantt の幅 552px。scrollLeft→作業名の左端: 0→1, 200→1, 226→1, 226→1
[14日、min-width: max-content なし] 枠の幅 326px、中身の幅 552px、.gantt の幅 326px。scrollLeft→作業名の左端: 0→1, 200→1, 226→-3, 226→-3
[31日、min-width: max-content あり] 枠の幅 326px、中身の幅 1096px、.gantt の幅 1096px。scrollLeft→作業名の左端: 0→1, 200→1, 400→1, 770→1
[31日、min-width: max-content なし] 枠の幅 326px、中身の幅 1096px、.gantt の幅 326px。scrollLeft→作業名の左端: 0→1, 200→1, 400→-177, 770→-547
スクロールの量には 0・200・400・99999 を指定しました。14 日では 226px までしか動かないので、後ろの2つは 226 になっています。min-width: max-content があれば、どこまでスクロールしても作業名の左端は 1px の位置に残りました。ないときは、14 日では最後までスクロールしたときに 4px ずれる程度でしたが、31 日では 400px スクロールした時点で -177px と、作業名が枠の外へ流れていきました。日数が少ないうちはずれが小さく、気づきにくい違いです。表示する日数を後から増やしても作業名が流れないように、最初から入れておきます。





