ヒートマップカレンダー
CSS Gridで組む貢献度カレンダー。日別の活動量を5段階の色で示し、コントリビューショングラフ風の活動量可視化に使えます。
ライブデモ
使用例(お題: カフェ 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で提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。