Charts and tablesActivity heatmap
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callActivity heatmap
When the agent and the team were busy, by day and hour. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add activity-heatmapReact
npx designme add activity-heatmap --reactThe CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.
About
What it is for and what it does.
A grid of 7 days by 24 hours where darker cells mean busier hours, so people can see when the agent and the team do their work. Hovering a cell gives the exact count for that day and hour. A switch flips between series, such as the agent's runs and the team's edits, and the cells refill in a wave.
- When you want to show when an agent runs, for example overnight when a queue is processed.
- When comparing the agent's working hours with the team's.
- When planning when to schedule jobs or reviews around busy periods.
- Takes 168 values from 0 to 1 per series, one per hour starting Monday 12am, and shades each cell by its value.
- Hovering a cell shows a tooltip like "Monday 9am: 40 runs", using the series'
maxandunit. - With two or more entries in
series, a switch picks which one shows. - The line under the title shows the series'
note, orperiodwhen it has none. - Cells fill in as a wave from the top left when the card appears and when the series changes.
- Day labels down the side, hour labels along the bottom and a Less to More legend.
- The grid has role="img" with an aria-label made of the title and the current note or period.
- The day labels, hour labels and legend are aria-hidden.
- The series switch is a role="group" labelled "Who", with aria-pressed on the active option.
- The wave delay between cells is removed when the user prefers reduced motion.
Usage
Import it and drive it from your own state.
Import
import { ActivityHeatmap } from '@/components/designme/activity-heatmap/ActivityHeatmap';Example
The demo above as a React component, 25 lines you can copy.
'use client';
// The animated preview from designme.dev, rebuilt from the React component: the agent's week fills in, and the switch shows the team's.
import { ActivityHeatmap, type ActivityHeatmapSeries } from './ActivityHeatmap';
// a made-up month: the agent works overnight when the queue runs, the team works office hours
function week(who: 'agent' | 'team') {
const out: number[] = [];
for (let d = 0; d < 7; d++) for (let h = 0; h < 24; h++) {
let v = who === 'agent' ? (h >= 1 && h <= 5 ? .75 : h >= 9 && h <= 18 ? .45 : .15) : (h >= 9 && h <= 18 ? .65 : h >= 19 && h <= 22 ? .2 : .03);
if (d >= 5) v *= who === 'agent' ? .8 : .2;
if (who === 'agent' && d === 0 && h === 9) v = 1;
out.push(Math.max(0, Math.min(1, v + Math.sin(d * 7.1 + h * 3.3) * .18)));
}
return out;
}
const SERIES: ActivityHeatmapSeries[] = [
{ key: 'agent', label: 'Agent', values: week('agent'), max: 40, unit: 'runs', note: 'Busiest overnight, when the queue runs' },
{ key: 'team', label: 'Team', values: week('team'), max: 25, unit: 'edits', note: 'Busiest 10am to 4pm on weekdays' }
];
export default function ActivityHeatmapDemo() {
return <div style={{ width: '100%' }}><ActivityHeatmap series={SERIES} /></div>;
}
Props
Everything ActivityHeatmap takes.
| Prop | Type | What it does |
|---|---|---|
seriesrequired | ActivityHeatmapSeries[] | One or more series; the switch shows when there are two or more |
value | string | The series on show, by key. Leave it out to let the switch pick it |
defaultValue | string | |
onValueChange | (key: string) => void | |
title | string | Header text |
period | string | The line under the title before the cells fill in, and when a series has no note |
className | string |
Code
The full source, as the CLI installs it.
ActivityHeatmap.tsxReact · 74 lines
'use client';
import { useEffect, useState, type CSSProperties } from 'react';
import { cx } from '../lib/cx';
import { useControllable, useReducedMotion } from '../lib/hooks';
import { Seg, Swap, Tip } from '../lib/motion';
import '../designme.css';
import './activity-heatmap.css';
export interface ActivityHeatmapSeries {
key: string;
/** The switch label, like "Agent" */
label: string;
/** 168 values from 0 to 1, one an hour, Monday 12am first */
values: number[];
/** What a full cell counts, for the tips, like 40 */
max: number;
/** What it counts, for the tips, like "runs" */
unit: string;
/** The line under the title while this series shows, like "Busiest overnight, when the queue runs" */
note?: string;
}
export interface ActivityHeatmapProps {
/** One or more series; the switch shows when there are two or more */
series: ActivityHeatmapSeries[];
/** The series on show, by key. Leave it out to let the switch pick it */
value?: string;
defaultValue?: string;
onValueChange?: (key: string) => void;
/** Header text */
title?: string;
/** The line under the title before the cells fill in, and when a series has no note */
period?: string;
className?: string;
}
const DAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
const LEGEND = [.08, .3, .55, .85];
const hour = (h: number) => (h % 12 || 12) + (h < 12 ? 'am' : 'pm');
const shade = (a: number): CSSProperties => ({ ['--dm-a' as string]: a });
/** When the agent or the team is busy, by day and hour. The cells fill in as a wave from the top left. */
export function ActivityHeatmap({ series, value, defaultValue, onValueChange, title = 'Activity', period = 'Last 4 weeks', className }: ActivityHeatmapProps) {
const [key, setKey] = useControllable(value, defaultValue ?? series[0]?.key ?? '', onValueChange);
const s = series.find(x => x.key === key) ?? series[0];
const reduce = useReducedMotion();
// the cells start empty and fill in a moment after the card appears
const [drawn, setDrawn] = useState(false);
useEffect(() => { const t = setTimeout(() => setDrawn(true), 150); return () => clearTimeout(t); }, []);
return (
<div data-dm="" className={cx('dm-hmx dm-panel', className)}>
<div className="dm-hmx-h">
<div><b>{title}</b><Swap className="dm-muted dm-hmx-st">{drawn ? s?.note ?? period : period}</Swap></div>
{series.length > 1 && <Seg className="dm-hmx-sg" label="Who" value={key} onChange={setKey} options={series.map(x => ({ value: x.key, label: x.label }))} />}
</div>
<div className="dm-hmx-wrap">
<div className="dm-hmx-days dm-muted" aria-hidden="true">{['Mon', '', 'Wed', '', 'Fri', '', 'Sun'].map((d, i) => <span key={i}>{d}</span>)}</div>
<div className="dm-hmx-grid" role="img" aria-label={`${title}, ${s?.note ?? period}`}>
{Array.from({ length: 168 }, (_, i) => {
const d = Math.floor(i / 24), h = i % 24, v = s?.values[i] ?? 0;
// each cell changes a beat after the one before it, so a new series sweeps in from the top left
const style = { ...shade(drawn ? +(v * .9).toFixed(2) : 0), transition: `background .5s var(--dm-ease) ${reduce ? 0 : (h + d) * 14}ms, transform .2s` };
// always inside its Tip, so the cell keeps its element (and its transition) when the tips switch on
return <Tip key={i} off={!drawn} label={s ? `${DAYS[d]} ${hour(h)}: ${Math.round(v * s.max)} ${s.unit}` : ''}><i style={style} /></Tip>;
})}
</div>
</div>
<div className="dm-hmx-f dm-muted" aria-hidden="true">{['12am', '6am', '12pm', '6pm'].map(t => <span key={t} className="dm-hmx-ax">{t}</span>)}</div>
<div className="dm-hmx-lg dm-muted" aria-hidden="true">Less{LEGEND.map(a => <i key={a} style={shade(a)} />)}More</div>
</div>
);
}
activity-heatmap.cssReact · 18 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Activity heatmap ---------- */
.dm-hmx{padding:var(--dm-s-4);display:flex;flex-direction:column;gap:10px}
.dm-hmx-h{display:flex;align-items:center;justify-content:space-between;gap:10px}
.dm-hmx-h>div:first-child{display:flex;flex-direction:column;font-size:13.5px}
.dm-hmx-h b{font-weight:500}
.dm-hmx-st{font-size:12px}
.dm-hmx-sg button{font-size:12px;height:26px;padding:0 9px}
.dm-hmx-wrap{display:flex;gap:6px}
.dm-hmx-days{display:grid;grid-template-rows:repeat(7,1fr);gap:3px;font-size:10px}
.dm-hmx-days span{display:flex;align-items:center;height:100%}
.dm-hmx-grid{flex:1;display:grid;grid-template-columns:repeat(24,minmax(0,1fr));gap:3px;min-width:0}
.dm-hmx-grid i{aspect-ratio:1;border-radius:3px;background:color-mix(in srgb,var(--dm-accent) calc(var(--dm-a) * 100%),var(--dm-chip));transition:background .5s var(--dm-ease),transform .2s;max-width:100%}
@media (hover:hover) and (pointer:fine){.dm-hmx-grid i:hover{transform:scale(1.35);box-shadow:0 0 0 1.5px var(--dm-surface);position:relative;z-index:1}}
.dm-hmx-f{display:grid;grid-template-columns:repeat(4,1fr);font-size:10.5px;margin-left:34px;margin-top:-4px}
.dm-hmx-lg{align-self:flex-end;display:flex;align-items:center;gap:3px;font-size:10.5px}
.dm-hmx-lg i{width:9px;height:9px;border-radius:2px;background:color-mix(in srgb,var(--dm-accent) calc(var(--dm-a) * 100%),var(--dm-chip))}
activity-heatmap.htmlHTML · 11 lines
<div class="card" data-c="activity-heatmap">
<div class="stage-in">
<div class="hmx panel">
<div class="hmx-h"><div><b>Activity</b><span class="muted hmx-st">Last 4 weeks</span></div><div class="seg2 hmx-sg" role="group" aria-label="Who"><button aria-pressed="true" data-v="agent">Agent</button><button aria-pressed="false" data-v="team">Team</button></div></div>
<div class="hmx-wrap"><div class="hmx-days muted"><span>Mon</span><span></span><span>Wed</span><span></span><span>Fri</span><span></span><span>Sun</span></div><div class="hmx-grid"></div></div>
<div class="hmx-f muted"><span class="hmx-ax">12am</span><span class="hmx-ax">6am</span><span class="hmx-ax">12pm</span><span class="hmx-ax">6pm</span></div>
<div class="hmx-lg muted">Less<i style="--a:.08"></i><i style="--a:.3"></i><i style="--a:.55"></i><i style="--a:.85"></i>More</div>
</div>
</div>
</div>
activity-heatmap.cssHTML · 17 lines
/* ---------- Activity heatmap ---------- */
.hmx{padding:var(--s-4);display:flex;flex-direction:column;gap:10px}
.hmx-h{display:flex;align-items:center;justify-content:space-between;gap:10px}
.hmx-h>div:first-child{display:flex;flex-direction:column;font-size:13.5px}
.hmx-h b{font-weight:500}
.hmx-st{font-size:12px}
.hmx-sg button{font-size:12px;height:26px;padding:0 9px}
.hmx-wrap{display:flex;gap:6px}
.hmx-days{display:grid;grid-template-rows:repeat(7,1fr);gap:3px;font-size:10px}
.hmx-days span{display:flex;align-items:center;height:100%}
.hmx-grid{flex:1;display:grid;grid-template-columns:repeat(24,minmax(0,1fr));gap:3px;min-width:0}
.hmx-grid i{aspect-ratio:1;border-radius:3px;background:color-mix(in srgb,var(--accent) calc(var(--a) * 100%),var(--chip));transition:background .5s var(--ease),transform .2s;max-width:100%}
@media (hover:hover) and (pointer:fine){.hmx-grid i:hover{transform:scale(1.35);box-shadow:0 0 0 1.5px var(--surface);position:relative;z-index:1}}
.hmx-f{display:grid;grid-template-columns:repeat(4,1fr);font-size:10.5px;margin-left:34px;margin-top:-4px}
.hmx-lg{align-self:flex-end;display:flex;align-items:center;gap:3px;font-size:10.5px}
.hmx-lg i{width:9px;height:9px;border-radius:2px;background:color-mix(in srgb,var(--accent) calc(var(--a) * 100%),var(--chip))}
activity-heatmap.jsHTML · 17 lines
DM.def('activity-heatmap', (r, c) => {
const DAYS = ['Monday','Tuesday','Wednesday','Thursday','Friday','Saturday','Sunday'];
const grid = $('.hmx-grid', r), st = $('.hmx-st', r);
const gen = (who) => { const a = []; for (let d = 0; d < 7; d++) for (let h = 0; h < 24; h++){
let v = who === 'agent' ? (h >= 1 && h <= 5 ? .75 : h >= 9 && h <= 18 ? .45 : .15) : (h >= 9 && h <= 18 ? .65 : h >= 19 && h <= 22 ? .2 : .03);
if (d >= 5) v *= who === 'agent' ? .8 : .2; if (who === 'agent' && d === 0 && h === 9) v = 1;
v = Math.max(0, Math.min(1, v + (Math.sin(d * 7.1 + h * 3.3) * .18))); a.push(v); } return a; };
const D = { agent:gen('agent'), team:gen('team') };
grid.innerHTML = Array.from({ length:168 }, () => '<i style="--a:0"></i>').join('');
const cells = $$('i', grid);
function draw(w){ cells.forEach((el, i) => { const d = Math.floor(i / 24), h = i % 24, v = D[w][i]; c.later((h + d) * 14, () => el.style.setProperty('--a', (v * .9).toFixed(2)));
const hr = (h % 12 || 12) + (h < 12 ? 'am' : 'pm'); el.dataset.tip = `${DAYS[d]} ${hr}: ${Math.round(v * (w === 'agent' ? 40 : 25))} ${w === 'agent' ? 'runs' : 'edits'}`; });
swap(st, w === 'agent' ? 'Busiest overnight, when the queue runs' : 'Busiest 10am to 4pm on weekdays'); }
seg($('.hmx-sg', r), draw);
c.later(240, () => draw('agent'));
});
Questions
What people ask about it.
- What shape does the data need to be?
- Each series needs exactly 168 numbers between 0 and 1, one per hour, Monday 12am first and Sunday 11pm last. Scale your counts down first; the tooltips multiply by
maxto show real counts again. - How do the tooltip counts work?
- A cell's value times the series'
max, rounded, followed by itsunit. So 0.5 withmax: 40andunit: 'runs'reads "20 runs". - Can I show just one series without the switch?
- Yes. Pass a single entry in
series. The switch only appears when there are two or more.
More in Charts and tables
Lightweight charts and tables for dashboards and reports, drawn in plain SVG and HTML.