初心者向け「コピペで動く」バニラJavaScriptのカレンダーアプリと作り方

この記事で作るもの

  • 月ごとのカレンダー(前月・次月・今月ボタン)
  • 日付クリックでモーダルが開き、その日の予定を追加・削除
  • 予定はブラウザのローカルストレージに保存(自動で永続化)
  • 予定のエクスポート/インポート(JSON)

使い方(超シンプル)

  1. 下のコードをそのまま1つのHTMLファイルとして保存(例:easy-calendar.html)。
  2. ダブルクリックでブラウザで開けばOK。
  3. 日付をクリック → モーダルで予定を追加できます。

完成コード(そのまま動きます)

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)
おすすめの記事