07Contribution heatmap calendar
A year lays out as small squares that darken with activity, filling in column by column from the left.
Best forFor streaks, commits or habits where one picture has to answer 'how consistent was I'
grid-template-columnsaria-labelscale

<main class="stage">
<section class="hm is-demo" id="hm" aria-label="2026 activity heatmap">
<div class="hm__months" id="hm-months" aria-hidden="true"></div>
<div class="hm__board" id="hm-board" role="grid" aria-label="2026 activity"></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>Less</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>More</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); // 1M 1일이 속한 주의 Monday — 격자의 첫 칸
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); // 12M 마감 몰림
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++) { // 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 Contribution heatmap calendar — Measured area 10.424% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · 9 css calendar design patterns that really move
The post this demo lives in
9 css calendar design patterns that really movecss calendar design often stops at restyling the date picker the browser gives you. These nine build the whole month gri See all demos in this post

