こんにちは!
フューチャースピリッツ開発チームにおけるフロントエンドのエースこと、マッシュルームです!!
今回は僕がReactプロジェクトでよく使用している自作カレンダーを公開します!
全ソースを公開するのでそのままコピーするだけで実装できます!
是非真似してみてください!!
完成イメージ
今風のフラットデザインを取り入れつつ、ボタン類にはレイヤーの概念を取り入れ、
Googleのマテリアルデザインチックに仕上げています。

ディレクトリ構成
次の2ファイルを作成します。
- CustomCalendar / CustomCalendar.tsx
- CustomCalendar / CustomCalendar.css
CustomCalendar.tsx
import React, { useEffect, useRef, useState } from "react";import './CustomCalendar.css';type Props = { placeholder?: string; date: string; setDate: React.Dispatch<React.SetStateAction<string>>;};const CustomCalendar: React.FC<Props> = ({ placeholder, date, setDate }) => { /** ref */ const calendarRef = useRef<HTMLInputElement>(null); // カスタム日付テキストボックス const calendarDivRef = useRef<HTMLDivElement>(null); // カレンダーブロック /** state */ const [currentYear, setCurrentYear] = useState<number | null>(null); // 表示対象の年 const [currentMonth, setCurrentMonth] = useState<number | null>(null); // 表示対象の月 const [days, setDays] = useState<React.ReactElement[]>([]); // 表示対象の日にち /** * 初期化処理 */ useEffect(() => { if (!calendarRef.current) return; calendarRef.current.value = date ?? ""; }, [date]); /** * カレンダー外クリック処理 */ useEffect(() => { function handleClickOutside(event: MouseEvent) { const calendarEl = calendarDivRef.current; const inputEl = calendarRef.current; // クリック先がカレンダー内なら何もしない if ((inputEl && inputEl.contains(event.target as Node)) || (calendarEl && calendarEl.contains(event.target as Node))) return; // それ以外ならカレンダーを閉じる if (calendarEl) calendarEl.style.display = "none"; } document.addEventListener("mousedown", handleClickOutside); return () => { document.removeEventListener("mousedown", handleClickOutside); }; }, []); /** * (日付テキストボックス) フォーカスイベント */ function focusCalendar (event: any) { const target = event.relatedTarget; // 前月・次月・今日ボタンの場合は処理終了 if (target && ["PREV-MONTH", "NEXT-MONTH", "TODAY"].includes(target.className)) return; // カレンダー表示処理 if (calendarRef.current) openCalendar(); }; /** * カレンダー表示処理 */ function openCalendar (argYear: number | null = null, argMonth: number | null = null) { if (!calendarRef.current) return; // 当日日付取得 const today = new Date(); const nowYear = today.getFullYear(); const nowMonth = today.getMonth() + 1; const nowDay = today.getDate(); // 入力日付取得 const inputDate = new Date(calendarRef.current.value); let year = argYear ?? inputDate.getFullYear(); let month = argMonth ?? inputDate.getMonth() + 1; if (isNaN(year) || isNaN(month) || year < 1980 || year > 9999 || month < 1 || month > 12) { year = nowYear; month = nowMonth; } // カレンダー表示 if (calendarDivRef.current) calendarDivRef.current.style.display = "block"; // ヘッダ表示 setCurrentYear(year); setCurrentMonth(month); // 日数取得 const daysInMonth = new Date(year, month, 0).getDate(); // 月初の曜日取得 const firstDay = new Date(year, month - 1, 1).getDay(); // 空白の曜日箇所生成 const calendarBody:React.ReactElement[] = []; for (let i = 0; i < firstDay; i++) { calendarBody.push(<div key={`blank-${i}`}></div>); } // 日にち生成 for (let day = 1; day <= daysInMonth; day++) { // 当日クラス付与判定 const isToday = year === nowYear && month === nowMonth && day === nowDay; // 選択中の日にちクラス付与付与判定 const isSelected = inputDate.getFullYear() === year && inputDate.getMonth() + 1 === month && inputDate.getDate() === day; // 要素追加 calendarBody.push( <div key={`day-${day}`} className={`DAY-ITEM${isToday ? " DAY-TODAY" : ""}${isSelected ? " DAY-SELECT" : ""}`} tabIndex={0} onClick={() => (clickDay(year, month, day))} > {day} </div> ); } setDays(calendarBody); } /** * 日付クリックイベント */ function clickDay (year: number, month: number, day: number) { if (calendarRef.current) { const fmtDate = formatDate(new Date(year, month - 1, day)) calendarRef.current.value = fmtDate; setDate(fmtDate); } if (calendarDivRef.current) calendarDivRef.current.style.display = "none"; } /** * 選択日付フォーマット */ function formatDate (dateStr: Date): string { let year = dateStr.getFullYear(); let month = String(dateStr.getMonth() + 1).padStart(2, "0"); let day = String(dateStr.getDate()).padStart(2, "0"); if (Number(month) < 1) { month = "12"; year--; } return `${year}-${month}-${day}`; } /** * カレンダー入力イベント処理 */ function inputCalendar () { if (!calendarRef.current) return; // 入力日付取得 const inputDate = new Date(calendarRef.current.value); const inputYear = inputDate.getFullYear(); const inputMonth = inputDate.getMonth() + 1; // 当日日付取得 const today = new Date(); const nowYear = today.getFullYear(); const nowMonth = today.getMonth() + 1; // 1980年未満の場合は、強制で当月指定 if (isNaN(inputYear) || String(inputYear).length !== 4 || Number(calendarRef.current.value.substring(0, 4)) < 1980) { openCalendar(nowYear, nowMonth); } else { openCalendar(inputYear, inputMonth); } } /** * 前月・次月ボタンクリックイベント */ function clickPrevNext (direction: "prev" | "next") { if (!calendarRef.current) return; let year = currentYear; let month = currentMonth; if (!year || !month) return; if (direction === "prev") { // 1980年1月の場合は処理終了 if (year === 1980 && month === 1) return calendarRef.current.focus(); if (--month < 1) { month = 12; year--; } } else { // 9999年12月の場合は処理終了 if (year === 9999 && month === 12) return calendarRef.current.focus(); if (++month > 12) { month = 1; year++; } } calendarRef.current.focus(); openCalendar(year, month); } /** * 今日ボタンクリックイベント */ function clickToday () { if (!calendarRef.current) return; // 当日日付取得 const today = new Date(); const year = today.getFullYear(); const month = today.getMonth(); const day = today.getDate(); // 日付セット const fmtDate = formatDate(new Date(year, month, day)) calendarRef.current.value = fmtDate; setDate(fmtDate); // カレンダー非表示 if (calendarDivRef.current) calendarDivRef.current.style.display = "none"; } return ( <div className="CUSTOM-DATEPICKER"> {/*カスタム日付テキストボックス*/} <input type="text" ref={calendarRef} className="DATEPICKER" autoComplete="off" placeholder={placeholder ?? "yyyy-mm-dd"} onFocus={focusCalendar} onInput={inputCalendar} onChange={(e) => setDate(e.target.value)} /> {/* カレンダー全体 */} <div className="CALENDAR" ref={calendarDivRef}> {/* ヘッダ */} <div className="CALENDAR-HEADER"> {/* 前月ボタン */} <input type="button" className="BUTTON-PREV-NEXT PREV-MONTH" value="<" onClick={() => clickPrevNext("prev")} /> {/* 表示日付 */} <div className="CURRENT-MONTH-YEAR"> <div className="CURRENT-YEAR-TEXT">{currentYear}年</div> <div className="CURRENT-MONTH-TEXT">{currentMonth}月</div> </div> {/* 次月ボタン */} <input type="button" className="BUTTON-PREV-NEXT NEXT-MONTH" value=">" onClick={() => clickPrevNext("next")} /> {/* 今日ボタン */} <input type="button" className="BUTTON-TODAY TODAY" value="今日" onClick={() => clickToday()} /> </div> {/* 曜日 */} <div className="CALENDAR-DAYS"> <div className="DAY">日</div> <div className="DAY">月</div> <div className="DAY">火</div> <div className="DAY">水</div> <div className="DAY">木</div> <div className="DAY">金</div> <div className="DAY">土</div> </div> {/* 日にち */} <div className="CALENDAR-BODY"> {days} </div> </div> </div> );};export default CustomCalendar;CustomCalendar.css
/*カスタム日付テキストボックス*/
.CUSTOM-DATEPICKER {
position: relative;
display: inline-block;
color: #333;
}
/*テキストボックス*/
.DATEPICKER {
width: 120px;
height: 20px;
border: solid 1px;
border-color: darkgrey;
border-radius: 5px;
}
/*カレンダー全体*/
.CALENDAR {
width: 250px;
position: absolute;
top: calc(100% + 5px);
left: 0;
z-index: 1000;
display: none;
background-color: white;
border: 1px;
border-color: darkgrey;
border-radius: 10px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
padding: 10px;
}
/*ヘッダ*/
.CALENDAR-HEADER {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 5px;
font-size: 14px;
}
/*現在の年月 (親)*/
.CURRENT-MONTH-YEAR {
display: flex;
}
/*現在の月*/
.CURRENT-MONTH-TEXT {
width: 35px;
text-align: right;
}
/*曜日*/
.CALENDAR-DAYS {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 5px;
font-size: 14px;
font-weight: bold;
}
/*各曜日*/
.DAY {
width: 30px;
height: 30px;
font-weight: bold;
display: flex;
justify-content: center;
align-items: center;
}
/*日にち*/
.CALENDAR-BODY {
display: grid;
grid-template-columns: repeat(7, 1fr);
column-gap: 6.7px;
font-size: 14px;
}
.CALENDAR-BODY .DAY-ITEM {
width: 30px;
height: 30px;
border-radius: 50px;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
}
.CALENDAR-BODY .DAY-ITEM:hover {
background-color: #f0f0f0;
}
/*今日の日にち*/
.DAY-TODAY {
background-color: #c1efaa;
}
/*選択中の日にち*/
.DAY-SELECT {
background-color: #a2a2a2;
}
/*前月・次月ボタン*/
.BUTTON-PREV-NEXT {
width: 35px;
height: 25px;
background-color: #333;
color: #ffffff;
font-weight: bold;
border: solid 1px;
border-color: darkgrey;
border-radius: 30px;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
box-shadow: 0 0 6px 0
rgba(0, 0, 0, 0.5);
transition: 0.2s;
cursor: pointer;
}
.BUTTON-PREV-NEXT:hover {
box-shadow: 0 4px 7px 0 rgba(0, 0, 0, 0.5);
transform: translateY(-2px);
cursor: pointer;
}
/*今日ボタン*/
.BUTTON-TODAY {
width: 60px;
height: 25px;
background-color: #333;
color: #ffffff;
font-size: 12px;
font-weight: bold;
border: solid 1px;
border-color: darkgrey;
border-radius: 30px;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
box-shadow: 0 0 6px 0
rgba(0, 0, 0, 0.5);
transition: 0.2s;
cursor: pointer;
}
.BUTTON-TODAY:hover {
box-shadow: 0 4px 7px 0 rgba(0, 0, 0, 0.5);
transform: translateY(-2px);
cursor: pointer;
}実装方法
カスタムカレンダーには、次の引数が必要となります。
- placeholder (string)(オプション) :テキストボックスのプレースホルダー
- date (string) :選択された日付
- setDate (React.SetStateAction) :日付のセッター
使用したいコンポーネントにて、"CustomCalendar" をインポートします。
import CustomCalendar from './CustomCalendar/CustomCalendar';次のようなstateを作成します。
const [inputDateFrom, setInputDateFrom] = useState<string>("");jsx(tsx)内にて、次のように書くと実装されます。
<div> <CustomCalendar placeholder={"From"} date={inputDateFrom} setDate={setInputDateFrom}/> </div>使い方
テキストボックスにフォーカスを当てると、カレンダーが表示されます。
日付を押下する、またはフォーカスを外すと、カレンダーが閉じられます。

日付を押下すると、テキストボックスに日付が入力されます。
今日の日付は緑色で、選択(入力)された日付はグレーで表示されます。

また、テキストボックス内に直接日付(yyyy-MM-dd)を入力することも可能で、自動でカレンダーの日付が選択されます。

カレンダーは1980-01-01 ~ 9999-12-31までの範囲で対応しており、範囲外の日付を入力すると、強制で当月のカレンダーが表示されます。

選択(入力)された日付は引数:setDateを経由して、引数:dateから取得することができます。
おわりに
以上、自作カレンダーの実装方法でした!
超クールな今風フラットデザインカレンダーを是非導入してみてください!! ^^

