Reactで、GitHubのような草のカレンダーを作る方法
GitHub のプロフィールにある「草」のカレンダーは、縦の7行が曜日、横の列が週になった表です。日付を「1970年1月1日から何日目か」という整数に直せば、マスを置く列は「左端の日曜日から何週後か」、行は曜日で決まります。あとは CSS Grid の grid-column と grid-row にその番号を渡すだけで、1年分のマスが並びます。
ライブラリを使わずに Heatmap というコンポーネントを作り、件数による色の濃さ、月の見出し、横にスクロールしても残る曜日の列、マスを選んで件数を表示する操作までを確かめました。データは、シードを固定した疑似乱数で作った2026年の見本です。
検証環境は 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、スマートフォンの実機、スクリーンリーダーでの読み上げは試していません(ARIA の属性が付いていることは確かめましたが、実際にどう読まれるかは分かりません)。GitHub の実際の画面が、どのような計算で色の段階を決めているかは調べていません。

マスの列と行を、日付から求める
マスの位置を決めるために、'2026-01-01' のような文字列を、1970年1月1日から数えた日数に直す関数を用意しました。Date.UTC で計算し、曜日も getUTCDay() で取り出すので、ブラウザのタイムゾーンに左右されません。
const DAY = 24 * 60 * 60 * 1000;
// 'YYYY-MM-DD' を、1970-01-01 から数えた日数にする(タイムゾーンの影響を受けない)
export function toDay(date: string): number {
const [y, m, d] = date.split('-').map(Number);
return Date.UTC(y, m - 1, d) / DAY;
}
// 日数から 'YYYY-MM-DD' に戻す
export function toDateString(day: number): string {
return new Date(day * DAY).toISOString().slice(0, 10);
}
// 日数から月・日・曜日(0 が日曜)を取り出す
export function dayParts(day: number) {
const dt = new Date(day * DAY);
return { month: dt.getUTCMonth() + 1, date: dt.getUTCDate(), weekday: dt.getUTCDay() };
}
列の番号は、範囲の最初の日を含む週の日曜日(firstSunday)から何日離れているかを7で割って求めます。1列目は曜日の見出しに使うので、週の列は2から始まります。行は、1行目を月の見出しにして、日曜を2行目、土曜を8行目にしました(weekday + 2)。
const firstSunday = first - dayParts(first).weekday;
const columnOf = (day: number) => Math.floor((day - firstSunday) / 7) + 2;
2026年1月1日は木曜日(weekday が 4)なので、firstSunday はその4日前の2025年12月28日(日)になり、1月1日は木曜の行に入ります。Chromium で表示し、各マスに指定された grid-column と grid-row の値と段階を読み取って、読みやすく並べた結果です。
1月1日 → grid-column 2 / grid-row 6 / level 2
1月3日 → grid-column 2 / grid-row 8 / level 2
1月4日 → grid-column 3 / grid-row 2 / level 4
3月1日 → grid-column 11 / grid-row 2 / level 0
12月31日 → grid-column 54 / grid-row 6 / level 0
1月4日(日)で次の列に移り、12月31日は54列目に入りました。曜日の列を除くと、2026年は53週ぶんの列になります。マスは全部で365個です。
日付の文字列を new Date('2026-01-01') のように読み込んで getDay() で曜日を取ると、結果がタイムゾーンで変わります。MDN の Date の説明にあるとおり、日付だけの文字列は UTC の0時として読まれ、getDay() は、その時刻を端末のタイムゾーンに直して曜日を返すためです。Playwright でタイムゾーンを切り替えて試したところ、次のようになりました。
Asia/Tokyo 1月1日(木)4件 grid-row 6 / new Date('2026-01-01').getDay()=4 getDate()=1
America/New_York 1月1日(木)4件 grid-row 6 / new Date('2026-01-01').getDay()=3 getDate()=31
Pacific/Honolulu 1月1日(木)4件 grid-row 6 / new Date('2026-01-01').getDay()=3 getDate()=31
ニューヨークとホノルルでは getDay() が水曜(3)、getDate() が31日になり、そのまま使うと1月1日のマスが水曜の行に入ってしまいます。Date.UTC と getUTCDay() を使う Heatmap では、どのタイムゾーンでも1月1日のマスが木曜の行(6行目)に入りました。
件数を5段階の濃さに分ける
マスの色は、件数を 0〜4 の段階に直して決めます。ここでは、0件を 0 にし、それ以外はその期間の最大の件数に対する割合で 1〜4 に分けました。
// 件数を 0〜4 の5段階にする。0件は 0、それ以外は最大値に対する割合で 1〜4 に分ける
export function levelOf(count: number, max: number): number {
if (count <= 0 || max <= 0) return 0;
return Math.min(4, Math.ceil((count / max) * 4));
}
Math.ceil で切り上げているので、1件でもあれば段階は 1 以上になり、薄い色でも「何かあった日」が分かります。見本のデータは最大が11件で、件数と段階の対応は次のとおりでした。
件数→段階: 0件→0 1件→1 2件→1 3件→2 4件→2 5件→2 6件→3 7件→3 8件→3 9件→4 10件→4 11件→4
最大値を基準にしているので、1日だけ件数が飛び抜けて多い日があると、ほかの日はまとめて薄くなります。Node.js で levelOf(1, 100) を呼ぶと 1、levelOf(1, 1) を呼ぶと 4 でした。同じ1件でも、期間の中の最大値によって色が変わります。いつも同じ件数を同じ色にしたいときは、「1〜2件は 1」のように決まったしきい値で分けるほうが向いています。
段階は data-level 属性に入れ、色は CSS で付けます。
.cell {
width: var(--cell);
height: var(--cell);
padding: 0;
border: 0;
border-radius: 2px;
background: #ebedf0;
}
.cell[data-level='1'] { background: #9be9a8; }
.cell[data-level='2'] { background: #40c463; }
.cell[data-level='3'] { background: #30a14e; }
.cell[data-level='4'] { background: #216e39; }
.cell[aria-checked='true'] { outline: 2px solid #1f2a30; outline-offset: 1px; }
.cell:focus-visible { outline: 2px solid #00c8e8; outline-offset: 1px; }
コンポーネントの全体
Heatmap は、日付ごとの件数(counts)、表示する期間(start と end)、選んでいる日(selected)を受け取ります。どの日を選んでいるかは親が持ち、マスが押されると onSelect で知らせます。
import { useLayoutEffect, useRef } from 'react';
import type { CSSProperties, KeyboardEvent } from 'react';
import { dayParts, toDateString, toDay } from './dates';
import { levelOf } from './level';
type Props = {
counts: Record<string, number>; // 'YYYY-MM-DD' → 件数
start: string;
end: string;
selected: string | null;
onSelect: (date: string) => void;
};
const WEEKDAYS = ['日', '月', '火', '水', '木', '金', '土'];
// 矢印キーで動く日数。上下は1日、左右は1週
const STEPS: Record<string, number> = { ArrowUp: -1, ArrowDown: 1, ArrowLeft: -7, ArrowRight: 7 };
export function Heatmap({ counts, start, end, selected, onSelect }: Props) {
const first = toDay(start);
const last = toDay(end);
// 1列目(左端の週)の日曜日。start が週の途中でも、列はかならず日曜から始める
const firstSunday = first - dayParts(first).weekday;
// その日が何列目か(1列目は曜日の見出しなので、週の列は 2 から)
const columnOf = (day: number) => Math.floor((day - firstSunday) / 7) + 2;
const weeks = columnOf(last) - 1;
const days = Array.from({ length: last - first + 1 }, (_, i) => first + i);
const max = Math.max(0, ...days.map((d) => counts[toDateString(d)] ?? 0));
// 月の見出しは、範囲の最初の日と、各月の1日がある週の上に置く
const months = days
.filter((d) => d === first || dayParts(d).date === 1)
.map((d) => ({ day: d, month: dayParts(d).month }));
const scrollRef = useRef<HTMLDivElement>(null);
const cellRefs = useRef(new Map<number, HTMLButtonElement>());
// 最初の表示で、いちばん新しい週(右端)が見えるようにする
useLayoutEffect(() => {
const el = scrollRef.current;
if (el) el.scrollLeft = el.scrollWidth;
}, []);
const move = (e: KeyboardEvent, day: number) => {
const step = STEPS[e.key];
if (step === undefined) return;
e.preventDefault();
const next = Math.min(last, Math.max(first, day + step));
onSelect(toDateString(next));
cellRefs.current.get(next)?.focus();
};
const grid: CSSProperties = {
// 1列目は曜日、2列目からが1週ずつの列。1行目は月の見出し、2〜8行目が日〜土
gridTemplateColumns: `2em repeat(${weeks}, var(--cell))`,
gridTemplateRows: `auto repeat(7, var(--cell))`,
};
// キーボードで最初に入るマス:選んだ日、なければ最後の日
const focusDay = selected ? toDay(selected) : last;
return (
<div className="heatmap-scroll" ref={scrollRef}>
{/* 1日だけを選ぶ部品なので、全体を radiogroup、マスを radio にする */}
<div className="heatmap" style={grid} role="radiogroup" aria-label={`${start}〜${end}の件数`}>
{/* 曜日の列。月の見出しの行から一番下の行までを1つの箱にして、横スクロールでも左に残す */}
<div className="weekdays">
{[1, 3, 5].map((w) => (
<span key={w} style={{ gridRow: w + 2 }}>
{WEEKDAYS[w]}
</span>
))}
</div>
{months.map(({ day, month }) => (
<span key={day} className="month" style={{ gridColumn: columnOf(day), gridRow: 1 }}>
{month}月
</span>
))}
{days.map((day) => {
const { month, date, weekday } = dayParts(day);
const key = toDateString(day);
const count = counts[key] ?? 0;
return (
<button
key={key}
ref={(el) => {
if (el) cellRefs.current.set(day, el);
else cellRefs.current.delete(day);
}}
type="button"
role="radio"
className="cell"
data-level={levelOf(count, max)}
aria-label={`${month}月${date}日(${WEEKDAYS[weekday]})${count}件`}
aria-checked={selected === key}
tabIndex={day === focusDay ? 0 : -1}
style={{ gridColumn: columnOf(day), gridRow: weekday + 2 }}
onClick={() => onSelect(key)}
onKeyDown={(e) => move(e, day)}
/>
);
})}
</div>
</div>
);
}
マスは押せる要素なので <button> にしました。色だけでは件数が伝わらないため、aria-label に「3月4日(水)2件」のような日付と件数を入れています。
選べるのはいつも1日だけなので、マスには role="radio" を付け、全体を role="radiogroup" で囲みました。選んだマスは aria-checked を true にし、CSS ではその属性を見て黒い枠を付けます。キーボードで移ったときは、フォーカスを示す水色の枠(:focus-visible)が黒い枠の代わりに出ます。
Chromium で3月4日のマスをクリックすると、下の表示が「3月4日(水):2件」になり、マスの aria-checked は true になりました。同じマスをもう一度押しても選んだままで、3月7日を押すと3月4日は false に戻り、true のマスは1つだけでした。
月の見出しは、各月の1日が入る週の列の上に置いています。2026年の見本では、1月が2列目、2月が7列目、12月が50列目に入りました。範囲の最初の日を月末に近い日にすると、検証環境(Noto Sans CJK JP の11px)では見出しの文字が重なりました。最初の日を1月31日(土)にして1年分を表示したところ、「1月」は2列目、「2月」はすぐ隣の3列目に入りました。見出しの文字の幅は約18pxあり、1列分の幅(マスの11pxと間隔の3pxで14px)より広いため、2つの文字は約4px重なりました。最初の日を3月30日(月)にした場合は、4月1日が同じ週に入るので、「3月」と「4月」がどちらも2列目に入り、文字が完全に重なりました。この Heatmap には、見出しを間引く処理は入れていません。
使う側では、選んだ日を useState で持ち、件数のデータ(counts)や期間と一緒に Heatmap に渡します。
const [selected, setSelected] = useState<string | null>(null);
// ...
<Heatmap counts={counts} start={START} end={END} selected={selected} onSelect={setSelected} />
曜日の列を、横にスクロールしても残す
マスを11px、間隔を3pxにしても、週の53列と曜日の列を合わせると、幅は764pxになります。スマートフォンの幅には収まらないので、外側の div に overflow-x: auto を付けて横にスクロールさせます。最初に見せたいのはいちばん新しい週なので、useLayoutEffect で scrollLeft を右端に合わせました。useLayoutEffect は、ブラウザが画面を描く前に動くと公式リファレンスに書かれています。そのため、左端を一度描いてから右へ動かすことにはなりません。
横幅390pxの画面で開いたところ、scrollLeft は406で、見えていた月の見出しは8月から12月でした。ページ全体の横幅は390pxのままで、はみ出したのはカレンダーの中だけです。
幅390px: {"scrollLeft":406,"scrollWidth":764,"clientWidth":358,"pageScrollWidth":390,"visibleMonths":"8月 9月 10月 11月 12月"}
このままでは、スクロールすると曜日の見出しも一緒に流れていきます。そこで、曜日の見出しを .weekdays という1つの箱にまとめ、position: sticky で左端に残しました。
.heatmap-scroll { overflow-x: auto; }
.heatmap {
display: grid;
gap: 3px;
width: max-content;
font-size: 11px;
}
.month { white-space: nowrap; }
.weekdays {
grid-column: 1;
grid-row: 1 / -1;
display: grid;
/* 行の高さと間隔は、外側のグリッドのものをそのまま使う */
grid-template-rows: subgrid;
line-height: var(--cell);
/* 横にスクロールしても、曜日の列は左端に残す */
position: sticky;
left: 0;
z-index: 1;
background: #fff;
/* 曜日の列と最初の週のあいだの隙間(gap)も白でふさぐ */
box-shadow: 3px 0 #fff;
}
比べるために、見出しを1行ずつ sticky にする形も試しました。月の見出しの行から土曜の行までの8行に、白い背景の span を1つずつ置いて sticky にしたところ、幅390pxで200pxスクロールしても、曜日の列にマスの緑は見えませんでした。マスは行の中にだけ置かれ、行と行の間の3pxの隙間には入らないので、この検証では、隙間からマスがのぞくことはありませんでした。ただし、この形では、月の見出しの行と、見出しのない日・火・木・土の行にも、空の span が要ります。月・水・金の3行にだけ置いたときは、ほかの行のマスや月の見出しが、曜日の列の位置に見えていました。箱を1行目から最後の行まで(grid-row: 1 / -1)伸ばして白い背景を付けると、見出しのない行も含めた曜日の列の全体を、1つの要素で隠せます。
箱の中の「月」「水」「金」を、外側のグリッドの行とそろえるために、grid-template-rows: subgrid を使いました。subgrid にした箱は、外側のグリッドの行の高さと間隔をそのまま使うので、箱の中の span に gridRow: w + 2 を指定すれば、マスと同じ行に入ります。幅390pxで200pxスクロールしたところ、箱の左端はスクロール領域の左端(0px)に残りました。見出しの上端は月が87px、水が115px、金が143pxで、1月5日(月)、1月7日(水)、1月2日(金)のマスの上端と同じでした。
矢印キーで日を移る
365個のマスがすべて Tab キーで止まると、カレンダーを通り抜けるだけで365回押すことになります。そこで、tabIndex を 0 にするのは1つのマス(選んだ日、なければ最後の日)だけにし、カレンダーの中は矢印キーで動くようにしました。ARIA Authoring Practices Guide(APG)にある roving tabindex という作り方です。
APG のラジオグループのパターンでは、右と下のキーで次のラジオボタンへ、左と上のキーで前のラジオボタンへ移って選び、端まで来ると反対の端へ回ります。この記事では表の形に合わせ、縦が曜日なので上下のキーは1日、左右のキーは1週ずつ動かしました。移った先のマスにフォーカスを移すため、マスの要素は ref のコールバックで Map に集めています。Chromium で3月4日を選んでからキーを押した結果です。
ArrowDown → 3月5日(木):0件 / focus: 3月5日(木)0件
ArrowRight → 3月12日(木):4件 / focus: 3月12日(木)4件
ArrowUp → 3月11日(水):8件 / focus: 3月11日(水)8件
ArrowLeft → 3月4日(水):2件 / focus: 3月4日(水)2件
ArrowLeft → 2月25日(水):4件 / focus: 2月25日(水)4件
3月7日(土)で下のキーを押すと、次の列の一番上にある3月8日(日)へ移り、上のキーで3月7日に戻りました。期間の端では、APG のように反対の端へ回すのではなく、最初の日か最後の日で止めました。何も選んでいないときに Tab キーで入る先も、APG では最初のラジオボタンですが、ここでは新しい日を見せたいので最後の日にしました。Tab キーを1回押すと12月31日のマスに入り、もう1回押すとカレンダーの外へ出ました。





