Reactで、月ごとのカレンダーを表示する方法

​​React​Intl,JavaScript,Playwright,TypeScript,アクセシビリティ

月ごとのカレンダーは、その月の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)は試していません。

2026年10月のカレンダーで「次の月」ボタンを2回押すと、見出しが2026年11月、2026年12月と変わる。続けて12月24日を押すと、24日が水色の丸になり、下の表示が「選んだ日:2026年12月24日木曜日」に変わる
「次の月」を2回押してから12月24日を押した様子。変わった見出しと「選んだ日」は、その瞬間だけピンクにしている(カーソル、押した瞬間の輪、クリックの回数の表示は GIF 用に重ねたもの)

1日の曜日から、表の左上の日を決める

カレンダーの表の左上に来る日は、1日の曜日から決まります。日曜始まりの表で、1日が木曜日なら、1日の前に前の月の日が4つ入ります。この数は、getDay() が返す曜日の番号(日曜が 0、土曜が 6)から求められます。

日付の並びは、React に関係のない関数 monthGrid にまとめました。

ts
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月の表を出した結果です。* が付いているのは、隣の月の日です。

text
--- 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行でした。

text
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 のまま変わりませんでした。

text
次の月: 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月も、この書き方で正しく出ました。

text
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時のまま次の日に進みます。

text
--- 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 で受け取ります。

tsx
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 に渡します。

tsx
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 を見て塗り分けます。

css
.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" にしたので、空のセルになりました。

text
- 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つだけで、フォーカスは押したボタンに残りました。

text
選んだ日: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 キーと矢印キーで移った結果です。

text
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 キーでは、ボタンを押したときと同じように、その日が選ばれます。

上は10月の表で、今日の30日の隣にある31日に濃い水色の枠が付いている。下は右矢印キーを押したあとで、表が2026年11月に変わり、1日に枠が付いている
10月31日にフォーカスがある状態から、右矢印キーを1回押したときの変化。濃い水色の枠がフォーカスの位置

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

​Posted by 86