Reactでカレンダーを自作する:日付ピッカーとタイムゾーンずれの倒し方
Reactで日付ピッカーを自作する手順。月グリッド生成、月送り、範囲選択、矢印キー操作、そして「日付が1日ずれる」タイムゾーンの罠まで、コピペで動くコードで解説。
「6月2日を予約したのに、確認メールには6月1日って書いてある」
ユーザーからこの問い合わせが来たとき、僕は自分の目を疑いました。コードは何度見ても正しい。テストも通っている。なのに、本番でだけ日付が1日ずれる。
犯人は、カレンダーのセルをクリックした瞬間に呼んでいた new Date("2026-06-02") でした。この一行が、日本のユーザーの「6月2日」を勝手に「6月1日の朝9時」に変えていたんです。
カレンダーUIは、見た目がいちばん簡単な部品です。でも、7×6のマスを並べる作業の裏に、月末・うるう年・夏時間・タイムゾーン・キーボード操作という地雷が埋まっています。今日はその地雷を一つずつ踏み抜きながら、Reactで動く日付ピッカーを自作していきます。
この記事の要点
- カレンダーの本体は「月のグリッドを作る計算」と「日付の保存形式」。見た目はその後でいい。
- 選んだ日付は
DateオブジェクトではなくYYYY-MM-DDの文字列で持つ。new Date("2026-06-02")は環境によって前日にずれる。 - 月グリッドは「月初の曜日ぶんだけ前にずらす」だけで作れる。コピペで動く
buildMonthGridを載せた。 - 矢印キーで移動できないカレンダーは未完成。
role="grid"とtabIndexの付け替えで対応する。 - 凝った範囲選択や祝日対応が要るなら react-day-picker。学習目的や軽い単日選択なら自作で十分。判断表を用意した。
カレンダーの本当の難しさは「日付」にある
最初に種明かしをします。カレンダーで詰まるポイントの9割は、グリッドの描画ではなく「日付の扱い」です。
JavaScript の Date は、時刻とタイムゾーンを必ず引きずります。new Date("2026-06-02") と書くと、これは「2026年6月2日の午前0時(UTC)」と解釈されます。日本(UTC+9)で表示すると、午前0時UTCは前日の朝9時。だから6月1日になる。冒頭の事故はこれでした。
予約日や公開日のように「何時かは関係ない、日付だけが欲しい」値のことを、英語では date-only(時刻なしの日付)と呼びます。この値を Date で持つのが、そもそもの間違いです。
| 持ち方 | 例 | 何が起きるか |
|---|---|---|
Date オブジェクト | new Date("2026-06-02") | タイムゾーンで前日/翌日にずれる |
toISOString() の結果 | "2026-06-01T15:00:00.000Z" | UTC変換済みで、もう「6月2日」が消えている |
| date-only 文字列 | "2026-06-02" | ずれない。比較も < で素直にできる |
僕の結論はシンプルです。「日付だけ」が欲しいなら、最初から最後まで "2026-06-02" という文字列で持つ。 Date を経由しない。これだけで、ずれ事故の大半は消えます。
ちなみに、この「型を間違えると1単位ずれる」構図は、お金の計算でもまったく同じです。円を浮動小数点で持つと1円ずれる話は通貨フォーマットの記事に書きました。日付もお金も、「ずれてはいけない値を、ずれる型で持つな」が鉄則です。
なお、この問題を言語仕様レベルで解決する Temporal.PlainDate という新APIが進行中ですが、MDNのTemporal.PlainDateを見るとまだ主要ブラウザに行き渡っておらず、本番では使えません。今は文字列で逃げるのが現実解です。
まず月のグリッドを作る
カレンダーの心臓は「その月のマスを並べる計算」です。ここだけ純粋関数(入力が同じなら出力も同じ関数)にしておくと、後でテストが死ぬほど楽になります。
考え方は一つだけ。月初の曜日のぶんだけ、前の月から日付を借りてくる。 6月1日が月曜なら、前に日曜を1つ足して7列目を埋める。あとは6週ぶん(42マス)になるまで翌月を借りる。
次のコードは src/calendar/date-utils.ts としてそのまま動きます。日付はすべて YYYY-MM-DD 文字列で扱い、内部計算だけ Date.UTC を使ってタイムゾーンの影響を消しています。
export type ISODate = `${number}-${number}-${number}`;
const pad2 = (value: number) => String(value).padStart(2, "0");
// 数値から "2026-06-02" 形式の文字列を作る
export function makeISODate(year: number, month: number, day: number): ISODate {
return `${year}-${pad2(month)}-${pad2(day)}` as ISODate;
}
// 文字列を分解しつつ、存在しない日付(2月30日など)を弾く
export function readISODate(value: ISODate) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (!match) throw new Error(`日付の形式が不正です: ${value}`);
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const check = new Date(Date.UTC(year, month - 1, day));
if (
check.getUTCFullYear() !== year ||
check.getUTCMonth() !== month - 1 ||
check.getUTCDate() !== day
) {
throw new Error(`存在しない日付です: ${value}`);
}
return { year, month, day };
}
function toUTCDate(value: ISODate) {
const { year, month, day } = readISODate(value);
return new Date(Date.UTC(year, month - 1, day));
}
// n日進める/戻す。月またぎ・うるう年は Date が面倒を見てくれる
export function addDaysISO(value: ISODate, amount: number): ISODate {
const date = toUTCDate(value);
date.setUTCDate(date.getUTCDate() + amount);
return makeISODate(date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate());
}
// nヶ月進める/戻す。常にその月の1日を返す
export function addMonthsISO(value: ISODate, amount: number): ISODate {
const { year, month } = readISODate(value);
const date = new Date(Date.UTC(year, month - 1 + amount, 1));
return makeISODate(date.getUTCFullYear(), date.getUTCMonth() + 1, 1);
}
export function startOfMonthISO(value: ISODate): ISODate {
const { year, month } = readISODate(value);
return makeISODate(year, month, 1);
}
// その月の日数。第0日 = 前月末日のトリックで求める
export function daysInMonthISO(value: ISODate): number {
const { year, month } = readISODate(value);
return new Date(Date.UTC(year, month, 0)).getUTCDate();
}
// 0=日曜 〜 6=土曜
export function weekdayIndex(value: ISODate): number {
return toUTCDate(value).getUTCDay();
}
// ★これがカレンダーの心臓部。月のマス目を配列で返す
export function buildMonthGrid(monthISO: ISODate, weekStartsOn: 0 | 1 = 0): ISODate[] {
const first = startOfMonthISO(monthISO);
// 月初の曜日ぶんだけ前へずらす(日曜始まり or 月曜始まり)
const leading = (weekdayIndex(first) - weekStartsOn + 7) % 7;
const start = addDaysISO(first, -leading);
// 7の倍数マスになるよう週数を切り上げる
const cells = Math.ceil((leading + daysInMonthISO(first)) / 7) * 7;
return Array.from({ length: cells }, (_, i) => addDaysISO(start, i));
}
buildMonthGrid("2026-06-01") を呼ぶと、5月31日から始まる42個(または35個)の文字列配列が返ります。これを7列のグリッドに流し込めば、カレンダーの骨組みは完成です。daysInMonthISO の「第0日は前月末日」というトリックや、addMonthsISO の月またぎ計算は、自分で書くと必ずバグるので Date.UTC に任せるのが安全です。
日付を表示する:locale と timeZone を混ぜない
グリッドができたら、各マスに「2」「3」と数字を出します。ここで2つ目の罠が来ます。表示言語(locale)と時刻の基準(timeZone)は、別物なのに混同されがちだという点です。
英語表示なのにAsia/Tokyoの予約枠を見せることもあれば、日本語表示でロサンゼルスの空きを扱うこともあります。だから両者は引数で分けます。
// "2026-06-02" を、指定ロケールの表記に整える
export function formatISODate(
value: ISODate,
locale: string,
options: Intl.DateTimeFormatOptions = {},
) {
const { year, month, day } = readISODate(value);
// ★timeZone を UTC に固定し、正午を渡す。これでずれない
return new Intl.DateTimeFormat(locale, { timeZone: "UTC", ...options }).format(
new Date(Date.UTC(year, month - 1, day, 12)),
);
}
// ユーザーの「今日」だけはタイムゾーンで変わるので Intl で求める
export function todayISO(
timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone,
): ISODate {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(new Date());
const get = (type: string) => parts.find((p) => p.type === type)?.value;
return `${get("year")}-${get("month")}-${get("day")}` as ISODate;
}
ポイントは formatISODate で timeZone: "UTC" を固定し、わざと正午(12時)を渡しているところです。正午にしておけば、どの地域で表示しても日付がまたぐことはありません。ロケールごとの表記をまとめて引き受けてくれる Intl.DateTimeFormat の使い方はMDNのIntl.DateTimeFormatが詳しいです。
todayISO だけは別扱いにしています。「今日」はユーザーのいる場所で変わる唯一の値なので、ここだけはタイムゾーンを尊重して求めます。
日付を選ぶ、範囲を選ぶ
単日選択は簡単です。クリックされたマスの文字列を state に入れるだけ。難しいのは範囲選択(チェックイン〜チェックアウトのような開始日と終了日)です。
範囲選択でよくやる事故が、終了日より後の開始日を保存してしまうこと。ユーザーが6月10日→6月5日の順にクリックしたら、開始6月5日・終了6月10日に直してあげないといけません。これは「2つを比べて小さいほうを開始にする」だけで解けます。
// "2026-06-02" 同士は文字列比較で日付の前後がわかる(ゼロ埋めの恩恵)
export function compareISODate(a: ISODate, b: ISODate) {
return a.localeCompare(b);
}
// クリック順に関係なく、必ず start <= end に並べ替える
export function normalizeRange(start: ISODate, end: ISODate) {
return compareISODate(start, end) <= 0
? { start, end }
: { start: end, end: start };
}
// ある日が範囲内かどうか
export function isISODateInRange(day: ISODate, start?: ISODate, end?: ISODate) {
if (!start || !end) return false;
return compareISODate(day, start) >= 0 && compareISODate(day, end) <= 0;
}
YYYY-MM-DD 形式の地味な強みがここで効きます。ゼロ埋めしてあるので、"2026-06-05" < "2026-06-10" という単純な文字列比較が、そのまま日付の前後比較になる。Date に変換する必要すらありません。
選択ロジックは「開始がまだない、または範囲が確定済みなら新しい開始として置く。開始だけある状態なら終了として確定し、normalizeRange で並べ替える」という2状態で書けます。次のコンポーネントでこの流れを実装します。
全部つなげる:動くCalendarコンポーネント
ここまでの部品を src/calendar/Calendar.tsx で組み立てます。単日選択・範囲選択・月送り・矢印キー操作までこの1ファイルに入っています。date-utils.ts と同じフォルダに置けば、これだけで動きます。
import { useMemo, useRef, useState, useEffect, type KeyboardEvent } from "react";
import {
addMonthsISO,
buildMonthGrid,
formatISODate,
isISODateInRange,
normalizeRange,
startOfMonthISO,
todayISO,
type ISODate,
} from "./date-utils";
type Mode = "single" | "range";
type RangeValue = { start?: ISODate; end?: ISODate };
type Props = {
locale?: string;
timeZone?: string;
initialMonth?: ISODate;
weekStartsOn?: 0 | 1;
mode?: Mode;
onSelectDate?: (date: ISODate) => void;
onSelectRange?: (range: RangeValue) => void;
};
const WEEKDAYS = ["日", "月", "火", "水", "木", "金", "土"];
export function Calendar({
locale = "ja-JP",
timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone,
initialMonth = todayISO(timeZone),
weekStartsOn = 0,
mode = "single",
onSelectDate,
onSelectRange,
}: Props) {
const [month, setMonth] = useState(startOfMonthISO(initialMonth));
const [activeDate, setActiveDate] = useState(initialMonth); // 矢印キーの現在位置
const [selected, setSelected] = useState<ISODate>();
const [range, setRange] = useState<RangeValue>({});
const cellRefs = useRef<Record<string, HTMLButtonElement | null>>({});
const days = useMemo(() => buildMonthGrid(month, weekStartsOn), [month, weekStartsOn]);
const today = todayISO(timeZone);
const monthLabel = formatISODate(month, locale, { year: "numeric", month: "long" });
// activeDate が変わったら、そのマスにフォーカスを移す
useEffect(() => {
cellRefs.current[activeDate]?.focus();
}, [activeDate]);
function goToMonth(next: ISODate) {
setMonth(next);
setActiveDate(next);
}
// 日付を確定する。単日と範囲で分岐
function commit(day: ISODate) {
if (mode === "single") {
setSelected(day);
onSelectDate?.(day);
return;
}
const next =
!range.start || range.end
? { start: day, end: undefined } // 新しい開始
: normalizeRange(range.start, day); // 終了を確定して並べ替え
setRange(next);
onSelectRange?.(next);
}
// 矢印キーで activeDate を動かす
function move(offset: number) {
const i = days.indexOf(activeDate);
setActiveDate(days[Math.min(Math.max(i + offset, 0), days.length - 1)]);
}
function onKeyDown(e: KeyboardEvent<HTMLDivElement>) {
const keys: Record<string, () => void> = {
ArrowLeft: () => move(-1),
ArrowRight: () => move(1),
ArrowUp: () => move(-7),
ArrowDown: () => move(7),
Home: () => setActiveDate(days[0]),
End: () => setActiveDate(days[days.length - 1]),
PageUp: () => goToMonth(addMonthsISO(month, -1)),
PageDown: () => goToMonth(addMonthsISO(month, 1)),
Enter: () => commit(activeDate),
" ": () => commit(activeDate),
};
const handler = keys[e.key];
if (!handler) return;
handler();
e.preventDefault();
}
return (
<section className="cal" aria-labelledby="cal-title">
<div className="cal__bar">
<button type="button" onClick={() => goToMonth(addMonthsISO(month, -1))}>
前の月
</button>
<h2 id="cal-title" aria-live="polite">{monthLabel}</h2>
<button type="button" onClick={() => goToMonth(addMonthsISO(month, 1))}>
次の月
</button>
</div>
<div className="cal__grid" role="grid" aria-labelledby="cal-title" onKeyDown={onKeyDown}>
{WEEKDAYS.map((w) => (
<div className="cal__head" role="columnheader" key={w}>{w}</div>
))}
{days.map((day) => {
const inMonth = startOfMonthISO(day) === month;
const isSelected = mode === "single" && selected === day;
const inRange =
mode === "range" &&
isISODateInRange(day, range.start, range.end ?? range.start);
return (
<button
key={day}
ref={(node) => { cellRefs.current[day] = node; }}
type="button"
role="gridcell"
tabIndex={activeDate === day ? 0 : -1} // ★フォーカスは常に1つだけ
aria-selected={isSelected || inRange}
data-outside={!inMonth}
data-today={day === today}
className="cal__cell"
onClick={() => commit(day)}
>
{formatISODate(day, locale, { day: "numeric" })}
</button>
);
})}
</div>
</section>
);
}
使うときは <Calendar mode="single" onSelectDate={(d) => console.log(d)} /> のように置くだけです。コールバックには "2026-06-02" という文字列がそのまま渡ってくるので、APIへ送るときも Date 変換を挟みません。範囲なら mode="range" にして onSelectRange を受け取ります。
最低限のCSSはこれだけあれば形になります。
.cal__grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 1px; }
.cal__head { text-align: center; padding: 8px 0; font-weight: 700; background: #f5f7fb; }
.cal__cell { min-height: 56px; border: 1px solid #d7deea; background: #fff; cursor: pointer; }
.cal__cell:focus-visible { outline: 3px solid #2563eb; outline-offset: -3px; }
.cal__cell[aria-selected="true"] { background: #dbeafe; box-shadow: inset 0 0 0 2px #2563eb; }
.cal__cell[data-outside="true"] { color: #9aa3b2; }
.cal__cell[data-today="true"] { font-weight: 800; }
矢印キーで動かせないカレンダーは未完成
ここを飛ばす人が本当に多いので強めに書きます。マウスでしか日付を選べないカレンダーは、半分しか完成していません。
支援技術(スクリーンリーダーなど)に部品の役割を伝える仕組みを ARIA と呼びます。カレンダーは表組みの一種なので、外側に role="grid"、各マスに role="gridcell" を付けます。そして矢印キーで移動できるようにする。これがいわゆるグリッドパターンで、設計指針はW3CのARIA Grid Patternにまとまっています。
上のコードでやっている肝は1か所、tabIndex={activeDate === day ? 0 : -1} です。
42個のボタン全部をTabキーで辿れるようにすると、ユーザーは目的の日に着くまでTabを30回押す羽目になります。そこで、**フォーカスを受け取れるのは常に「今いるマス1つだけ」**にして、残りは tabIndex={-1} で外す。日付間の移動は矢印キーに任せる。これがroving tabindex(動き回るtabindex)という定番テクニックです。useEffect で activeDate のマスに .focus() を当てているのは、矢印キーで論理位置を動かしたら実際のフォーカスも追従させるためです。
アクセシビリティ全般をClaude Codeで詰める手順はアクセシビリティ対応の記事に分けて書いたので、axeでの自動チェックまでやりたい人はそちらもどうぞ。
ライブラリか、自作か
ここまで自作してきて何ですが、正直に言います。全部のケースで自作が正解ではありません。
代表的なライブラリ react-day-picker は記事執筆時点でv10、React 16.8以降で動きます。範囲選択・複数月表示・無効日・ローカライズが最初から入っていて、自分でroving tabindexを書く必要もありません。判断基準を表にします。
| 状況 | おすすめ | 理由 |
|---|---|---|
| 単日選択だけ・依存を増やしたくない | 自作 | 上のコードで十分。バンドルも軽い |
| 仕組みを理解したい/学習目的 | 自作 | グリッド計算とtabindexを手で書く価値がある |
| 範囲選択・複数月・祝日・無効日が必要 | react-day-picker | 自作すると地雷が多すぎる |
| デザインを完全に作り込みたい | 自作 or ヘッドレス | 既製の見た目に縛られない |
| 期日が近く、とにかく早く出す | ライブラリ | 車輪の再発明をしない |
僕の使い分けは「単日選択のフィルタUIみたいな軽いものは自作、宿泊予約のような範囲選択が絡んだら react-day-picker」です。日付計算の地雷(月末・うるう年・タイムゾーン)は、自作でもライブラリでも結局向き合うことになるので、最初の date-utils.ts の考え方だけは知っておいて損はありません。
僕がやらかした失敗3つ
カレンダーで踏んだ地雷を正直に書きます。
ひとつ目は冒頭の new Date("2026-06-02") で1日ずれた事故。クリック時にDateへ変換していたのが原因でした。date-only文字列を最後まで貫いてから、ぱったり止まりました。
ふたつ目は、色だけで「満席」を表したこと。休業日を灰色にしただけで満足していたら、色覚特性のあるユーザーから「どこが休みか分からない」と指摘が来ました。今はマス内に「休」のテキストと aria-disabled を必ずセットで付けています。色は補助、情報はテキストで、が鉄則です。
みっつ目は、テストを後回しにしたこと。「見た目ができてから書こう」と思っていたら、月送りで2月だけグリッドがずれるバグを本番まで見逃しました。日付ロジックは目視で抜けます。buildMonthGrid("2026-02-01") が28日(うるう年なら29日)を正しく並べるか、純粋関数のうちにテストしておけば数秒で気づけた話です。
よくある質問
Q. なぜ Date ではなく文字列で日付を持つのですか?
A. Date は時刻とタイムゾーンを必ず引きずるからです。「日付だけ」が欲しい予約日や締切日では、new Date("2026-06-02") がUTC午前0時と解釈され、日本では前日にずれます。"2026-06-02" のまま持てばずれず、< で比較もできます。
Q. 月のグリッドは6週(42マス)で固定すべきですか?
A. 固定すると毎月の高さが揃ってレイアウトが安定します。一方、上の buildMonthGrid は必要な週数だけ返す実装(35 or 42)です。高さを固定したいなら Math.ceil(...) を 6 に置き換えてください。どちらでも日付計算は同じです。
Q. 範囲選択で開始日と終了日が逆になります。
A. クリック順をそのまま保存しているのが原因です。normalizeRange で2つを比較し、小さいほうを start に揃えてから保存してください。YYYY-MM-DD なら文字列比較がそのまま日付比較になります。
Q. react-day-pickerと自作、結局どちらがいいですか? A. 単日選択や学習目的なら自作で十分です。範囲選択・複数月・祝日・無効日が絡むなら react-day-picker(v10)に任せたほうが安全です。上の判断表を目安にしてください。
Q. Claude Codeにカレンダーを作らせるコツは?
A. 「見た目を作って」ではなく制約を渡すことです。「日付キーはYYYY-MM-DDの文字列」「Date.toISOString()を選択日の保存に使わない」「月グリッドと範囲判定は純粋関数に分ける」「矢印キー移動を実装」と書くと、後から直すより安く済みます。
実際に試した結果
この記事のコードをVite + React + TypeScriptの検証プロジェクトに貼り、単日選択・範囲選択・矢印キー移動・月送りを動かしました。最初にハマったのは、やはり範囲選択のクリック順で、終了→開始の順に押すと開始日が未来になる。normalizeRange を挟んで解決しました。buildMonthGrid("2026-02-01") も叩いてみて、うるう年の2月が29日まで正しく並ぶことを確認しています。
学んだのは、カレンダーは「描画の部品」ではなく「日付計算の部品」だということです。グリッドを描くだけなら30分で終わる。残りの時間は全部、タイムゾーンとの戦いに溶けます。だからこそ、日付計算を純粋関数に切り出して先にテストする価値がある。本番に出す前には、これに祝日・予約枠の上限・サーバー側バリデーションを足してください。
より実務的なプロンプト例やレビュー観点をまとめた資料は教材一覧に整理しています。チームでカレンダーやフォームのようなUI部品を量産するなら、最初からプロンプトとテスト観点を共有しておくと手戻りが減ります。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Codeに1ファイルだけ直させる指示文のつくり方
「もっと良くして」で40行も変えられた失敗から学んだ、触る範囲・検証・戻し方をセットにしたClaude Code用の依頼文テンプレートを紹介します。
Claude Code の権限拒否から復旧する: 止まった理由を次の安全手順に変える
Claude Code のコマンドが拒否されたとき、焦って許可を広げずに、拒否理由、代替手順、証拠コマンド、再試行条件へ分解する方法。
Claude Codeにビルド→スモークテスト→自動修正を回させる足場の作り方
最小スモークテストの選び方、失敗ログを食わせて直させるループ、回数上限と確認ゲートで暴走を止める方法を、コピペで動くコード付きで紹介します。