
この記事で作るもの
- 月ごとのカレンダー(前月・次月・今月ボタン)
- 日付クリックでモーダルが開き、その日の予定を追加・削除
- 予定はブラウザのローカルストレージに保存(自動で永続化)
- 予定のエクスポート/インポート(JSON)
使い方(超シンプル)
- 下のコードをそのまま1つのHTMLファイルとして保存(例:
easy-calendar.html)。 - ダブルクリックでブラウザで開けばOK。
- 日付をクリック → モーダルで予定を追加できます。
完成コード(そのまま動きます)
1ファイル完結・外部ライブラリ不要。見た目もそこそこ整えてあります。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>かんたんカレンダー(バニラJS)</title>
<style>
:root{
--bg:#0f172a; --panel:#111827; --text:#e5e7eb; --muted:#9ca3af;
--accent:#60a5fa; --accent-2:#22c55e; --danger:#ef4444; --today:#fde68a;
--shadow:0 10px 25px rgba(0,0,0,.35);
}
*{box-sizing:border-box}
html,body{margin:0;background:linear-gradient(135deg,#0b1227 0%,#1f2937 100%);
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans JP", "Hiragino Kaku Gothic ProN", Meiryo, sans-serif;
color:var(--text);min-height:100%}
.wrap{max-width:980px;margin:40px auto;padding:16px}
.card{background:var(--panel);border-radius:16px;box-shadow:var(--shadow);overflow:hidden}
.header{display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid #1f2937}
.title{display:flex;align-items:center;gap:12px}
.title h1{font-size:18px;margin:0;color:var(--text)}
.title small{color:var(--muted)}
.nav{display:flex;gap:8px}
button.icon{background:#0b1220;border:1px solid #1f2937;color:var(--text);border-radius:10px;padding:8px 10px;cursor:pointer}
button.icon:focus{outline:2px solid var(--accent)}
.grid{display:grid;grid-template-columns:repeat(7,1fr);gap:6px;padding:10px}
.dow{color:var(--muted);font-size:12px;text-align:center;padding:6px 0}
.cell{background:#0b1220;border:1px solid #1f2937;border-radius:12px;min-height:88px;padding:8px;position:relative;cursor:pointer;display:flex;flex-direction:column;gap:6px}
.cell:hover{border-color:#334155}
.daynum{font-weight:700;color:#cbd5e1}
.empty{background:transparent;border:none}
.today{outline:2px solid var(--today)}
.badges{display:flex;flex-wrap:wrap;gap:6px;margin-top:4px}
.badge{background:#0f172a;border:1px dashed #334155;color:#cbd5e1;border-radius:999px;font-size:11px;padding:2px 8px;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.footer{padding:12px 16px;color:var(--muted);font-size:12px}
/* modal */
.backdrop{position:fixed;inset:0;background:rgba(0,0,0,.6);display:none;place-items:center;z-index:50}
.modal{width:min(560px,92vw);background:#0b1220;border:1px solid #1f2937;border-radius:16px;box-shadow:var(--shadow);padding:16px}
.modal .row{display:flex;gap:10px;margin-top:10px}
.modal h2{margin:0 0 8px 0;font-size:18px}
.modal input[type="text"]{flex:1;padding:10px;border-radius:10px;border:1px solid #374151;background:#0a1220;color:var(--text)}
.modal .btn{padding:10px 12px;border:none;border-radius:10px;cursor:pointer}
.btn-primary{background:var(--accent);color:#001}
.btn-ghost{background:transparent;border:1px solid #334155;color:var(--text)}
.list{display:grid;gap:8px;margin:10px 0}
.item{display:flex;align-items:center;gap:10px;background:#0e1629;border:1px solid #1f2937;border-radius:12px;padding:8px 10px}
.item .dot{width:10px;height:10px;border-radius:999px;background:var(--accent-2)}
.item .text{flex:1}
.item .del{background:transparent;border:1px solid #ef4444;color:#ef4444;padding:4px 8px;border-radius:8px;cursor:pointer}
.close{float:right}
@media (max-width:520px){ .cell{min-height:72px} .header{flex-direction:column;align-items:flex-start;gap:8px} }
</style>
</head>
<body>
<div class="wrap">
<div class="card">
<div class="header">
<div class="title">
<button id="prev" class="icon" aria-label="前の月">◀</button>
<button id="today" class="icon" aria-label="今月へ">今日</button>
<button id="next" class="icon" aria-label="次の月">▶</button>
<div>
<h1 id="ym"></h1>
<small id="weekday"></small>
</div>
</div>
<div class="nav">
<button id="export" class="icon">📤 エクスポート</button>
<button id="import" className="icon">📥 インポート</button>
<input id="file" type="file" accept="application/json" style="display:none">
</div>
</div>
<div class="grid" id="grid"></div>
<div class="footer">ローカルストレージに予定を保存します。ブラウザを変えるとデータは共有されません。</div>
</div>
</div>
<!-- モーダル -->
<div class="backdrop" id="backdrop" role="dialog" aria-modal="true" aria-labelledby="modal-title">
<div class="modal">
<button class="icon close" id="close">×</button>
<h2 id="modal-title"></h2>
<div class="list" id="eventList"></div>
<div class="row">
<input id="eventText" type="text" placeholder="予定を入力して Enter または 追加" />
<button class="btn btn-primary" id="add">追加</button>
<button class="btn btn-ghost" id="clear">その日の予定を全消去</button>
</div>
</div>
</div>
<script>
// ======== かんたんカレンダー(バニラJS) ========
// データは localStorage に保存します(キー: 'simple-calendar-events')
const grid = document.getElementById('grid');
const ym = document.getElementById('ym');
const weekdayEl = document.getElementById('weekday');
const prevBtn = document.getElementById('prev');
const nextBtn = document.getElementById('next');
const todayBtn = document.getElementById('today');
const backdrop = document.getElementById('backdrop');
const closeBtn = document.getElementById('close');
const eventList = document.getElementById('eventList');
const eventText = document.getElementById('eventText');
const addBtn = document.getElementById('add');
const clearBtn = document.getElementById('clear');
const exportBtn = document.getElementById('export');
const importBtn = document.getElementById('import');
const fileInput = document.getElementById('file');
let viewDate = new Date(); // 表示している月
let selectedDateStr = null; // モーダルで編集中の日付(YYYY-MM-DD)
const STORAGE_KEY = 'simple-calendar-events';
function loadEvents(){
try{ const raw = localStorage.getItem(STORAGE_KEY); return raw ? JSON.parse(raw) : {}; }
catch(e){ console.warn('イベント読み込み失敗', e); return {}; }
}
function saveEvents(data){ localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); }
let events = loadEvents();
const pad = (n)=> String(n).padStart(2,'0');
function toKey(y,m,d){ return `${y}-${pad(m)}-${pad(d)}`; }
function isSameDay(a,b){ return a.getFullYear()===b.getFullYear() && a.getMonth()===b.getMonth() && a.getDate()===b.getDate(); }
const DOW = ['日','月','火','水','木','金','土'];
function render(){
ym.textContent = `${viewDate.getFullYear()}年 ${viewDate.getMonth()+1}月`;
const now = new Date();
weekdayEl.textContent = `今日は ${now.getFullYear()}-${pad(now.getMonth()+1)}-${pad(now.getDate())}(${DOW[now.getDay()]})`;
grid.innerHTML='';
// 曜日ヘッダ
for(let i=0;i<7;i++){
const h = document.createElement('div'); h.className = 'dow'; h.textContent = DOW[i]; grid.appendChild(h);
}
const y = viewDate.getFullYear();
const m = viewDate.getMonth();
const first = new Date(y,m,1);
const last = new Date(y,m+1,0);
const startDay = first.getDay();
const daysInMonth = last.getDate();
for(let i=0;i<startDay;i++){ const e = document.createElement('div'); e.className = 'cell empty'; grid.appendChild(e); }
for(let d=1; d<=daysInMonth; d++){
const key = toKey(y,m+1,d);
const cell = document.createElement('div');
cell.className = 'cell';
cell.setAttribute('tabindex','0');
cell.setAttribute('role','button');
cell.setAttribute('aria-label', `${key}の予定`);
const head = document.createElement('div');
head.className = 'daynum';
head.textContent = d;
cell.appendChild(head);
if(isSameDay(new Date(y,m,d), new Date())){ cell.classList.add('today'); }
const list = events[key] || [];
if(list.length){
const badges = document.createElement('div'); badges.className = 'badges';
list.slice(0,2).forEach(t=>{ const b=document.createElement('span'); b.className='badge'; b.textContent=t; badges.appendChild(b); });
if(list.length>2){ const more=document.createElement('span'); more.className='badge'; more.textContent=`+${list.length-2}`; badges.appendChild(more); }
cell.appendChild(badges);
}
cell.addEventListener('click', ()=> openModal(key));
cell.addEventListener('keydown', (e)=>{ if(e.key==='Enter' || e.key===' '){ e.preventDefault(); openModal(key); } });
grid.appendChild(cell);
}
}
function openModal(dateKey){
selectedDateStr = dateKey;
const dt = new Date(dateKey);
const title = document.getElementById('modal-title');
title.textContent = `${dateKey}(${DOW[dt.getDay()]})の予定`;
renderEventList();
backdrop.style.display = 'grid';
eventText.focus();
}
function closeModal(){ backdrop.style.display = 'none'; selectedDateStr = null; }
function renderEventList(){
const list = events[selectedDateStr] || [];
eventList.innerHTML='';
if(list.length===0){
const empty = document.createElement('div'); empty.className = 'item';
empty.innerHTML = '<span class="text" style="color:#9ca3af">この日は予定がありません</span>';
eventList.appendChild(empty); return;
}
list.forEach((txt,idx)=>{
const item = document.createElement('div'); item.className = 'item';
const dot = document.createElement('span'); dot.className='dot';
const text = document.createElement('span'); text.className='text'; text.textContent=txt;
const del = document.createElement('button'); del.className='del'; del.textContent='削除';
del.addEventListener('click', ()=>{
events[selectedDateStr].splice(idx,1);
if(events[selectedDateStr].length===0) delete events[selectedDateStr];
saveEvents(events); renderEventList(); render();
});
item.appendChild(dot); item.appendChild(text); item.appendChild(del); eventList.appendChild(item);
});
}
addBtn.addEventListener('click', addEventFromInput);
eventText.addEventListener('keydown', (e)=>{ if(e.key==='Enter'){ addEventFromInput(); } });
function addEventFromInput(){
const txt = eventText.value.trim();
if(!selectedDateStr || !txt) return;
if(!events[selectedDateStr]) events[selectedDateStr] = [];
events[selectedDateStr].push(txt);
saveEvents(events);
eventText.value=''; renderEventList(); render();
}
clearBtn.addEventListener('click', ()=>{
if(!selectedDateStr) return;
if(confirm(`${selectedDateStr} の予定を全て削除しますか?`)){
delete events[selectedDateStr]; saveEvents(events); renderEventList(); render();
}
});
closeBtn.addEventListener('click', closeModal);
backdrop.addEventListener('click', (e)=>{ if(e.target===backdrop) closeModal(); });
document.getElementById('prev').addEventListener('click', ()=>{ viewDate.setMonth(viewDate.getMonth()-1); render(); });
document.getElementById('next').addEventListener('click', ()=>{ viewDate.setMonth(viewDate.getMonth()+1); render(); });
document.getElementById('today').addEventListener('click', ()=>{ viewDate = new Date(); render(); });
// エクスポート / インポート
document.getElementById('export').addEventListener('click', ()=>{
const blob = new Blob([JSON.stringify(events,null,2)], {type:'application/json'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href=url; a.download='calendar-events.json'; a.click();
URL.revokeObjectURL(url);
});
document.getElementById('import').addEventListener('click', ()=> document.getElementById('file').click());
document.getElementById('file').addEventListener('change', (e)=>{
const file = e.target.files?.[0]; if(!file) return;
const reader = new FileReader();
reader.onload = ()=>{
try{
const obj = JSON.parse(reader.result);
if(obj && typeof obj === 'object'){ events = obj; saveEvents(events); render(); alert('インポートしました。'); }
else{ alert('JSON形式が不正です。'); }
}catch(err){ alert('読み込みに失敗しました。'); }
};
reader.readAsText(file, 'utf-8'); e.target.value = '';
});
render();
</script>
</body>
</html>
ここが学びポイント(初心者向け解説)
- カレンダーの基本
月初の曜日(new Date(y, m, 1).getDay())と、月末日(new Date(y, m+1, 0).getDate())がわかれば、
先頭の空セルと1〜末日までのセルをグリッドで並べるだけ。 - 日付キーの作成
"YYYY-MM-DD"形式(toKey())にそろえると、予定の保存・読み出しが簡単になります。 - 保存先はローカルストレージ
localStorage.setItem(KEY, JSON.stringify(obj))JSON.parse(localStorage.getItem(KEY)) || {}
で、ブラウザに自動保存できます(サーバー不要)。 - モーダルの仕組み
背景オーバーレイ(.backdrop)のdisplayを切り替え。
中の.modalをクリックしたときはバブリングを止めると閉じません(今回は背景クリックで閉じ)。 - アクセシビリティ
日付セルにrole="button"とtabindex="0"を付け、Enter/Spaceでも開けるようにしています。
発展アイデア
- 予定に開始時刻/終了時刻を追加
- 月/週/日ビュー切り替え
- GoogleカレンダーやDBに同期
- 予定に色タグや検索をつける
- スマホでのPWA化(ホームに追加→オフラインでもOK)









