Activity Heatmap HTML CSS JS — GitHub-Style Calendar

Activity Heatmap · Charts · Plain HTML, CSS & JS · Live preview

Share & Support

What's included

Features

Data model: flat { "YYYY-MM-DD": count } object — replace generateData() with any API response in that shape
Quartile binning: buildTiers() sorts non-zero values and splits at 25th/50th/75th percentile — adaptive to any count range
CSS Grid layout: grid-template-columns: repeat(52, 11px); grid-auto-rows: 11px; gap: 2px — 364 cells
Month labels: column-position calculated as weekIndex × 13px (11px cell + 2px gap) — accurate first-week-of-month placement
Day labels: Mon/Wed/Fri at indices 1/3/5 only — faithful GitHub-style alternating day label pattern
Hover tooltip: event delegation on parent container, position:fixed offset from clientX/clientY — single listener for all 364 cells
Five-tier COLORS array: index 0 = empty, indices 1-4 = quartile tiers — swap any hex to change theme
Legend row: dynamically rendered swatches from COLORS array matching grid tier colors

About this UI Snippet

Activity Heatmap — How to Build a GitHub-Style Contribution Calendar in HTML, CSS, and JavaScript

Screenshot of the Activity Heatmap snippet rendered live

The GitHub contribution graph is one of the most recognized data visualizations in developer tools. Its 52-column × 7-row grid of colored squares communicates an entire year of activity at a single glance — no axes, no numbers, no scrolling. The intensity of each cell encodes a count, so patterns like streaks, weekday vs weekend habits, and seasonal dips emerge immediately from the visual texture.

This snippet replicates that pattern in pure HTML, CSS, and vanilla JavaScript — no D3, no charting library, no canvas. It renders a full 364-day (52 weeks) heatmap with five color intensity tiers, month labels above the grid, day labels on the left, and a hover tooltip showing the exact count and date.

Data Model and Date Generation

The heatmap data is a plain JavaScript object where each key is an ISO date string in YYYY-MM-DD format and each value is an activity count (any non-negative integer). The generateData() function populates this object for the 364 days before today: it counts backwards from today using setDate(today.getDate() - offset) and formats each date as d.toISOString().slice(0, 10).

The distribution is deliberately skewed: approximately 40% of days get a count of 0 (the Math.random() < 0.4 branch), and the remaining 60% get a count from 1 to 20 via Math.ceil(Math.random() * 20). This produces realistic-looking sparse activity rather than a uniform fill.

Quartile-Based Color Tier Binning

Rather than using a fixed scale (e.g., 1–4 = tier 1, 5–9 = tier 2), the snippet uses quartile binning: it collects all non-zero counts, sorts them ascending, and divides the sorted array into four equal quartile buckets. The thresholds are at indices 25%, 50%, and 75% of the sorted array. Any count that falls below the 25th percentile gets tier 1 (lightest), above the 75th gets tier 4 (darkest), and the middle two quartiles get tiers 2 and 3.

This adaptive approach means the color distribution always looks balanced regardless of the actual count magnitude — whether your data ranges from 1–5 or 1–500, the heatmap uses all four non-empty colors. The buildTiers(data) function returns a function tier(count) that takes a count and returns the tier index 0–4 (0 for zero counts).

CSS Grid Layout for the Cell Matrix

The core grid uses CSS: display: grid; grid-template-columns: repeat(52, 11px); grid-auto-rows: 11px; gap: 2px. The 52 columns represent weeks and the 7 rows (auto-generated) represent days. Each cell is an 11×11px div.

The key layout detail is column-first ordering: cells are appended in day order (day 0 of week 0, day 1 of week 0, ..., day 6 of week 0, day 0 of week 1, ...) so CSS Grid naturally flows them into the right column-per-week arrangement. The grid-auto-flow: column equivalent is achieved by setting 52 fixed columns — the browser fills down each column before moving to the next.

Month Label Positioning

Month labels above the grid (Jan, Feb, ..., Dec) are the trickiest part. The label for a given month must appear above the first week-column that starts in that month. For each of the 52 weeks, the snippet checks whether weekStart.getMonth() !== previousWeekStart.getMonth() — if the month changed, that column gets a label. The label div uses absolute positioning with left: (weekIndex * 13)px (column width 11px + gap 2px = 13px per column). The container has position: relative and a fixed height matching the label row.

Day Labels

A separate flex column on the left shows Mon, Wed, Fri at indices 1, 3, 5 (the even-index days are left blank for a GitHub-faithful look). Each label is 11px tall plus 2px gap, aligned to the corresponding grid row.

Hover Tooltip

The tooltip is a single absolutely-positioned div that moves with the mouse. Each cell has data-date and data-count attributes set at render time. mouseover reads these via e.target.dataset and updates the tooltip content and position. The tooltip uses position: fixed and offsets from e.clientX and e.clientY with a 10px nudge to avoid covering the hovered cell. mouseleave on the grid container hides it.

Connecting Real Data

To use real contribution data, replace generateData() with a function that builds the same { [YYYY-MM-DD]: count } object from your API. For GitHub contribution data: call the GitHub REST API (/users/{user}/events) or GraphQL API (contributionCalendar) and map each contribution day to its count. For custom apps, aggregate your events table by date server-side and return the result as a JSON object. The rendering code does not care about the source — it only requires the YYYY-MM-DD: count format.

Performance Characteristics

The snippet creates 364 DOM elements on load. This is well within browser performance limits — modern browsers handle thousands of small divs at 60fps without issue. The hover handler is attached once to the parent container (event delegation), not individually to each cell, so there are no 364 separate event listeners in memory. The month-label calculation is O(n) over 52 weeks, not 364 days.

Build with AI

Build, Understand, Optimize, and Extend It With AI

You don't have to work through the percentile math by hand — paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to explain exactly how buildTiers computes the 25th/50th/75th percentile thresholds from the sorted non-zero values, and why that adaptive quartile approach was chosen over a fixed count-based scale. The same assistant is useful for optimizing it — asking whether creating 364 DOM cells up front is the right tradeoff versus a canvas-based renderer for embedding many heatmaps on one dashboard page, or whether the month-label positioning loop could be simplified. It's just as good for extending the heatmap: ask it to wire generateData up to a real API endpoint and cache the quartile calculation, add a year-picker to swap between different 52-week windows, or add keyboard navigation between cells for accessibility. Treat the code less like a finished artifact and more like a starting point for a conversation.

Prompt to recreate it

Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:

text
Build a GitHub-style "activity heatmap" (contribution graph) in plain HTML, CSS, and JavaScript — no charting library, no canvas or SVG, using only CSS grid for the cell layout.

Requirements:
- A data model that is a plain object mapping ISO date strings (YYYY-MM-DD) to a non-negative integer count, covering the past 364 days.
- A color-tier function that does NOT use a fixed numeric scale. Instead, collect every non-zero count, sort them ascending, and compute the values at the 25th, 50th, and 75th percentile indices of that sorted array. Map each day's count to tier 0 (zero activity), tier 1 (at or below the 25th percentile), tier 2 (at or below the 50th), tier 3 (at or below the 75th), or tier 4 (above the 75th), so the color distribution adapts automatically whether the data ranges from 1-5 or 1-500.
- A CSS grid with 52 columns (one per week) and 7 fixed rows (one per day, Sunday to Saturday), filling cells in column-first order so each week is a vertical strip.
- Month labels positioned above the grid: iterate all 52 weeks, and only when a week's starting date falls in a different month than the previous week's, insert an absolutely-positioned label at that week's pixel offset (weekIndex times the cell-plus-gap width).
- Day-of-week labels down the left side, showing only alternating labels (e.g. Sun, Tue, Thu, Sat) to avoid crowding.
- A hover tooltip that follows the cursor and displays the exact date and activity count for the hovered cell, plus a five-swatch "Less to More" legend below the grid matching the tier colors exactly.

Step by step

How to Use

  1. 1
    See the grid renderThe heatmap renders automatically on load — 364 cells (52 weeks × 7 days) filled with random activity data using quartile color tiers from lightest purple to darkest indigo.
  2. 2
    Hover over cellsHover any cell to see a tooltip showing the exact date and activity count. Empty days show "No activity", active days show the count.
  3. 3
    Read the legendThe legend row below the grid shows five swatches from empty (grey) to maximum intensity (dark indigo), matching the quartile tier colors used across the grid.
  4. 4
    Plug in your dataReplace generateData() with a function returning a plain object: { "2025-06-10": 5, "2025-06-11": 12, ... }. Keys are YYYY-MM-DD strings, values are activity counts. The rendering and tier binning work unchanged.
  5. 5
    Change the color paletteEdit the COLORS array — 5 values: [empty, tier1, tier2, tier3, tier4]. Replace with your brand gradient from lightest tint (index 1) to darkest shade (index 4). Index 0 is the zero-activity cell color.
  6. 6
    Customize date rangeChange WEEKS from 52 to any number. The loop automatically calculates start and end dates relative to today. For a specific year range, replace the date generation loop with fixed start/end Date objects.

Real-world uses

Common Use Cases

Developer Portfolio Contribution Graph
Show your coding consistency across the year, exactly like GitHub. Feed data from the GitHub GraphQL API's contributionCalendar field. Embed on a portfolio page alongside a stats card showing total commits and longest streak.
Habit Tracker & Streak Visualization
Visualize daily exercise, reading, meditation, or journaling streaks. The five-tier scale works for any 0–20+ frequency range. Users see their consistency patterns at a glance — months with dense dark cells vs sparse light ones tell a clear story.
Analytics & Event Volume Dashboard
Show daily active users, login events, purchases, or API call volume across a full year. Product managers and analysts use the heatmap to spot seasonal patterns, campaign spikes, and anomaly days without reading a data table.
Learning & Course Progress Tracking
Track which days a student completed lessons, solved problems, or submitted assignments. Instructors see engagement patterns — students who study consistently vs those who cram before deadlines. Pair with a leaderboard table for classroom rankings.
CMS Publishing Cadence Visualization
Show an author's publishing frequency per day over the past year — posts, updates, or review submissions. Editors see at a glance whether a contributor is active consistently or in bursts. Combine with a line chart widget for cumulative post count over time.
Team Activity & Commit History
Show per-developer commit, PR, or deployment frequency across the year. Engineering managers use heatmaps to spot under-contribution periods and calibrate workload distribution. Each team member can have their own heatmap card in a dashboard grid.

Got questions?

Frequently Asked Questions

Replace the generateData() function body. It must return a plain object where every key is a "YYYY-MM-DD" string and every value is a non-negative integer count. For GitHub: call the GraphQL contributionCalendar API and map week → day entries. For custom apps: aggregate your events table by date server-side and return the JSON. The rest of the rendering code — tier binning, grid layout, labels, tooltip — reads only this object and needs no changes.

buildTiers() collects all non-zero counts from the data object, sorts them ascending, and computes three thresholds at the 25th, 50th, and 75th percentile indices of the sorted array. The returned tier() function maps any count to 0 (zero), 1 (below 25th), 2 (25th–50th), 3 (50th–75th), or 4 (above 75th percentile). This adaptive approach means the heatmap always shows all four active colors regardless of whether your count range is 1–5 or 1–500.

In generateData(), replace the today-relative loop with: const start = new Date("2025-01-01"); const end = new Date("2025-12-31"); const data = {}; for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) { data[d.toISOString().slice(0,10)] = yourCount; }. Update WEEKS = Math.ceil((end - start) / (7 * 86400000)) to match the column count.

In the cell creation loop in buildHeatmap(), add: cell.addEventListener("click", () => { const date = iso; const count = activity[iso] || 0; openDayDetail(date, count); }). The iso and activity variables are in scope via closure. Alternatively, use event delegation: add one click listener to the heatmap-grid container and read e.target.dataset.date and e.target.dataset.count.

Yes — restructure the grid so each column is a month (28–31 cells wide) instead of a week (7 cells tall). Set grid-template-columns based on the number of months you want to show. Within each month column, cells represent days 1–28/30/31. You lose the clean weekly alignment but gain a month-per-column view useful for monthly habit tracking comparisons.

Yes. Use the JSX, Vue, Angular, or Tailwind export buttons to download a converted component. In React, generate the day cells with a map() over your contribution data instead of the DOM loop, and derive each cell’s level class from the count at render time.