Skip to content
Star on GitHub
Charts and tablesActivity heatmap
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

Activity 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.

$npx designme add activity-heatmap
ActivityLast 4 weeks
MonWedFriSun
12am6am12pm6pm
LessMore
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add activity-heatmap

React

$npx designme add activity-heatmap --react

The 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.

02

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.
03

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>;
}
04

Props

Everything ActivityHeatmap takes.

PropTypeWhat it does
seriesrequiredActivityHeatmapSeries[]One or more series; the switch shows when there are two or more
valuestringThe series on show, by key. Leave it out to let the switch pick it
defaultValuestring
onValueChange(key: string) => void
titlestringHeader text
periodstringThe line under the title before the cells fill in, and when a series has no note
classNamestring
05

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'));
});
06

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 max to show real counts again.
How do the tooltip counts work?
A cell's value times the series' max, rounded, followed by its unit. So 0.5 with max: 40 and unit: '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.
07

More in Charts and tables

Lightweight charts and tables for dashboards and reports, drawn in plain SVG and HTML.

Line chartTwo series over time with a hover readout and toggles.
Bar chartWeekly agent runs, split by outcome, with a range switch.
Donut chartWhere the agent's time went, by kind of task.
Funnel chartSignup to paid, with the biggest drop called out by the agent.
Top pagesMost visited pages with share of traffic and a quick sort.
Health scoreA design system score made of a few checks you can open.
Stat cardsFour headline numbers with sparklines and a period switch.
Account tableCustomer accounts with health, plan and an agent note per row.