Design Lounge
Install

Nº 332 of 520

Paper contribution heatmap

A year-long contribution heatmap on warm paper with day tooltips, arrow-key navigation, a year switcher, streak stats and three ink themes that ripple across the grid.

Preview

Open full size
1280 × 800 · 15.3 KB · 251 lines

Use it

Free to use · credit appreciated

1 Once, in your project

npx skills add SusanAcharya/Design-Lounge

2 Then ask

Using the Design Lounge skill, build "Paper contribution heatmap" (piece social-contribution-graph) in my stack. Match its brief; restyle it onto my product's theme.

No skill? Paste the brief instead. It carries the numbers, tokens and behaviour.

Brief

193 lines

Build brief for a coding agent. Rebuild this piece in the reader’s stack. If they haven’t said which stack, ask once, then default to semantic HTML + CSS + a little vanilla JS. Match the numbers below; don’t “improve” them.

What it is

The contribution calendar on a developer’s profile at “Mossgrid”, an invented code host. It is set like a printed almanac rather than a dashboard: a warm paper sheet, a serif headline (“1,312 contributions in 2025”), monospaced labels, and 53 × 7 squares in five steps of one ink. A year switcher (2024 / 2025 / 2026) rebuilds the grid with a left-to-right wave. Three theme swatches (Meadow green, Clay terracotta, Tide blue) recolour every cell, staggered 4ms per week, so the change sweeps across the year instead of snapping. Below the grid sit four stats: current streak, longest streak, busiest day and active days. The detail worth copying is that the grid is one tab stop: focus it, then move day by day with arrow keys while the same tooltip used for hover follows the focused day.

Reference behaviour

  1. Initial state: year 2025, theme Meadow. Cells rise in (opacity 0 → 1, translateY 4px → 0, scale .6 → 1, 500ms expo out) staggered 7ms per week column. The headline total and the stats count up over 700ms (cubic out).
  2. Hover a day. A dark tooltip appears 10px above the cell, centred, with “14 contributions · Tue 12 Aug 2025” (or “No contributions · …”). The hovered cell gets a 1.5px ink ring. The tooltip is clamped 8px inside the sheet edges.
  3. Leave the grid and the tooltip fades out in 120ms.
  4. Tab to the grid. The last past day becomes active: it scales to 1.35 with a paper gap and an ink ring, and the tooltip shows. ↑/↓ move one day, ←/→ move one week, Home/End jump to the first and last past day. A polite live region reads the tooltip text. On narrow screens the scroller follows the active day.
  5. Click 2026 (or use arrows inside the year radiogroup). The grid rebuilds with the wave. Days after today (3 Oct 2026) render as empty 1px-outlined squares and cannot be focused. The scroller jumps to the end so today is visible. The first stat label reads “Current streak”. For 2024 and 2025 it reads “Streak at year end”.
  6. Click the Clay swatch. Cell colours, the italic year in the headline, and the streak number crossfade over 300ms, each cell delayed by week × 4ms.
  7. Reduced motion: no rise-in, no ripple, no count-up. Values and colours apply at once.

Structure

page 1280 × 800, #F3EFE3 + 4px paper grain; sheet 968 max, radius 3, padding 36/40/30
┌──────────────────────────────────────────────────────────────────────────────┐
│ ottoline · Mossgrid · Lisbon                     ( 2024 [2025] 2026 ) (◐)(◐)(◐)│
│ 1,312 contributions in 2025          Spectral 40                             │
│ ──────────────────────────────────────────────────────────────────────────── │ margin 24/20
│      Jan     Feb     Mar     Apr  …                                   Dec    │ 11px mono
│ Mon  ■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■             │ 13px cells
│ Wed  ■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■             │ 3px gap
│ Fri  ■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■             │ 7 rows
│ Hover a day, or focus the grid and use the arrow keys.      Less ■■■■■ More │
│ ──────────────────────────────────────────────────────────────────────────── │
│ STREAK AT YEAR END │ LONGEST STREAK │ BUSIEST DAY    │ ACTIVE DAYS           │
│ 3 days             │ 13 days        │ 18 20 Feb      │ 213 of 365            │
└──────────────────────────────────────────────────────────────────────────────┘
  • section.sheet labelled by the h1.
  • Year switcher: div role="radiogroup" aria-label="Year" of three button role="radio", roving tabindex.
  • Theme switcher: div role="radiogroup" aria-label="Colour theme" of three 34px round buttons, each with a half/half conic swatch (mid and darkest step).
  • Grid: .scroll (overflow-x auto) > .graph (2-column grid: 30px day labels, cells). Cells are i elements in a CSS grid with grid-template-rows: repeat(7, 13px); grid-auto-flow: column. The cell container is tabindex="0", role="group", aria-roledescription="calendar heatmap".
  • Leading blank cells (.pad, visibility: hidden) push 1 January onto its weekday row.
  • Month labels are absolutely positioned at week × 16px above the first week containing the 1st.
  • Stats: dl with four groups split by 1px rules.
  • Tooltip: one absolutely positioned div in the sheet, aria-hidden. The live region carries the text.

Tokens

:root {
  --bg: #f3efe3;        /* page */
  --paper: #faf7ee;     /* sheet */
  --ink: #22302a;       /* text, tooltip, active year */
  --ink-2: #46544c;
  --ink-3: #6b7770;     /* labels */
  --line: #ddd6c3;      /* rules, future-day outline */
  --c0: #e6e1d0;        /* zero */
  --c1: #c5d9a6; --c2: #8fbf6a; --c3: #4f9a45; --c4: #24642f;   /* Meadow */
  --serif: "Spectral", Georgia, serif;
  --mono: "DM Mono", ui-monospace, monospace;
  --cell: 13px; --gap: 3px;            /* column pitch 16px */
  --r-sheet: 3px; --r-cell: 2px; --r-pill: 999px;
  --ease: cubic-bezier(0.2, 0.7, 0.2, 1);
  --expo: cubic-bezier(0.16, 1, 0.3, 1);
}
body[data-theme="clay"] { --c1: #efc9a8; --c2: #e09a6b; --c3: #c0603a; --c4: #7f3420; }
body[data-theme="tide"] { --c1: #bcd6dc; --c2: #7fb2c0; --c3: #3f8399; --c4: #1d4f63; }

Level thresholds: 0 → c0, 1–2 → c1, 3–5 → c2, 6–10 → c3, 11+ → c4.

Typography

RoleFamilySize / lineWeightTrackingNotes
Owner lineDM Mono12px400, handle 5000.06emink-3
HeadlineSpectral40 / 1.05400, number 600−0.015em“in 2025” italic in --c4
Year pillDM Mono13px400034px tall
Month / day labelsDM Mono11px / 10px4000ink-3
Hint, legendDM Mono11px4000ink-3
Stat labelDM Mono11px4000.08emuppercase
Stat valueSpectral30 / 14000lining tabular nums. Unit in 12px mono
TooltipDM Mono12 / 1.3400, count 5000paper on ink

Motion

ThingTriggerPropertyFrom → toDurationEasing / delayReduced motion
Cell riserender (load, year)opacity, translateY, scale0, 4px, .6 → 1, 0, 1500msexpo, week × 7msnone
Theme rippletheme changebackground-colorold → new ink300msease, week × 4msinstant
Headline italic, streaktheme changecolorold → new300mseaseinstant
Countersrendernumberprevious → new700mscubic outinstant
Tooltiphover/focusopacity, translateY0, 4px → 1, 0120ms / 150mseaseinstant
Active cellkeyboardscale1 → 1.35150mseaseinstant
Year pillselectbackground, color→ ink / paper200mseaseinstant

States

  • Cell hover: 1.5px ink ring.
  • Cell active (keyboard): scale 1.35, box-shadow: 0 0 0 1.5px paper, 0 0 0 3px ink, z-index 1.
  • Future day: transparent with a 1px inset --line outline. No tooltip, not reachable by keys.
  • Year selected: ink fill, paper text. Others ink-3, hover ink.
  • Theme selected: 1px ink border plus 1px inset ink ring.
  • Grid focus-visible: 2px --c4 outline, offset 4px, around the whole cell block.
  • Empty year (all zero): the grid shows all c0 cells, the headline reads “No contributions in 2024”, and the stats show 0.

Accessibility

  • One tab stop for 365 cells. Arrow keys move the active day. Do not make each cell a button.
  • aria-label on the grid explains the keys. The visible hint repeats it (aria-describedby).
  • The live region announces “14 contributions · Tue 12 Aug 2025” on each keyboard move. Hover does not announce.
  • Year and theme groups are radiogroups with roving tabindex. Arrow keys move and select, and wrap around.
  • Theme swatches have names (Meadow, Clay, Tide) as aria-label.
  • Colour is not the only carrier. Every value is reachable as text via the tooltip and live region.
  • Contrast: #6b7770 on #faf7ee ≈ 4.6:1. Paper on ink tooltip ≈ 12:1.
  • Year and theme targets are 34px. Use 40px on touch-first layouts.

Responsive rules

  • ≥1024: sheet 968px. The grid (30 + 53 × 16 = 878px) fits without scrolling.
  • 768: the sheet shrinks and the grid scrolls horizontally inside .scroll. The page never scrolls sideways.
  • <720: padding 24/20, headline 30px, controls wrap under the headline, stats become a 2 × 2 grid.
  • When the current year is shown, set scrollLeft to the end so recent weeks are visible first.
  • Keep cells at 13px. Do not shrink them to fit, because they become unreadable below 10px.

Acceptance checklist

Always

  • 7 rows (Sun–Sat) by up to 53 columns, 13px cells, 3px gaps, 2px radius.
  • Five levels of one hue plus a neutral zero. Legend “Less … More” shows all five.
  • Tooltip shows count and full date, sits 10px above the cell, and is clamped inside the sheet.
  • Grid is a single tab stop with ↑↓ (day), ←→ (week), Home/End.
  • Keyboard moves announce through a polite live region.
  • Future days are outlined, not filled, and cannot be focused.
  • Year switch rebuilds the grid with a per-week wave, and stats recount.
  • Theme switch ripples colour across weeks (4ms per week, 300ms each).
  • Streak label says “Current streak” for the current year and “Streak at year end” otherwise.
  • No page-level horizontal scroll at 375px. The grid scrolls in its own container.
  • Reduced motion removes the rise, ripple and count-up.

This demo

  • Owner line “ottoline · Mossgrid · Lisbon”. Today is fixed at 3 Oct 2026.
  • Years 2024, 2025, 2026, and 2025 starts selected.
  • Themes Meadow, Clay, Tide with the hexes in Tokens.
  • Stats: streak, longest streak, busiest day (value + date), active days “of N”.

Implementation notes

Column-major layout with no JS positioning. Let CSS grid flow the days down each week:

.cells {
  display: grid;
  grid-template-rows: repeat(7, var(--cell));
  grid-auto-flow: column;
  grid-auto-columns: var(--cell);
  gap: var(--gap);
}
.cells i { transition: background-color .3s var(--ease) var(--d, 0ms);
  animation: in .5s var(--expo) both; animation-delay: calc(var(--w) * 7ms); }

Prepend new Date(year, 0, 1).getDay() invisible pad cells so 1 January lands on its weekday row. Set --w (week index) and --d (week × 4ms) inline on each cell. Then a theme change is just swapping data-theme on body, and the ripple comes from the per-cell transition-delay.

Streaks, where future days (v = -1) neither break nor extend a run:

let long = 0, run = 0;
days.forEach(x => { if (x.v > 0) long = Math.max(long, ++run); else if (x.v === 0) run = 0; });
let cur = 0;
const past = days.filter(x => x.v >= 0);
for (let i = past.length - 1; i >= 0 && past[i].v > 0; i--) cur++;

Arrow keys map to index offsets: up −1, down +1, left −7, right +7. Clamp to [0, lastPastIndex] and ignore moves that would leave the range, rather than wrapping across years.

Common mistakes:

  • Making 365 focusable buttons. Tabbing becomes impossible.
  • A tooltip that follows the mouse instead of anchoring to the cell.
  • Different hues per level. It is one hue in five steps.
  • Shrinking cells on mobile instead of letting the grid scroll.
  • Letting the body grow to the grid’s max-content width. Give the page grid grid-template-columns: minmax(0, 1fr).
  • Data that looks random. Weight weekdays (≈85%) above weekends (≈35%), add a seasonal swell and two or three gaps of 6–12 days for holidays.

Details

Palette
Type
Spectral · DM Mono
Motion
Subtle motion
Build
One session
Designed bySusan AcharyaKathmandu

Studied

Moremedia

A control. It studies this site for quiet, not for a show.

All sources

On the shelves