ヒートマップカレンダー

CSS Gridで組む貢献度カレンダー。日別の活動量を5段階の色で示し、コントリビューショングラフ風の活動量可視化に使えます。

#css#grid#heatmap#calendar

ライブデモ

使用例(お題: カフェ MOON BREW)

この技法を「カフェ MOON BREW」というテーマのダミーサイトで実際に使った例です。

HTML
<!-- MOON BREW:来店分析画面。日別の来店数をヒートマップカレンダーで可視化 -->
<section class="mh-stage">
  <header class="mh-head">
    <div class="mh-brand"><span class="mh-cup">☕</span> MOON BREW</div>
    <span class="mh-sub">青山店 ・ 直近26週の来店状況</span>
  </header>

  <div class="mh-panel">
    <p class="mh-panel__title">日別 来店ヒートマップ</p>
    <!-- グリッドはJSが7行×週数で生成(曜日×週) -->
    <div class="dv-cal" id="cal" role="img" aria-label="日別来店数のヒートマップ"></div>
    <div class="dv-foot">
      <span class="dv-foot__txt">少ない</span>
      <div class="dv-scale" id="scale"></div>
      <span class="dv-foot__txt">多い</span>
      <span class="dv-tip" id="tip" aria-live="polite"></span>
    </div>
  </div>
</section>
CSS
/* MOON BREW:来店ヒートマップカレンダー(CSS Gridヒートマップが主役) */
:root {
  --cream: #f5ede1;
  --brown: #2b1d12;
  --amber: #c98a3b;
  /* 5段階のヒートカラー(0=来店わずか → 4=満席級) */
  --h0: #ece1d0;
  --h1: #e6c79a;
  --h2: #d9a85e;
  --h3: #c98a3b;
  --h4: #9c5f1f;
  --cell: 13px;
  --gap: 4px;
}

* { box-sizing: border-box; }

body {
  margin: 0;
  height: 400px;
  font-family: "Hiragino Kaku Gothic ProN", "Yu Gothic", system-ui, sans-serif;
  background: var(--cream);
  color: var(--brown);
}

.mh-stage { height: 400px; padding: 18px 22px; display: flex; flex-direction: column; }

.mh-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
.mh-brand {
  font-family: "Hiragino Mincho ProN", serif;
  font-weight: 700; font-size: 16px; letter-spacing: 0.1em;
  display: flex; align-items: center; gap: 7px;
}
.mh-cup { font-size: 17px; }
.mh-sub { font-size: 11px; color: #8a755e; letter-spacing: 0.06em; }

/* ヒートマップを載せるカード */
.mh-panel {
  flex: 1;
  background: #fffaf2;
  border: 1px solid #e7dccb;
  border-radius: 14px;
  padding: 16px 18px 14px;
  box-shadow: 0 8px 20px rgba(43,29,18,0.06);
  display: flex; flex-direction: column; justify-content: center;
}
.mh-panel__title { margin: 0 0 14px; font-size: 14px; font-weight: 700; }

/* 列方向に週、各列7セル(縦=曜日) */
.dv-cal {
  display: grid;
  grid-auto-flow: column;
  grid-template-rows: repeat(7, var(--cell));
  gap: var(--gap);
  justify-content: start;
  overflow-x: auto;
  padding-bottom: 6px;
}
.dv-cell {
  width: var(--cell);
  height: var(--cell);
  border-radius: 3px;
  background: var(--h0);
  transform: scale(0.2);
  opacity: 0;
  transition: transform .35s ease, opacity .35s ease, outline-color .15s ease;
  outline: 2px solid transparent;
  outline-offset: 1px;
}
.dv-cell.show { transform: scale(1); opacity: 1; }
.dv-cell[data-level="1"] { background: var(--h1); }
.dv-cell[data-level="2"] { background: var(--h2); }
.dv-cell[data-level="3"] { background: var(--h3); }
.dv-cell[data-level="4"] { background: var(--h4); }
.dv-cell:hover { outline-color: rgba(43,29,18,0.6); }

.dv-foot {
  display: flex; align-items: center; gap: 8px;
  margin-top: 16px; font-size: 12px; color: #8a755e;
}
.dv-foot__txt { white-space: nowrap; }
.dv-scale { display: flex; gap: 4px; }
.dv-scale span { width: 12px; height: 12px; border-radius: 3px; }
.dv-tip { margin-left: auto; font-weight: 700; color: var(--brown); min-height: 16px; }

@media (prefers-reduced-motion: reduce) {
  .dv-cell { transition: none; transform: scale(1); opacity: 1; }
}
JavaScript
// MOON BREW:直近約半年の擬似来店数を生成し、Gridヒートマップを描画
(() => {
  const cal = document.getElementById('cal');
  const scale = document.getElementById('scale');
  const tip = document.getElementById('tip');
  if (!cal) return; // null安全

  const WEEKS = 26; // 表示週数
  const cssVar = (n) => getComputedStyle(document.documentElement).getPropertyValue(`--h${n}`).trim();
  const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  // 凡例スケールを生成
  if (scale) {
    for (let i = 0; i <= 4; i++) {
      const s = document.createElement('span');
      s.style.background = cssVar(i);
      scale.appendChild(s);
    }
  }

  // 来店数→5段階レベルへ変換
  function toLevel(v) {
    if (v <= 0) return 0;
    if (v < 12) return 1;
    if (v < 24) return 2;
    if (v < 36) return 3;
    return 4;
  }

  const weekday = ['日', '月', '火', '水', '木', '金', '土'];
  const today = new Date();
  const cells = [];

  // 古い週→新しい週、各週は日曜→土曜の順で生成
  const frag = document.createDocumentFragment();
  for (let w = WEEKS - 1; w >= 0; w--) {
    for (let d = 0; d < 7; d++) {
      const daysAgo = w * 7 + (6 - d);
      const date = new Date(today);
      date.setDate(today.getDate() - daysAgo);

      // 週末は来店多め・平日は控えめのランダムで自然な見た目に
      const base = (d === 0 || d === 6) ? 34 : 16;
      const val = Math.max(0, Math.round(base * (0.4 + Math.random() * 1.1)));
      const level = toLevel(val);

      const cell = document.createElement('div');
      cell.className = 'dv-cell';
      cell.dataset.level = String(level);
      const dateStr = `${date.getMonth() + 1}/${date.getDate()}(${weekday[date.getDay()]})`;
      cell.dataset.info = `${dateStr} ・ ${val}名来店`;
      cell.title = cell.dataset.info;
      frag.appendChild(cell);
      cells.push(cell);
    }
  }
  cal.appendChild(frag);

  // ホバーで詳細をフッターに表示
  cal.addEventListener('mouseover', (e) => {
    const t = e.target;
    if (t instanceof HTMLElement && t.classList.contains('dv-cell') && tip) {
      tip.textContent = t.dataset.info || '';
    }
  });
  cal.addEventListener('mouseleave', () => { if (tip) tip.textContent = ''; });

  // 波打つように順次フェードイン
  if (reduceMotion) {
    cells.forEach((c) => c.classList.add('show'));
  } else {
    cells.forEach((c, i) => {
      setTimeout(() => c.classList.add('show'), i * 4);
    });
  }
})();

コード

HTML
<div class="dv-wrap">
  <figure class="dv-card">
    <figcaption class="dv-head">
      <h2 class="dv-title">アクティビティ・ヒートマップ</h2>
      <p class="dv-sub">CSS Grid で組む貢献度カレンダー(コントリビューショングラフ風)</p>
    </figcaption>
    <!-- グリッドはJSが7行×週数で生成する -->
    <div class="dv-cal" id="cal" role="img" aria-label="日別アクティビティのヒートマップ"></div>
    <div class="dv-foot">
      <span class="dv-foot__txt">少ない</span>
      <div class="dv-scale" id="scale"></div>
      <span class="dv-foot__txt">多い</span>
      <span class="dv-tip" id="tip" aria-live="polite"></span>
    </div>
  </figure>
</div>
CSS
:root {
  --dv-radius: 18px;
  --dv-ink: #e2e8f0;
  --dv-sub: #94a3b8;
  /* 5段階のヒートカラー(0=無し) */
  --h0: #1e293b;
  --h1: #0e4429;
  --h2: #006d32;
  --h3: #26a641;
  --h4: #39d353;
  --cell: 15px;
  --gap: 4px;
}

* { box-sizing: border-box; }

body {
  margin: 0;
  min-height: 100vh;
  display: grid;
  place-items: center;
  font-family: "Segoe UI", "Hiragino Kaku Gothic ProN", system-ui, sans-serif;
  color: var(--dv-ink);
  background:
    radial-gradient(900px 500px at 80% -20%, #14532d 0%, transparent 55%),
    linear-gradient(160deg, #0f172a, #020617);
}

.dv-wrap { width: min(94vw, 760px); padding: 20px; }

.dv-card {
  margin: 0;
  padding: 22px 24px 18px;
  border-radius: var(--dv-radius);
  background: rgba(15, 23, 42, 0.55);
  border: 1px solid rgba(148, 163, 184, 0.18);
  box-shadow: 0 24px 60px -24px rgba(0, 0, 0, 0.7);
  backdrop-filter: blur(6px);
}

.dv-head { margin-bottom: 14px; }
.dv-title { margin: 0; font-size: clamp(18px, 3.4vw, 22px); }
.dv-sub { margin: 4px 0 0; font-size: 13px; color: var(--dv-sub); }

/* 列方向に週、各列7セル(縦=曜日) */
.dv-cal {
  display: grid;
  grid-auto-flow: column;
  grid-template-rows: repeat(7, var(--cell));
  gap: var(--gap);
  justify-content: start;
  overflow-x: auto;
  padding-bottom: 6px;
}

.dv-cell {
  width: var(--cell);
  height: var(--cell);
  border-radius: 4px;
  background: var(--h0);
  transform: scale(0.2);
  opacity: 0;
  transition: transform .35s ease, opacity .35s ease, outline-color .15s ease;
  outline: 2px solid transparent;
  outline-offset: 1px;
}
.dv-cell.show { transform: scale(1); opacity: 1; }
.dv-cell[data-level="1"] { background: var(--h1); }
.dv-cell[data-level="2"] { background: var(--h2); }
.dv-cell[data-level="3"] { background: var(--h3); }
.dv-cell[data-level="4"] { background: var(--h4); }
.dv-cell:hover { outline-color: rgba(226, 232, 240, 0.85); }

.dv-foot {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-top: 14px;
  font-size: 12px;
  color: var(--dv-sub);
}
.dv-foot__txt { white-space: nowrap; }

.dv-scale { display: flex; gap: 4px; }
.dv-scale span {
  width: 13px;
  height: 13px;
  border-radius: 3px;
}

.dv-tip {
  margin-left: auto;
  font-weight: 600;
  color: var(--dv-ink);
  min-height: 16px;
}

@media (prefers-reduced-motion: reduce) {
  .dv-cell { transition: none; transform: scale(1); opacity: 1; }
}
JavaScript
// 直近約半年分の擬似アクティビティを生成し、CSS Gridヒートマップを描画
(() => {
  const cal = document.getElementById('cal');
  const scale = document.getElementById('scale');
  const tip = document.getElementById('tip');
  if (!cal) return; // null安全

  const WEEKS = 26; // 表示週数
  const cssVar = (n) => getComputedStyle(document.documentElement).getPropertyValue(`--h${n}`).trim();
  const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  // 凡例スケールを生成
  if (scale) {
    for (let i = 0; i <= 4; i++) {
      const s = document.createElement('span');
      s.style.background = cssVar(i);
      scale.appendChild(s);
    }
  }

  // 値→5段階レベルへ変換
  function toLevel(v) {
    if (v <= 0) return 0;
    if (v < 3) return 1;
    if (v < 6) return 2;
    if (v < 9) return 3;
    return 4;
  }

  const weekday = ['日', '月', '火', '水', '木', '金', '土'];
  const today = new Date();
  const cells = [];

  // 古い週→新しい週、各週は日曜→土曜の順で生成
  const frag = document.createDocumentFragment();
  for (let w = WEEKS - 1; w >= 0; w--) {
    for (let d = 0; d < 7; d++) {
      const daysAgo = w * 7 + (6 - d);
      const date = new Date(today);
      date.setDate(today.getDate() - daysAgo);

      // 平日は活動多め・週末は少なめのランダム値で自然な見た目に
      const base = (d === 0 || d === 6) ? 2 : 6;
      const val = Math.max(0, Math.round(base * Math.random() * 1.6 - 1));
      const level = toLevel(val);

      const cell = document.createElement('div');
      cell.className = 'dv-cell';
      cell.dataset.level = String(level);
      const dateStr = `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}(${weekday[date.getDay()]})`;
      cell.dataset.info = `${dateStr} ・ ${val}件`;
      cell.title = cell.dataset.info;
      frag.appendChild(cell);
      cells.push(cell);
    }
  }
  cal.appendChild(frag);

  // ホバーで詳細をフッターに表示
  cal.addEventListener('mouseover', (e) => {
    const t = e.target;
    if (t instanceof HTMLElement && t.classList.contains('dv-cell') && tip) {
      tip.textContent = t.dataset.info || '';
    }
  });
  cal.addEventListener('mouseleave', () => { if (tip) tip.textContent = ''; });

  // 波打つように順次フェードイン
  if (reduceMotion) {
    cells.forEach((c) => c.classList.add('show'));
  } else {
    cells.forEach((c, i) => {
      setTimeout(() => c.classList.add('show'), i * 4);
    });
  }
})();

🤖 AIエージェント用プロンプト

このままコピーしてAIに貼り付け「追加する場所」だけ書き換えればOK
あなたは熟練のフロントエンドエンジニアです。私のWebサイトに「ヒートマップカレンダー」の効果を追加してください。

# 追加してほしい効果
ヒートマップカレンダー(データ可視化)
CSS Gridで組む貢献度カレンダー。日別の活動量を5段階の色で示し、コントリビューショングラフ風の活動量可視化に使えます。

# 追加する場所
👉【ここに対象箇所を記入:例「トップのヒーローセクション」「お問い合わせボタン」「記事カードの一覧」など】

# 参考実装(この見た目・挙動を再現してください)
【HTML】
<div class="dv-wrap">
  <figure class="dv-card">
    <figcaption class="dv-head">
      <h2 class="dv-title">アクティビティ・ヒートマップ</h2>
      <p class="dv-sub">CSS Grid で組む貢献度カレンダー(コントリビューショングラフ風)</p>
    </figcaption>
    <!-- グリッドはJSが7行×週数で生成する -->
    <div class="dv-cal" id="cal" role="img" aria-label="日別アクティビティのヒートマップ"></div>
    <div class="dv-foot">
      <span class="dv-foot__txt">少ない</span>
      <div class="dv-scale" id="scale"></div>
      <span class="dv-foot__txt">多い</span>
      <span class="dv-tip" id="tip" aria-live="polite"></span>
    </div>
  </figure>
</div>

【CSS】
:root {
  --dv-radius: 18px;
  --dv-ink: #e2e8f0;
  --dv-sub: #94a3b8;
  /* 5段階のヒートカラー(0=無し) */
  --h0: #1e293b;
  --h1: #0e4429;
  --h2: #006d32;
  --h3: #26a641;
  --h4: #39d353;
  --cell: 15px;
  --gap: 4px;
}

* { box-sizing: border-box; }

body {
  margin: 0;
  min-height: 100vh;
  display: grid;
  place-items: center;
  font-family: "Segoe UI", "Hiragino Kaku Gothic ProN", system-ui, sans-serif;
  color: var(--dv-ink);
  background:
    radial-gradient(900px 500px at 80% -20%, #14532d 0%, transparent 55%),
    linear-gradient(160deg, #0f172a, #020617);
}

.dv-wrap { width: min(94vw, 760px); padding: 20px; }

.dv-card {
  margin: 0;
  padding: 22px 24px 18px;
  border-radius: var(--dv-radius);
  background: rgba(15, 23, 42, 0.55);
  border: 1px solid rgba(148, 163, 184, 0.18);
  box-shadow: 0 24px 60px -24px rgba(0, 0, 0, 0.7);
  backdrop-filter: blur(6px);
}

.dv-head { margin-bottom: 14px; }
.dv-title { margin: 0; font-size: clamp(18px, 3.4vw, 22px); }
.dv-sub { margin: 4px 0 0; font-size: 13px; color: var(--dv-sub); }

/* 列方向に週、各列7セル(縦=曜日) */
.dv-cal {
  display: grid;
  grid-auto-flow: column;
  grid-template-rows: repeat(7, var(--cell));
  gap: var(--gap);
  justify-content: start;
  overflow-x: auto;
  padding-bottom: 6px;
}

.dv-cell {
  width: var(--cell);
  height: var(--cell);
  border-radius: 4px;
  background: var(--h0);
  transform: scale(0.2);
  opacity: 0;
  transition: transform .35s ease, opacity .35s ease, outline-color .15s ease;
  outline: 2px solid transparent;
  outline-offset: 1px;
}
.dv-cell.show { transform: scale(1); opacity: 1; }
.dv-cell[data-level="1"] { background: var(--h1); }
.dv-cell[data-level="2"] { background: var(--h2); }
.dv-cell[data-level="3"] { background: var(--h3); }
.dv-cell[data-level="4"] { background: var(--h4); }
.dv-cell:hover { outline-color: rgba(226, 232, 240, 0.85); }

.dv-foot {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-top: 14px;
  font-size: 12px;
  color: var(--dv-sub);
}
.dv-foot__txt { white-space: nowrap; }

.dv-scale { display: flex; gap: 4px; }
.dv-scale span {
  width: 13px;
  height: 13px;
  border-radius: 3px;
}

.dv-tip {
  margin-left: auto;
  font-weight: 600;
  color: var(--dv-ink);
  min-height: 16px;
}

@media (prefers-reduced-motion: reduce) {
  .dv-cell { transition: none; transform: scale(1); opacity: 1; }
}

【JavaScript】
// 直近約半年分の擬似アクティビティを生成し、CSS Gridヒートマップを描画
(() => {
  const cal = document.getElementById('cal');
  const scale = document.getElementById('scale');
  const tip = document.getElementById('tip');
  if (!cal) return; // null安全

  const WEEKS = 26; // 表示週数
  const cssVar = (n) => getComputedStyle(document.documentElement).getPropertyValue(`--h${n}`).trim();
  const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  // 凡例スケールを生成
  if (scale) {
    for (let i = 0; i <= 4; i++) {
      const s = document.createElement('span');
      s.style.background = cssVar(i);
      scale.appendChild(s);
    }
  }

  // 値→5段階レベルへ変換
  function toLevel(v) {
    if (v <= 0) return 0;
    if (v < 3) return 1;
    if (v < 6) return 2;
    if (v < 9) return 3;
    return 4;
  }

  const weekday = ['日', '月', '火', '水', '木', '金', '土'];
  const today = new Date();
  const cells = [];

  // 古い週→新しい週、各週は日曜→土曜の順で生成
  const frag = document.createDocumentFragment();
  for (let w = WEEKS - 1; w >= 0; w--) {
    for (let d = 0; d < 7; d++) {
      const daysAgo = w * 7 + (6 - d);
      const date = new Date(today);
      date.setDate(today.getDate() - daysAgo);

      // 平日は活動多め・週末は少なめのランダム値で自然な見た目に
      const base = (d === 0 || d === 6) ? 2 : 6;
      const val = Math.max(0, Math.round(base * Math.random() * 1.6 - 1));
      const level = toLevel(val);

      const cell = document.createElement('div');
      cell.className = 'dv-cell';
      cell.dataset.level = String(level);
      const dateStr = `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}(${weekday[date.getDay()]})`;
      cell.dataset.info = `${dateStr} ・ ${val}件`;
      cell.title = cell.dataset.info;
      frag.appendChild(cell);
      cells.push(cell);
    }
  }
  cal.appendChild(frag);

  // ホバーで詳細をフッターに表示
  cal.addEventListener('mouseover', (e) => {
    const t = e.target;
    if (t instanceof HTMLElement && t.classList.contains('dv-cell') && tip) {
      tip.textContent = t.dataset.info || '';
    }
  });
  cal.addEventListener('mouseleave', () => { if (tip) tip.textContent = ''; });

  // 波打つように順次フェードイン
  if (reduceMotion) {
    cells.forEach((c) => c.classList.add('show'));
  } else {
    cells.forEach((c, i) => {
      setTimeout(() => c.classList.add('show'), i * 4);
    });
  }
})();

# 外部ライブラリ
なし(追加ライブラリ不要)

# 守ってほしいこと
- 既存のHTML構造・レイアウト・デザインを壊さないこと。必要に応じてクラス名・色・サイズを私のサイトに合わせて調整してよい。
- クラス名やidが既存と衝突しないよう、必要なら接頭辞で名前空間を分けること。
- レスポンシブ対応と prefers-reduced-motion への配慮を入れること。
- 私のサイトのフレームワーク(React / Vue / 素のHTML など)に合わせて実装すること。不明な場合は素のHTML/CSS/JSで提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。