01Year contribution grid
Fifty-three weeks by seven days fill in from the left, and pointing at a cell shows its date and count. Today's cell goes up from a button and the value stays in localStorage.
Best forA profile page that shows a year of records at a glance, a study-log dashboard
role="grid"aria-labelanimation-delaylocalStorage
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="ico-calendar-check" viewBox="0 0 256 256" fill="currentColor"><g><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-38.34-85.66a8 8 0 0 1 0 11.32l-48 48a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L116 164.69l42.34-42.35a8 8 0 0 1 11.32 0"/></g></symbol>
<symbol id="ico-plus-circle" viewBox="0 0 256 256" fill="currentColor"><g><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M176 128a8 8 0 0 1-8 8h-32v32a8 8 0 0 1-16 0v-32H88a8 8 0 0 1 0-16h32V88a8 8 0 0 1 16 0v32h32a8 8 0 0 1 8 8m56 0A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></symbol>
</defs>
</svg>
<main class="stage">
<div class="cg is-demo" id="cg">
<p class="cg__hint">
<svg class="cg__ico" aria-hidden="true"><use href="#ico-calendar-check"/></svg>
<span>Point at a cell to see its date and count</span>
</p>
<div class="cg__wrap">
<span class="cg__tip" id="tip" role="status" aria-live="polite"></span>
<div class="cg__grid" id="grid" role="grid" aria-label="Habit log grid for the past year"></div>
</div>
<div class="cg__bar">
<p class="cg__legend">
<span class="cg__legendLabel">Per day</span>
<span class="cg__keyItem"><i class="cg__key cg__key--l0"></i><b class="cg__keyNum">0</b></span>
<span class="cg__keyItem"><i class="cg__key cg__key--l1"></i><b class="cg__keyNum">1</b></span>
<span class="cg__keyItem"><i class="cg__key cg__key--l2"></i><b class="cg__keyNum">2</b></span>
<span class="cg__keyItem"><i class="cg__key cg__key--l3"></i><b class="cg__keyNum">3</b></span>
<span class="cg__keyItem"><i class="cg__key cg__key--l4"></i><b class="cg__keyNum">4</b></span>
</p>
<button class="cg__add" id="add" type="button">
<svg class="cg__ico" aria-hidden="true"><use href="#ico-plus-circle"/></svg><span>Raise today</span>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 1년 잔디 격자 — 53주 × 7일. 칸은 JS 가 만들고 강도(0~4)는 시드 고정 난수,
// 오늘 칸만 localStorage 값으로 덮는다. 색 강도는 보조 신호일 뿐이라 범례에 숫자를 같이 적고
// 칸마다 aria-label 에 날짜와 횟수를 넣는다 (WCAG 1.4.1 — 색만으로 정보를 주지 않는다).
// 격자 미리보기(.is-demo)는 주 단위 animation-delay 로 왼쪽부터 차오르는 물결을 CSS 로 재생한다.
// 사람이 격자에 손을 대거나 버튼을 누르면 JS 가 .is-demo 를 떼고 정적 표시로 넘어간다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $stage-yellow;
@include stage($stage-ink);
.cg { display: flex; flex-direction: column; gap: $sp-3; width: 100%; max-width: 424px; }
.cg__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); color: $subject-cream;
}
.cg__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $color; }
// 말풍선은 절대배치라 부모 높이에 안 잡힌다 — padding-top 으로 자리를 미리 비워 둔다 (BD9)
.cg__wrap { position: relative; padding-top: $sp-5; }
.cg__tip {
position: absolute; left: 0; top: 0; height: $sp-4;
display: inline-flex; align-items: center; border-radius: $r-xs;
padding: 0 $sp-2; background: rgba($subject-cream, .12); color: $subject-cream;
font-weight: 700; font-size: clamp(9px, 2.4vw, 11px); white-space: nowrap;
}
.cg__grid { display: flex; flex-direction: column; }
.cg__row { display: flex; }
// 칸 사이 1px 씩은 gap 이 아니라 **투명 테두리**로 벌린다. 53주 × 7일을 424px 안에 넣으려면
// 칸 간격이 4px 격자에 못 맞는데(BD17), 테두리는 간격이 아니라 칸 자신의 일부라 그 규칙 밖이다.
// background-clip: padding-box 가 있어야 배경이 테두리 밑으로 새지 않는다.
.cg__cell {
box-sizing: border-box; width: 7px; height: 7px; flex: 0 0 auto;
border: 1px solid transparent; border-radius: 50%;
background: rgba($subject-cream, .10); background-clip: padding-box;
}
.cg__cell--l1 { background: rgba($color, .30); }
.cg__cell--l2 { background: rgba($color, .52); }
.cg__cell--l3 { background: rgba($color, .76); }
// 가장 높은 단계만 안쪽 테두리를 더해 색 말고 무늬로도 갈린다
.cg__cell--l4 { background: $color; box-shadow: inset 0 0 0 1px rgba($stage-ink, .55); }
.cg__cell.is-today { box-shadow: 0 0 0 2px rgba($subject-cream, .9); }
.cg__bar { display: flex; align-items: center; justify-content: space-between; gap: $sp-2; flex-wrap: wrap; }
.cg__legend { display: flex; align-items: center; gap: $sp-1; margin: 0; }
.cg__legendLabel {
margin-right: $sp-1; font-weight: 700; font-size: clamp(9px, 2.4vw, 11px);
color: rgba($subject-cream, .72);
}
// 숫자를 색 칩 **안**에 넣으면 2단계(노랑 52%) 위에서 어느 글자색도 AA 를 못 넘는다 (실측 4.06·4.21:1).
// 칩은 칸과 같은 모양의 점으로만 두고 숫자는 무대 배경(잉크) 위에 따로 적는다 — 17.11:1.
.cg__keyItem { display: inline-flex; align-items: center; gap: $sp-1; }
.cg__key { width: 10px; height: 10px; border-radius: 50%; }
.cg__keyNum { font-weight: 800; font-size: 9px; line-height: 1; color: $subject-cream; }
.cg__key--l0 { background: rgba($subject-cream, .10); }
.cg__key--l1 { background: rgba($color, .30); }
.cg__key--l2 { background: rgba($color, .52); }
.cg__key--l3 { background: rgba($color, .76); }
.cg__key--l4 { background: $color; box-shadow: inset 0 0 0 1px rgba($stage-ink, .55); }
.cg__add {
display: inline-flex; align-items: center; gap: $sp-1; border: 0; border-radius: $r-pill;
padding: $sp-1 $sp-3; background: $color; color: $subject-ink; cursor: pointer;
font-family: inherit; font-weight: 800; font-size: clamp(10px, 2.6vw, 11px); line-height: 1.6;
}
.cg__add .cg__ico { color: $subject-ink; }
.cg__add:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
// ── 격자 미리보기 물결 ──────────────────────────────────────────
// 주(열)마다 12ms 씩 늦게 시작해 왼쪽부터 차오른다. 0% 와 100% 를 같은 값으로 둬서
// 루프 경계에 점프가 없고, 지연이 걸린 루프에는 fill-mode: backwards 가 반드시 붙는다
// (없으면 지연 구간에 작성 상태가 그대로 한 번 번쩍인다 — 252-08 실측).
.cg.is-demo .cg__cell {
animation-name: cgWave; animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
animation-delay: calc(var(--c) * 12ms);
}
@keyframes cgWave {
0% { transform: scale(.15); opacity: .3; }
14% { transform: scale(1); opacity: 1; }
86% { transform: scale(1); opacity: 1; }
100% { transform: scale(.15); opacity: .3; }
}
@media (max-width: 340px) {
.cg { gap: $sp-2; }
.cg__wrap { padding-top: $sp-4; }
.cg__cell { width: 5px; height: 5px; }
.cg__key { width: 8px; height: 8px; }
}
// reduce 에서는 물결을 끄고 잔디가 다 자란 한 장으로 세운다
@media (prefers-reduced-motion: reduce) {
.cg.is-demo .cg__cell { animation: none !important; transform: none !important; opacity: 1 !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cg {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
max-width: 424px;
}
.cg__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
color: #fff7e6;
}
.cg__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #ffd23f;
}
.cg__wrap {
position: relative;
padding-top: 20px;
}
.cg__tip {
position: absolute;
left: 0;
top: 0;
height: 16px;
display: inline-flex;
align-items: center;
border-radius: 4px;
padding: 0 8px;
background: rgba(255, 247, 230, 0.12);
color: #fff7e6;
font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px);
white-space: nowrap;
}
.cg__grid {
display: flex;
flex-direction: column;
}
.cg__row {
display: flex;
}
.cg__cell {
box-sizing: border-box;
width: 7px;
height: 7px;
flex: 0 0 auto;
border: 1px solid transparent;
border-radius: 50%;
background: rgba(255, 247, 230, 0.1);
background-clip: padding-box;
}
.cg__cell--l1 {
background: rgba(255, 210, 63, 0.3);
}
.cg__cell--l2 {
background: rgba(255, 210, 63, 0.52);
}
.cg__cell--l3 {
background: rgba(255, 210, 63, 0.76);
}
.cg__cell--l4 {
background: #ffd23f;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.55);
}
.cg__cell.is-today {
box-shadow: 0 0 0 2px rgba(255, 247, 230, 0.9);
}
.cg__bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
}
.cg__legend {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
}
.cg__legendLabel {
margin-right: 4px;
font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px);
color: rgba(255, 247, 230, 0.72);
}
.cg__keyItem {
display: inline-flex;
align-items: center;
gap: 4px;
}
.cg__key {
width: 10px;
height: 10px;
border-radius: 50%;
}
.cg__keyNum {
font-weight: 800;
font-size: 9px;
line-height: 1;
color: #fff7e6;
}
.cg__key--l0 {
background: rgba(255, 247, 230, 0.1);
}
.cg__key--l1 {
background: rgba(255, 210, 63, 0.3);
}
.cg__key--l2 {
background: rgba(255, 210, 63, 0.52);
}
.cg__key--l3 {
background: rgba(255, 210, 63, 0.76);
}
.cg__key--l4 {
background: #ffd23f;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.55);
}
.cg__add {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
border-radius: 999px;
padding: 4px 12px;
background: #ffd23f;
color: #17141a;
cursor: pointer;
font-family: inherit;
font-weight: 800;
font-size: clamp(10px, 2.6vw, 11px);
line-height: 1.6;
}
.cg__add .cg__ico {
color: #17141a;
}
.cg__add:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.cg.is-demo .cg__cell {
animation-name: cgWave;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
animation-delay: calc(var(--c) * 12ms);
}
@keyframes cgWave {
0% {
transform: scale(0.15);
opacity: 0.3;
}
14% {
transform: scale(1);
opacity: 1;
}
86% {
transform: scale(1);
opacity: 1;
}
100% {
transform: scale(0.15);
opacity: 0.3;
}
}
@media (max-width: 340px) {
.cg {
gap: 8px;
}
.cg__wrap {
padding-top: 16px;
}
.cg__cell {
width: 5px;
height: 5px;
}
.cg__key {
width: 8px;
height: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.cg.is-demo .cg__cell {
animation: none !important;
transform: none !important;
opacity: 1 !important;
}
}var WEEKS = 53, DAYS = 7, TOTAL = WEEKS * DAYS, STORE = 'godrich-habit-grid';
var root = document.getElementById('cg');
var grid = document.getElementById('grid');
var tip = document.getElementById('tip');
var TIP_TEXT = '{m}/{d} · {n} times';
var TODAY_TEXT = 'Today: {n}';
var CELL_LABEL = '{m}/{d} · {n} times';
var cells = [];
var levels = [];
// 가짜 기록은 시드 고정 난수 — 새로 고쳐도 같은 잔디가 나온다 (mulberry32)
function seeded(seed) {
return function () {
seed = seed + 0x6d2b79f5 | 0;
var t = Math.imul(seed ^ seed >>> 15, 1 | seed);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
function load() {
try { return Number(window.localStorage.getItem(STORE)) || 0; } catch (e) { return 0; }
}
function save(n) {
try { window.localStorage.setItem(STORE, String(n)); } catch (e) { /* 시크릿 창에서는 저장만 건너뛴다 */ }
}
function dateOf(index) {
var d = new Date();
d.setHours(12, 0, 0, 0);
d.setDate(d.getDate() - (TOTAL - 1 - index));
return d;
}
function label(index, level) {
var d = dateOf(index);
return CELL_LABEL.replace('{m}', String(d.getMonth() + 1)).replace('{d}', String(d.getDate())).replace('{n}', String(level));
}
function build() {
var rnd = seeded(20260912);
for (var i = 0; i < TOTAL; i++) {
var v = rnd();
levels.push(v > 0.82 ? 4 : v > 0.68 ? 3 : v > 0.48 ? 2 : v > 0.3 ? 1 : 0);
}
levels[TOTAL - 1] = load();
for (var r = 0; r < DAYS; r++) {
var row = document.createElement('div');
row.className = 'cg__row';
row.setAttribute('role', 'row');
for (var c = 0; c < WEEKS; c++) {
var index = c * DAYS + r;
var cell = document.createElement('span');
cell.className = 'cg__cell cg__cell--l' + levels[index];
cell.setAttribute('role', 'gridcell');
cell.setAttribute('aria-label', label(index, levels[index]));
cell.style.setProperty('--c', String(c));
if (index === TOTAL - 1) { cell.classList.add('is-today'); }
cells[index] = cell;
row.appendChild(cell);
}
grid.appendChild(row);
}
}
function paintToday() {
var index = TOTAL - 1, cell = cells[index];
cell.className = 'cg__cell cg__cell--l' + levels[index] + ' is-today';
cell.setAttribute('aria-label', label(index, levels[index]));
tip.textContent = TODAY_TEXT.replace('{n}', String(levels[index]));
}
function show(cell) {
var index = cells.indexOf(cell);
if (index < 0) { return; }
var d = dateOf(index);
tip.textContent = TIP_TEXT.replace('{m}', String(d.getMonth() + 1)).replace('{d}', String(d.getDate())).replace('{n}', String(levels[index]));
}
build();
paintToday();
grid.addEventListener('pointermove', function (e) {
root.classList.remove('is-demo');
if (e.target !== grid) { show(e.target); }
});
grid.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
if (e.target !== grid) { show(e.target); }
});
document.getElementById('add').addEventListener('click', function () {
root.classList.remove('is-demo');
var index = TOTAL - 1;
levels[index] = levels[index] >= 4 ? 0 : levels[index] + 1;
save(levels[index]);
paintToday();
});// ContributionGrid — vanilla 판(run/297/demo/01-habit-contribution-grid/index.html) 의 React 포트.
// 53주 × 7일 칸을 시드 고정 난수로 채우고 오늘 칸만 localStorage 값으로 덮는다. 값·수치는 vanilla 와 같다.
// 사람이 격자에 손을 대거나 버튼을 누르면 미리보기 루프를 떼고 정적 표시로 넘어간다.
import { useEffect, useMemo, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import styles from "./ContributionGrid.module.scss";
export type ContributionGridProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type CellVars = CSSProperties & { "--c"?: string };
const WEEKS = 53;
const DAYS = 7;
const TOTAL = WEEKS * DAYS;
const STORE = "godrich-habit-grid";
const HINT = "칸에 손을 올리면 날짜와 횟수가 보입니다";
const GRID_LABEL = "최근 1년 습관 기록 격자";
const LEGEND_LABEL = "하루 완료 수";
const ADD_TEXT = "오늘 칸 올리기";
const CELL_LABEL = "{m}월 {d}일 · {n}회";
const TODAY_TEXT = "오늘 {n}회";
// 가짜 기록은 시드 고정 난수 — 새로 고쳐도 같은 잔디가 나온다 (mulberry32)
function seeded(seed: number): () => number {
let s = seed;
return () => {
s = (s + 0x6d2b79f5) | 0;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function baseLevels(): number[] {
const rnd = seeded(20260912);
const out: number[] = [];
for (let i = 0; i < TOTAL; i++) {
const v = rnd();
out.push(v > 0.82 ? 4 : v > 0.68 ? 3 : v > 0.48 ? 2 : v > 0.3 ? 1 : 0);
}
return out;
}
function dateOf(index: number): Date {
const d = new Date();
d.setHours(12, 0, 0, 0);
d.setDate(d.getDate() - (TOTAL - 1 - index));
return d;
}
function label(index: number, level: number): string {
const d = dateOf(index);
return CELL_LABEL.replace("{m}", String(d.getMonth() + 1))
.replace("{d}", String(d.getDate()))
.replace("{n}", String(level));
}
export default function ContributionGrid({
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#ffd23f",
}: ContributionGridProps) {
const base = useMemo(baseLevels, []);
const [today, setToday] = useState(0);
const [tip, setTip] = useState("");
const [demo, setDemo] = useState(true);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
try {
const raw = window.localStorage.getItem(STORE);
if (raw) { setToday(Number(raw) || 0); }
} catch (e) { /* 시크릿 창에서는 기본값 그대로 간다 */ }
}, []);
function levelAt(index: number): number {
return index === TOTAL - 1 ? today : base[index];
}
function save(n: number) {
try { window.localStorage.setItem(STORE, String(n)); } catch (e) { /* 저장만 건너뛴다 */ }
}
function raise() {
setDemo(false);
const next = today >= 4 ? 0 : today + 1;
setToday(next);
save(next);
setTip(TODAY_TEXT.replace("{n}", String(next)));
}
function onPointerMove(event: ReactPointerEvent<HTMLDivElement>) {
setDemo(false);
const el = event.target as HTMLElement;
const raw = el.getAttribute("data-index");
if (raw === null) { return; }
const index = Number(raw);
setTip(label(index, levelAt(index)));
}
const levelClass = [styles.cell, styles.l1, styles.l2, styles.l3, styles.l4];
const keyClass = [styles.key0, styles.key1, styles.key2, styles.key3, styles.key4];
const rows: number[] = [0, 1, 2, 3, 4, 5, 6];
const weeks: number[] = [];
for (let c = 0; c < WEEKS; c++) { weeks.push(c); }
const cls = [styles.root, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
const cellVars = (c: number): CellVars => ({ "--c": String(c) });
return (
<main className={styles.stage}>
<svg width="0" height="0" style={{ position: "absolute" }} aria-hidden="true">
<defs>
<symbol id="ico-calendar-check" viewBox="0 0 256 256" fill="currentColor"><g><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-38.34-85.66a8 8 0 0 1 0 11.32l-48 48a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L116 164.69l42.34-42.35a8 8 0 0 1 11.32 0"/></g></symbol>
<symbol id="ico-plus-circle" viewBox="0 0 256 256" fill="currentColor"><g><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M176 128a8 8 0 0 1-8 8h-32v32a8 8 0 0 1-16 0v-32H88a8 8 0 0 1 0-16h32V88a8 8 0 0 1 16 0v32h32a8 8 0 0 1 8 8m56 0A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></symbol>
</defs>
</svg>
<div className={cls} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} aria-hidden="true"><use href="#ico-calendar-check" /></svg>
<span>{HINT}</span>
</p>
<div className={styles.wrap}>
<span className={styles.tip} role="status" aria-live="polite">{tip}</span>
<div className={styles.grid} role="grid" aria-label={GRID_LABEL} onPointerMove={onPointerMove}>
{rows.map((r) => (
<div className={styles.row} role="row" key={r}>
{weeks.map((c) => {
const index = c * DAYS + r;
const level = levelAt(index);
const isToday = index === TOTAL - 1;
const cn = [levelClass[0], level > 0 ? levelClass[level] : "", isToday ? styles.today : ""]
.filter(Boolean).join(" ");
return (
<span
className={cn}
key={c}
role="gridcell"
data-index={index}
aria-label={label(index, level)}
style={cellVars(c)}
/>
);
})}
</div>
))}
</div>
</div>
<div className={styles.bar}>
<p className={styles.legend}>
<span className={styles.legendLabel}>{LEGEND_LABEL}</span>
{[0, 1, 2, 3, 4].map((n) => (
<span className={styles.keyItem} key={n}>
<i className={`${styles.key} ${keyClass[n]}`} />
<b className={styles.keyNum}>{n}</b>
</span>
))}
</p>
<button className={styles.add} type="button" onClick={raise}>
<svg className={styles.ico} aria-hidden="true"><use href="#ico-plus-circle" /></svg>
<span>{ADD_TEXT}</span>
</button>
</div>
</div>
</main>
);
}01 Year contribution grid — Measured area 9.524% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · Habit Tracker UI: 9 Parts from Grid to Badge


