Reactで、月ごとのカレンダーを表示する方法
月ごとのカレンダーは、その月の1日が何曜日かが分かれば、6行×7列の表に日付を並べられます。日付を作るときは new Date(年, 月, 日) の「日」に数を足していけば、月末を越えたぶんは JavaScript の Date が次の月の日に直してくれます。月の日数を自分で数える必要はありません。
ライブラリを使わずに、MonthCalendar というコンポーネントを作りました。前の月・次の月への切り替え、今日の表示、日を選ぶ操作、矢印キーで日を移る動きまでを、ブラウザで操作して確かめています。
検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で Chromium を操作しました。ブラウザの言語は日本語、タイムゾーンは日本時間にし、ブラウザの「今」を2026年10月30日に固定しています。StrictMode を付けた開発ビルドでも同じ操作をしたところ、結果は本番ビルドと同じでした。Firefox と Safari、スクリーンリーダーでの読み上げ、サーバーでの描画(SSR)は試していません。

1日の曜日から、表の左上の日を決める
カレンダーの表の左上に来る日は、1日の曜日から決まります。日曜始まりの表で、1日が木曜日なら、1日の前に前の月の日が4つ入ります。この数は、getDay() が返す曜日の番号(日曜が 0、土曜が 6)から求められます。
日付の並びは、React に関係のない関数 monthGrid にまとめました。
export type Day = { date: Date; inMonth: boolean };
// month は 0 始まり(1月が 0)。weekStartsOn は週の始まりの曜日(0 が日曜、1 が月曜)
export function monthGrid(year: number, month: number, weekStartsOn = 0): Day[][] {
// 1日の前に、前の月の日が何日入るか
const offset = (new Date(year, month, 1).getDay() - weekStartsOn + 7) % 7;
const weeks: Day[][] = [];
// 行の数は 6 に固定する(月によって表の高さが変わらないように)
for (let w = 0; w < 6; w++) {
const week: Day[] = [];
for (let d = 0; d < 7; d++) {
// 0日や32日のような日も渡してよい。Date が隣の月の日に直してくれる
const date = new Date(year, month, 1 - offset + w * 7 + d);
week.push({ date, inMonth: date.getMonth() === month });
}
weeks.push(week);
}
return weeks;
}
export const addDays = (date: Date, days: number) =>
new Date(date.getFullYear(), date.getMonth(), date.getDate() + days);
export const sameDay = (a: Date, b: Date) =>
a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
monthGrid は、表の左上の日から数えて42日分の Date を作り、7日ずつに分けて返します。それぞれの日には、表示している月の日かどうか(inMonth)を持たせます。
月曜始まりにするときは、weekStartsOn に 1 を渡します。1日が月曜日のときに 0 になるよう、引いてから 7 を足し、7 で割った余りを取っています。Node.js で2026年10月の表を出した結果です。* が付いているのは、隣の月の日です。
--- 2026年10月(日曜始まり。* は隣の月の日)
27* 28* 29* 30* 1 2 3
4 5 6 7 8 9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28 29 30 31
1* 2* 3* 4* 5* 6* 7*
--- 2026年10月(月曜始まり)
28* 29* 30* 1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31 1*
2* 3* 4* 5* 6* 7* 8*
行の数は6に固定する
日付の入る行の数は、月によって4行から6行まで変わります。2026年の各月を数えると、1日が日曜日で28日しかない2月は4行、1日が金曜日や土曜日で31日ある5月と8月は6行でした。
1月: 1日は木曜、最終日は31日、5行
2月: 1日は日曜、最終日は28日、4行
3月: 1日は日曜、最終日は31日、5行
4月: 1日は水曜、最終日は30日、5行
5月: 1日は金曜、最終日は31日、6行
6月: 1日は月曜、最終日は30日、5行
7月: 1日は水曜、最終日は31日、5行
8月: 1日は土曜、最終日は31日、6行
9月: 1日は火曜、最終日は30日、5行
10月: 1日は木曜、最終日は31日、5行
11月: 1日は日曜、最終日は30日、5行
12月: 1日は火曜、最終日は31日、5行
行の数をそのまま使うと、月を切り替えるたびに表の高さが変わります。カレンダーの下にあるボタンや文章の位置もずれます。そこで monthGrid では、いつも6行を作るようにしました。日付の入らない行は、次の月の日で埋まります。ブラウザで月を切り替えても、表の高さは 317px のまま変わりませんでした。
次の月: 2026年11月 / 行=6 高さ=317px / Tab で入れる日 2026年11月1日日曜日
次の月: 2026年12月 / 行=6 高さ=317px / Tab で入れる日 2026年12月1日火曜日
次の月: 2027年1月 / 行=6 高さ=317px / Tab で入れる日 2027年1月1日金曜日
日付は、ミリ秒ではなく日にちの数で進める
MDN の Date() コンストラクターのページによると、new Date(年, 月, 日) に範囲を超えた値を渡すと、超えたぶんが上の単位に繰り上がります。月は 0 から数えるので、12 を渡すと次の年の1月になります。日に 0 を渡すと、前の月の最終日です。うるう年の2月も、この書き方で正しく出ました。
new Date(2026, 12, 1) = 2027-1-1 00:00
new Date(2026, -1, 1) = 2025-12-1 00:00
new Date(2026, 2, 0) = 2026-2-28 00:00
new Date(2028, 2, 0) = 2028-2-29 00:00
日付を1日進めるのに、24時間分のミリ秒を足す書き方もあります。ただ、夏時間のあるタイムゾーンでは、1日が24時間とは限りません。America/New_York では、2026年11月1日に夏時間が終わり、その日は25時間あります。タイムゾーンを America/New_York にして Node.js で試すと、ミリ秒を足した側は11月1日の23時になり、日付が進みませんでした。monthGrid や addDays のように日にちの数を足す書き方なら、0時のまま次の日に進みます。
--- 2026年11月1日の 0時から、1日ずつ進める
1日後: ミリ秒で足す 2026-11-1 23:00 / addDays 2026-11-2 00:00
2日後: ミリ秒で足す 2026-11-2 23:00 / addDays 2026-11-3 00:00
タイムゾーンを日本時間(Asia/Tokyo)にしたときは、どちらの書き方でも11月2日の0時になりました。日本には夏時間がないため、日本時間だけで試していると気づけません。
表示する月を state で持つ MonthCalendar コンポーネント
monthGrid を使って、表を描くコンポーネントを作りました。表示している月(view)と、矢印キーで動かす元になる日(active)を state で持ちます。選んだ日(selected)は、使う側から props で受け取ります。
import { useEffect, useId, useRef, useState, type KeyboardEvent } from 'react';
import { addDays, monthGrid, sameDay } from './calendar';
const titleFormat = new Intl.DateTimeFormat('ja-JP', { year: 'numeric', month: 'long' });
const weekdayFormat = new Intl.DateTimeFormat('ja-JP', { weekday: 'short' });
const labelFormat = new Intl.DateTimeFormat('ja-JP', { dateStyle: 'full' });
const moves: Record<string, number> = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 };
type Props = {
selected: Date | null;
onSelect: (date: Date) => void;
weekStartsOn?: 0 | 1;
};
export function MonthCalendar({ selected, onSelect, weekStartsOn = 0 }: Props) {
const titleId = useId();
const tableRef = useRef<HTMLTableElement>(null);
const shouldFocus = useRef(false);
const [today] = useState(() => new Date());
const [view, setView] = useState(() => {
const d = selected ?? today;
return { year: d.getFullYear(), month: d.getMonth() };
});
// 矢印キーで動かす元になる日
const [active, setActive] = useState(() => selected ?? today);
const { year, month } = view;
const weeks = monthGrid(year, month, weekStartsOn);
const inView = (d: Date) => d.getFullYear() === year && d.getMonth() === month;
// Tab キーで入れる日は1つだけ。表示中の月に active がなければ1日にする
const focusable = inView(active) ? active : new Date(year, month, 1);
const moveMonth = (diff: number) => {
const d = new Date(year, month + diff, 1);
setView({ year: d.getFullYear(), month: d.getMonth() });
};
const handleKeyDown = (e: KeyboardEvent) => {
const days = moves[e.key];
if (days === undefined) return;
e.preventDefault();
const next = addDays(focusable, days);
setActive(next);
setView({ year: next.getFullYear(), month: next.getMonth() });
shouldFocus.current = true;
};
// 矢印キーで動かしたときだけ、描き直したあとの日にフォーカスを移す
useEffect(() => {
if (!shouldFocus.current) return;
shouldFocus.current = false;
tableRef.current?.querySelector<HTMLButtonElement>('button[tabindex="0"]')?.focus();
});
return (
<div className="calendar">
<div className="calendar-header">
<button type="button" aria-label="前の月" onClick={() => moveMonth(-1)}>‹</button>
<h2 id={titleId} aria-live="polite">{titleFormat.format(new Date(year, month, 1))}</h2>
<button type="button" aria-label="次の月" onClick={() => moveMonth(1)}>›</button>
</div>
<table ref={tableRef} role="grid" aria-labelledby={titleId} onKeyDown={handleKeyDown}>
<thead>
<tr>
{weeks[0].map(({ date }) => (
<th key={date.getDay()} scope="col">{weekdayFormat.format(date)}</th>
))}
</tr>
</thead>
<tbody>
{weeks.map((week) => (
<tr key={week[0].date.getTime()}>
{week.map(({ date, inMonth }) => (
<td key={date.getTime()} aria-selected={selected !== null && sameDay(date, selected) ? true : undefined}>
{inMonth ? (
<button
type="button"
tabIndex={sameDay(date, focusable) ? 0 : -1}
aria-label={labelFormat.format(date)}
aria-current={sameDay(date, today) ? 'date' : undefined}
onClick={() => {
setActive(date);
onSelect(date);
}}
>
{date.getDate()}
</button>
) : (
// 隣の月の日は見せるだけ。読み上げからは外す
<span className="outside" aria-hidden="true">{date.getDate()}</span>
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
見出しの「2026年10月」、曜日の「日」「月」、日のボタンに付ける「2026年10月30日金曜日」は、どれも Intl.DateTimeFormat で作っています。年月は year: 'numeric', month: 'long'、曜日は weekday: 'short'、日付は dateStyle: 'full' です。曜日の見出しは、1行目の7日をそのまま書式に通すので、weekStartsOn を変えれば曜日の並びも変わります。言語を 'ja-JP' と決めておかないと、閲覧している人のブラウザの設定で表示が変わります。
「前の月」「次の月」のボタンでは、new Date(year, month + diff, 1) で月を動かします。12月から次の月へ進むと、month は 12 になりますが、「日付は、ミリ秒ではなく日にちの数で進める」の節で見たとおり、Date が次の年の1月に直します。ブラウザで試すと、見出しは「2026年12月」の次に「2027年1月」になりました。
使う側では、選んだ日を state で持ち、selected と onSelect に渡します。
import { useState } from 'react';
import { MonthCalendar } from './MonthCalendar';
const format = new Intl.DateTimeFormat('ja-JP', { dateStyle: 'full' });
export function App() {
const [selected, setSelected] = useState<Date | null>(null);
return (
<main>
<MonthCalendar selected={selected} onSelect={setSelected} />
<p className="selected">選んだ日:{selected ? format.format(selected) : 'まだありません'}</p>
</main>
);
}
今日の日付は、最初に描いたときの new Date() で決めています。ページを開いたまま日付が変わっても、今日の枠は動きません。また、サーバーで描画する場合は、サーバーとブラウザで「今」がずれることがあります。どちらも、この記事では試していません。
今日と選んだ日は、属性で伝える
今日の日付は、水色の丸い枠で見せています。見た目だけでなく、ボタンに aria-current="date" を付けました。MDN の aria-current のページによると、date の値は、カレンダーのような日付の集まりの中で今日の日付を表します。
日のボタンには、数字の「30」だけでなく、aria-label で「2026年10月30日金曜日」という名前を付けました。表のセルの数字だけでは、何月の何曜日かが伝わらないからです。
選んだ日は、表のセル(td)に aria-selected="true" を付けて表します。表には role="grid" を付けました。これは、W3C の ARIA Authoring Practices Guide(APG)にある日付選択の例に合わせた書き方です。この例では、table に grid の役割を付け、選んだ日のセルにだけ aria-selected="true" を付けています。APG の例ではセル自身がフォーカスを受けますが、この記事ではセルの中にボタンを置き、ボタンがフォーカスを受けるようにしました。CSS も、aria-current と aria-selected を見て塗り分けます。
.calendar td button[aria-current="date"] { box-shadow: inset 0 0 0 2px #00c8e8; font-weight: bold; }
.calendar td[aria-selected="true"] button { background: #00c8e8; color: #fff; }
開いた直後の表を、Playwright でアクセシビリティツリーとして取り出した結果です。曜日は列の見出し(columnheader)になり、日のボタンの名前には日付全体が入っています。隣の月の日は aria-hidden="true" にしたので、空のセルになりました。
- rowgroup:
- row "日 月 火 水 木 金 土":
- columnheader "日"
- columnheader "月"
- columnheader "火"
- columnheader "水"
- columnheader "木"
- columnheader "金"
- columnheader "土"
- row "2026年10月1日木曜日 2026年10月2日金曜日 2026年10月3日土曜日":
- gridcell
- gridcell
- gridcell
- gridcell
- gridcell "2026年10月1日木曜日":
- button "2026年10月1日木曜日": "1"
- gridcell "2026年10月2日金曜日":
- button "2026年10月2日金曜日": "2"
- gridcell "2026年10月3日土曜日":
- button "2026年10月3日土曜日": "3"
表を11月に切り替えてから、11月3日を押した結果です。aria-selected="true" のセルは1つだけで、フォーカスは押したボタンに残りました。
選んだ日:2026年11月3日火曜日
aria-selected="true" のセル: 1 個 / 2026年11月3日火曜日
フォーカス: 2026年11月3日火曜日 / Tab で入れる日: 2026年11月3日火曜日
月の見出しには aria-live="polite" を付けました。APG の例でも、月が変わったときに見出しを読み上げさせるために付けています。ただし、実際のスクリーンリーダーで読み上げられるかは試していません。
矢印キーで日を移る
日のボタンは、1か月で30個前後あります。全部を Tab キーで順にたどるのは大変です。そこで、Tab キーでフォーカスが入る日を1つだけにし(tabIndex が 0)、ほかの日は -1 にしました。表の中では矢印キーで日を移ります。APG では、この方法をroving tabindex(Tab キーでフォーカスが入る要素を1つだけにして、矢印キーで動かす方法)として説明しています。左右の矢印キーで前後の日に、上下の矢印キーで前後の週の同じ曜日に移るのは、APG の日付選択の例と同じ割り当てです。APG の例には、Home・End キーで週の初めと終わりへ、Page Up・Page Down キーで前後の月へ移る割り当てもありますが、この記事のコンポーネントには入れていません。
キーを押したときは、移る先の日を addDays で求めます。その日が隣の月なら、表示する月も切り替えます。フォーカスを移すのは、React が表を描き直したあとです。useEffect に依存配列を渡していないので、React の公式リファレンスにある、依存配列を省いたときの説明のとおり、描き直すたびに中の処理が動きます。矢印キーで動かしたときだけ shouldFocus を true にしておき、そのときだけ、tabindex="0" になったボタンにフォーカスを移します。「前の月」「次の月」のボタンを押したときや、開いた直後には、フォーカスは動きません。
開き直してから、Tab キーと矢印キーで移った結果です。
Tab: フォーカス 前の月 / 見出し 2026年10月
Tab: フォーカス 次の月 / 見出し 2026年10月
Tab: フォーカス 2026年10月30日金曜日 / 見出し 2026年10月
ArrowRight: フォーカス 2026年10月31日土曜日 / 見出し 2026年10月
ArrowRight: フォーカス 2026年11月1日日曜日 / 見出し 2026年11月
ArrowUp: フォーカス 2026年10月25日日曜日 / 見出し 2026年10月
ArrowDown: フォーカス 2026年11月1日日曜日 / 見出し 2026年11月
ArrowLeft: フォーカス 2026年10月31日土曜日 / 見出し 2026年10月
Enter: フォーカス 2026年10月31日土曜日 / 見出し 2026年10月
選んだ日:2026年10月31日土曜日
3回目の Tab で、今日の10月30日に入りました。10月31日で右矢印キーを押すと、表が11月に切り替わり、11月1日にフォーカスが移ります。11月1日で上矢印キーを押すと、1週間前の10月25日に戻りました。Enter キーでは、ボタンを押したときと同じように、その日が選ばれます。

「前の月」「次の月」のボタンで切り替えたときは、active の日がその月にないので、Tab キーでフォーカスが入る日は1日になります。「行の数は6に固定する」の節で、表の高さを確かめた出力の「Tab で入れる日」が、どの月でも1日になっているのはそのためです。





