07濃さで読むヒートマップ暦
1年が小さな四角の格子として並び、活動が多い日ほど色が濃くなり、左の列から順に埋まります。
使いどころ出席・コミット・習慣のように「どれだけ続いたか」を1枚で見せる場所に付けるとき
grid-template-columnsaria-labelscale

<main class="stage">
<section class="hm is-demo" id="hm" aria-label="2026年 アクティビティヒートマップ">
<div class="hm__months" id="hm-months" aria-hidden="true"></div>
<div class="hm__board" id="hm-board" role="grid" aria-label="2026年 アクティビティ"></div>
<footer class="hm__foot">
<span class="hm__year"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="13" height="13" aria-hidden="true"><g fill="currentColor"><path d="M216 48v40H40V48a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 32h-24v-8a8 8 0 0 0-16 0v8H88v-8a8 8 0 0 0-16 0v8H48a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16M72 48v8a8 8 0 0 0 16 0v-8h80v8a8 8 0 0 0 16 0v-8h24v32H48V48Zm136 160H48V96h160zm-68-76a12 12 0 1 1-12-12a12 12 0 0 1 12 12m44 0a12 12 0 1 1-12-12a12 12 0 0 1 12 12m-88 40a12 12 0 1 1-12-12a12 12 0 0 1 12 12m44 0a12 12 0 1 1-12-12a12 12 0 0 1 12 12m44 0a12 12 0 1 1-12-12a12 12 0 0 1 12 12"/></g></svg>2026</span>
<p class="hm__legend"><span>少ない</span><i class="hm__sw hm__sw--1" aria-hidden="true"></i><i class="hm__sw hm__sw--2" aria-hidden="true"></i><i class="hm__sw hm__sw--3" aria-hidden="true"></i><i class="hm__sw hm__sw--4" aria-hidden="true"></i><span>多い</span></p>
</footer>
<span class="hm__live" role="status" id="hm-live"></span>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
@use "sass:color";
$duration: $dur-quick; // 칸 팝 하나의 길이 (참조용 — 루프는 $dur-loop)
$easing: $ease-spring;
$color: $subject-ink; // 옐로 무대 위 잉크 피사체
@include stage($stage-yellow);
.hm {
width: min(100%, 432px);
}
// 월 눈금 — 아래 격자와 같은 53열
.hm__months {
display: grid;
grid-template-columns: repeat(53, 1fr);
margin: 0 0 $sp-1;
color: color.mix($color, $stage-yellow, 85%); // 60% 는 4.22:1 이었다 — AA 4.5 (8.96:1)
font-size: clamp(7px, 1.8vw, 9px);
line-height: 1;
font-weight: 700;
}
// 7행 × 53열 — 한 행이 한 요일, 한 열이 한 주
// 행(role="row")이 요일이라 DOM 도 요일 단위다. 열 우선 흐름(grid-auto-flow: column)을 쓰면
// DOM 순서 그대로 같은 요일 7칸이 한 열에 쌓여 격자가 전치된다 — 53열을 명시하고 기본 행 흐름으로 채운다.
.hm__board {
display: grid;
grid-template-columns: repeat(53, 1fr);
}
.hm__row { display: contents; } // 시맨틱 행은 남기고 격자에는 칸만 참여
.hm__cell,
.hm__blank {
aspect-ratio: 1;
border-radius: 0;
}
.hm__cell {
background: color.mix($color, $stage-yellow, 14%);
box-shadow: inset 0 0 0 1px $stage-yellow; // 칸 사이 1px 무대색 실린더
transform-origin: center;
}
.hm__cell.lv1 { background: color.mix($color, $stage-yellow, 30%); }
.hm__cell.lv2 { background: color.mix($color, $stage-yellow, 48%); }
.hm__cell.lv3 { background: color.mix($color, $stage-yellow, 70%); }
.hm__cell.lv4 { background: $color; }
.hm__cell:focus-visible {
outline: 2px solid $color;
outline-offset: 1px;
}
.hm__cell.is-today {
outline: 2px solid $color;
outline-offset: 1px;
}
.hm__foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-2;
margin: $sp-2 0 0;
}
.hm__year {
display: inline-flex;
align-items: center;
gap: $sp-1;
font-size: 11px;
line-height: 1;
font-weight: 800;
color: $color;
}
.hm__legend {
display: inline-flex;
align-items: center;
gap: $sp-1;
margin: 0;
font-size: 10px;
line-height: 1;
font-weight: 700;
color: color.mix($color, $stage-yellow, 85%);
}
.hm__sw {
width: 10px;
height: 10px;
border-radius: $r-xs;
box-shadow: inset 0 0 0 1px color.mix($color, $stage-yellow, 30%);
}
.hm__sw--1 { background: color.mix($color, $stage-yellow, 30%); }
.hm__sw--2 { background: color.mix($color, $stage-yellow, 48%); }
.hm__sw--3 { background: color.mix($color, $stage-yellow, 70%); }
.hm__sw--4 { background: $color; }
.hm__live {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
// ── 자동 루프: 왼쪽 열부터 차례로 찬다 → 전부 찬다 → 같은 순서로 빠진다.
// 0% 와 100% 가 같은 상태(빈 판)라 이어 붙여도 텔레포트가 없다.
// 시차는 칸마다 인라인 animation-delay — 속성은 낱개로만 쓴다(단축은 delay 를 0s 로 리셋).
.hm.is-demo .hm__cell {
animation-name: hm-cell;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes hm-cell {
0%, 4.5% { transform: scale(0); }
9%, 52% { transform: scale(1); }
56.5%, 100% { transform: scale(0); }
}
@media (prefers-reduced-motion: reduce) {
.hm.is-demo .hm__cell { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.hm {
width: min(100%, 432px);
}
.hm__months {
display: grid;
grid-template-columns: repeat(53, 1fr);
margin: 0 0 4px;
color: rgb(22.6666666667%, 19.0196078431%, 12.3725490196%);
font-size: clamp(7px, 1.8vw, 9px);
line-height: 1;
font-weight: 700;
}
.hm__board {
display: grid;
grid-template-columns: repeat(53, 1fr);
}
.hm__row {
display: contents;
}
.hm__cell,
.hm__blank {
aspect-ratio: 1;
border-radius: 0;
}
.hm__cell {
background: rgb(87.262745098%, 71.9215686275%, 22.6745098039%);
box-shadow: inset 0 0 0 1px #ffd23f;
transform-origin: center;
}
.hm__cell.lv1 {
background: rgb(72.7058823529%, 60%, 20.3529411765%);
}
.hm__cell.lv2 {
background: rgb(56.3294117647%, 46.5882352941%, 17.7411764706%);
}
.hm__cell.lv3 {
background: rgb(36.3137254902%, 30.1960784314%, 14.5490196078%);
}
.hm__cell.lv4 {
background: #17141a;
}
.hm__cell:focus-visible {
outline: 2px solid #17141a;
outline-offset: 1px;
}
.hm__cell.is-today {
outline: 2px solid #17141a;
outline-offset: 1px;
}
.hm__foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin: 8px 0 0;
}
.hm__year {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
line-height: 1;
font-weight: 800;
color: #17141a;
}
.hm__legend {
display: inline-flex;
align-items: center;
gap: 4px;
margin: 0;
font-size: 10px;
line-height: 1;
font-weight: 700;
color: rgb(22.6666666667%, 19.0196078431%, 12.3725490196%);
}
.hm__sw {
width: 10px;
height: 10px;
border-radius: 4px;
box-shadow: inset 0 0 0 1px rgb(72.7058823529%, 60%, 20.3529411765%);
}
.hm__sw--1 {
background: rgb(72.7058823529%, 60%, 20.3529411765%);
}
.hm__sw--2 {
background: rgb(56.3294117647%, 46.5882352941%, 17.7411764706%);
}
.hm__sw--3 {
background: rgb(36.3137254902%, 30.1960784314%, 14.5490196078%);
}
.hm__sw--4 {
background: #17141a;
}
.hm__live {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.hm.is-demo .hm__cell {
animation-name: hm-cell;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes hm-cell {
0%, 4.5% {
transform: scale(0);
}
9%, 52% {
transform: scale(1);
}
56.5%, 100% {
transform: scale(0);
}
}
@media (prefers-reduced-motion: reduce) {
.hm.is-demo .hm__cell {
animation: none !important;
}
}// 실제 동작: 1년치 칸을 만들고, 화살표(←→ 주 · ↑↓ 요일)로 옮기고, 포커스를 읽어 준다.
var root = document.getElementById('hm');
var board = document.getElementById('hm-board');
var months = document.getElementById('hm-months');
var live = document.getElementById('hm-live');
var START = new Date(2025, 11, 29); // 1月 1일이 속한 주의 月曜日 — 격자의 첫 칸
var NCOLS = 53, TODAY = new Date(2026, 8, 12);
var cells = []; // DOM 순서(행=요일 × 53열), 빈칸은 null
var seed = 7;
function rnd() { seed = (seed * 48271) % 2147483647; return seed / 2147483647; }
function levelOf(d) {
var v = rnd(), dow = d.getDay(), m = d.getMonth();
if (dow === 0) v *= .35; // 일요일은 쉰다
else if (dow === 6) v *= .55;
if (m === 6 || (m === 7 && d.getDate() < 15)) v *= .2; // 여름 공백
if (m === 11) v = Math.min(1, v * 1.4 + .1); // 12月 마감 몰림
if (rnd() < .12) v = Math.min(1, v + .45); // 가끔 폭발
return { level: v < .16 ? 0 : v < .36 ? 1 : v < .56 ? 2 : v < .76 ? 3 : 4, count: Math.round(v * 9) };
}
function pad(n) { return n < 10 ? '0' + n : '' + n; }
for (var d = 0; d < 7; d++) {
var row = document.createElement('div');
row.className = 'hm__row';
row.setAttribute('role', 'row');
for (var w = 0; w < NCOLS; w++) {
var date = new Date(START.getFullYear(), START.getMonth(), START.getDate() + w * 7 + d);
if (date.getFullYear() !== 2026) {
var blank = document.createElement('span');
blank.className = 'hm__blank';
blank.setAttribute('aria-hidden', 'true');
row.appendChild(blank); cells.push(null); continue;
}
var r = levelOf(date), cell = document.createElement('span');
cell.className = 'hm__cell lv' + r.level;
cell.setAttribute('role', 'gridcell');
cell.setAttribute('aria-label', '2026-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate()) + ' · ' + r.count);
cell.tabIndex = -1;
// 시차: 왼쪽 열부터 0.7초에 걸쳐 차례로 (낱개 속성 — animation 단축은 delay 를 0s 로 리셋한다)
cell.style.animationDelay = (w * 13.5).toFixed(1) + 'ms';
if (+date === +TODAY) { cell.classList.add('is-today'); cell.setAttribute('aria-current', 'date'); }
row.appendChild(cell); cells.push(cell);
}
board.appendChild(row);
}
for (var m = 1; m <= 12; m++) { // 月 눈금 — 열 위치에 맞춰 놓는다
var w0 = Math.floor((new Date(2026, m - 1, 1) - START) / 6048e5);
var w1 = m < 12 ? Math.floor((new Date(2026, m, 1) - START) / 6048e5) : NCOLS;
var tick = document.createElement('span');
tick.textContent = m;
tick.style.gridColumn = (w0 + 1) + ' / ' + (w1 + 1);
months.appendChild(tick);
}
var cur = 0;
while (cur < cells.length && !cells[cur]) cur++;
cells[cur].tabIndex = 0;
function move(n) {
cells[cur].tabIndex = -1;
cur = n;
cells[cur].tabIndex = 0;
cells[cur].focus({ preventScroll: true });
live.textContent = cells[cur].getAttribute('aria-label');
}
function step(i, delta) { // 빈칸은 건너뛴다
i += delta;
while (i >= 0 && i < cells.length && !cells[i]) i += delta;
return i >= 0 && i < cells.length ? i : null;
}
board.addEventListener('keydown', function (e) {
var k = e.key, n = null, base = cur - (cur % NCOLS);
if (k === 'ArrowRight') n = step(cur, 1);
else if (k === 'ArrowLeft') n = step(cur, -1);
else if (k === 'ArrowDown') n = step(cur, NCOLS);
else if (k === 'ArrowUp') n = step(cur, -NCOLS);
else if (k === 'Home') n = step(base - 1, 1);
else if (k === 'End') n = step(base + NCOLS, -1);
if (n !== null && n !== cur) { e.preventDefault(); root.classList.remove('is-demo'); move(n); }
});
board.addEventListener('click', function (e) {
var t = e.target.closest ? e.target.closest('.hm__cell') : null;
if (!t) return;
root.classList.remove('is-demo');
for (var i = 0; i < cells.length; i++) if (cells[i] === t) { move(i); break; }
});import { useMemo, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./HeatmapCalendar.module.scss";
export type HeatmapCalendarProps = {
year?: number;
demo?: boolean;
duration?: number; // 루프 한 바퀴 ms — 칸 시차 폭이 이 값에 비례한다
easing?: string;
color?: string; // 칸 색 (투명도 단계로 진하기를 낸다)
stage?: string; // 칸 사이 이격 실린더 색 (무대 배경색)
};
type KeyEvt = { key: string; preventDefault: () => void };
type Cell = { week: number; level: number; label: string; today: boolean };
type Pos = [number, number];
type Vars = CSSProperties & { "--color"?: string; "--stage"?: string };
const LEVEL_OPACITY = [0.14, 0.3, 0.48, 0.7, 1];
function pad(n: number): string {
return n < 10 ? "0" + n : "" + n;
}
export default function HeatmapCalendar({
year = 2026,
demo = true,
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#17141a",
stage = "#ffd23f",
}: HeatmapCalendarProps) {
const [demoOn, setDemoOn] = useState(demo);
const cellRefs = useRef<(HTMLSpanElement | null)[][]>([]);
const [cur, setCur] = useState<Pos | null>(null);
const { rows, months, first } = useMemo(() => {
const jan1 = new Date(year, 0, 1);
const start = new Date(jan1);
start.setDate(jan1.getDate() - ((jan1.getDay() + 6) % 7)); // 1월 1일이 속한 주의 월요일
let seed = 7;
const rnd = () => {
seed = (seed * 48271) % 2147483647;
return seed / 2147483647;
};
const now = new Date();
const grid: (Cell | null)[][] = [];
for (let d = 0; d < 7; d++) {
const row: (Cell | null)[] = [];
for (let w = 0; w < 53; w++) {
const date = new Date(start.getFullYear(), start.getMonth(), start.getDate() + w * 7 + d);
if (date.getFullYear() !== year) {
row.push(null);
continue;
}
let v = rnd();
const dow = date.getDay(), m = date.getMonth();
if (dow === 0) v *= 0.35; // 일요일은 쉰다
else if (dow === 6) v *= 0.55;
if (m === 6 || (m === 7 && date.getDate() < 15)) v *= 0.2; // 여름 공백
if (m === 11) v = Math.min(1, v * 1.4 + 0.1); // 12월 마감 몰림
if (rnd() < 0.12) v = Math.min(1, v + 0.45); // 가끔 폭발
row.push({
week: w,
level: v < 0.16 ? 0 : v < 0.36 ? 1 : v < 0.56 ? 2 : v < 0.76 ? 3 : 4,
label: year + "-" + pad(date.getMonth() + 1) + "-" + pad(date.getDate()) + " · " + Math.round(v * 9),
today: date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth() && date.getDate() === now.getDate(),
});
}
grid.push(row);
}
let firstCell: Pos = [0, 2];
for (let d = 0; d < 7; d++) {
const w = grid[d].findIndex((c) => c !== null);
if (w >= 0) { firstCell = [d, w]; break; }
}
const ticks: { m: number; col: string }[] = [];
for (let m = 1; m <= 12; m++) {
const w0 = Math.floor((new Date(year, m - 1, 1).getTime() - start.getTime()) / 604800000);
const w1 = m < 12 ? Math.floor((new Date(year, m, 1).getTime() - start.getTime()) / 604800000) : 53;
ticks.push({ m, col: (w0 + 1) + " / " + (w1 + 1) });
}
return { rows: grid, months: ticks, first: firstCell };
}, [year]);
const active: Pos = cur ?? first;
function cellEl(d: number, w: number): HTMLSpanElement | null {
const grid = cellRefs.current;
if (!grid || d < 0 || d > 6 || w < 0 || w > 52) return null;
return grid[d] ? grid[d][w] : null;
}
function walk(d: number, w: number, dd: number, dw: number): Pos | null {
let nd = d + dd, nw = w + dw;
while (nd >= 0 && nd < 7 && nw >= 0 && nw < 53) {
if (rows[nd][nw]) return [nd, nw];
nd += dd;
nw += dw;
}
return null;
}
function move(d: number, w: number) {
setDemoOn(false);
setCur([d, w]);
const el = cellEl(d, w);
if (el) el.focus({ preventScroll: true });
}
function onKeyDown(e: KeyEvt) {
const d = active[0], w = active[1];
let next: Pos | null = null;
if (e.key === "ArrowRight") next = walk(d, w, 0, 1); // ←→ 주 단위
else if (e.key === "ArrowLeft") next = walk(d, w, 0, -1);
else if (e.key === "ArrowDown") next = walk(d, w, 1, 0); // ↑↓ 요일 단위
else if (e.key === "ArrowUp") next = walk(d, w, -1, 0);
else if (e.key === "Home") next = walk(d, 0, 0, 1);
else if (e.key === "End") next = walk(d, 52, 0, -1);
if (next) {
e.preventDefault();
move(next[0], next[1]);
}
}
const vars: Vars = { "--color": color, "--stage": stage };
return (
<section
className={`${styles.hm} ${demoOn ? styles.demo : ""}`}
style={vars}
aria-label={year + " activity heatmap"}
>
<div className={styles.months} aria-hidden="true">
{months.map((t) => (
<span key={t.m} style={{ gridColumn: t.col }}>{t.m}</span>
))}
</div>
<div className={styles.board} role="grid" aria-label={year + " activity"} onKeyDown={onKeyDown}>
{rows.map((row, d) => (
<div key={d} className={styles.row} role="row">
{row.map((c, w) =>
c ? (
<span
key={w}
ref={(el: HTMLSpanElement | null) => {
const grid = cellRefs.current;
if (!grid) return;
if (!grid[d]) grid[d] = [];
grid[d][w] = el;
}}
className={styles.cell}
role="gridcell"
aria-label={c.label}
aria-current={c.today ? "date" : undefined}
tabIndex={active[0] === d && active[1] === w ? 0 : -1}
style={{
opacity: LEVEL_OPACITY[c.level],
animationDelay: ((c.week / 52) * duration * 0.35).toFixed(1) + "ms",
animationTimingFunction: easing,
}}
onClick={() => move(d, w)}
/>
) : (
<span key={w} className={styles.blank} aria-hidden="true" />
)
)}
</div>
))}
</div>
<footer className={styles.foot}>
<span className={styles.year}>{year}</span>
<p className={styles.legend}>
<span>Less</span>
{LEVEL_OPACITY.slice(1).map((o, i) => (
<i key={i} className={styles.sw} style={{ opacity: o }} aria-hidden="true" />
))}
<span>More</span>
</p>
</footer>
</section>
);
}07 濃さで読むヒートマップ暦 — 計測 累積面積 10.424% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · カレンダー css デザイン 9選 — 実際に月が動く
このデモが属する記事
カレンダー css デザイン 9選 — 実際に月が動くカレンダー css デザイン というと、ブラウザが用意する小さな日付ポップアップの色を変えるところで終わりがちです。ここで作る9つは1か月をまるごと広げる月間グリッドそのもので、どれも本当に動きます。1つは月を送るときに2枚のグリッドが一緒 この記事のデモをすべて見る

