CSS + JS Data & tools #hover #data #chart #json #isometric

3D heatmap from JSON in CSS + JavaScript

A week-by-weekday grid of commits where every value is a block: taller and hotter the bigger it is. Hover or tap a block to read it. JSON sets one number per block; CSS turns it into a height and a colour.

  • 142 lines of CSS
  • 10 lines of HTML
  • 49 lines of JS
  • No dependencies
  • Snippet under MIT
GitHub

How it works

  1. The JSON is a list of weeks, each a list of numbers. JS writes one number per block: --v = value ÷ the largest value, plus its grid position --x / --y.
  2. A block is three surfaces. Each button is the roof, lifted by translateZ(calc(var(--v) * 40 * var(--u))), and the <i> inside it is the roof's face; its ::before and ::after are the two walls you can see, hanging down from its edges with rotateX(-90deg) and rotateY(90deg). Their height is the same --v × 40 units.
  3. The colour follows the value too: color-mix(in srgb, pink calc(var(--v) * 100%), teal) runs from cold to hot with no colour scale in JS.
  4. Each block sits in a real <button>, so it can be tabbed to and tapped. The button is the lifted roof, not a tile on the floor: a tile on the floor would be hidden under the taller blocks in front of it, and could never be pointed at. The floor has pointer-events: none, so only the blocks are hit, and the blocks are no taller than 40 units under a rotateX(48deg) tilt. That limit is what keeps every roof in sight: as the sway passes 45°, each block stands straight in front of its diagonal neighbour, and at 52 units the 14-commit block hid the 3-commit one behind it entirely; at 40, every roof keeps at least a quarter of itself showing all through the sway.
  5. Every length is a multiple of one base unit, --u, so the grid is the same share of a gallery card, the editor and a recording canvas. The line under it that names the pointed-at block is in plain vmin: it is the caption of the same control zone every model has, one line whatever it says (it is never empty and never wraps), so it cannot move the grid.

Key techniques

  • JSON → --v per cell (value ÷ max)
  • roof translateZ(--v × 52 units), walls as ::before / ::after
  • colour: color-mix(hot --v%, teal)
  • real <button> per cell, floor ignores the pointer

Copy-paste code

HTML 10 lines

<div class="heat">
  <div class="view">
    <div class="scene">
      <div class="world"></div>
    </div>
  </div>
  <div class="controls">
    <output class="caption"></output>
  </div>
</div>

CSS 142 lines

/* the model box and the control zone stand in one stack */
.heat {
  /* one base unit: every length of the grid is a multiple of it. The caption under it is in
     plain vmin, because the control zone is the same object in every model. */
  --u: 0.29vmin;
  display: grid;
  justify-items: center;
  gap: 4vmin;
  font-family: Inter, system-ui, sans-serif;
}

/* the model box */
.view {
  display: grid;
  place-items: center;
  height: 50vmin;
}

.scene {
  perspective: calc(800 * var(--u));
  /* tilted back, the floor's near corner hangs lower than its box: the room under it lifts what
     is drawn, not the box, into the middle of the model box, so the corner stays clear of the
     caption by the band's gap */
  padding-bottom: calc(44 * var(--u));
  pointer-events: none; /* the floor is tilted back: only the blocks take the pointer */
}

/* 5 columns and 4 rows: a 34-unit pitch, 26-unit blocks, a 10-unit margin */
.world {
  box-sizing: content-box; /* its size is its inside, its edge outside it, on any page */
  position: relative;
  width: calc(182 * var(--u));
  height: calc(160 * var(--u)); /* 4 rows, and a strip along the near edge for the day names */
  border: calc(1 * var(--u)) solid rgb(139 108 255 / 0.4);
  border-radius: calc(10 * var(--u));
  background: #1c1d3d;
  transform-style: preserve-3d;
  transform: rotateX(48deg) rotateZ(36deg);
  animation: sway 9s ease-in-out infinite alternate;
  pointer-events: none; /* only the blocks are hit */
}

@keyframes sway {
  to { transform: rotateX(48deg) rotateZ(50deg); }
}

/* the day names, printed on the floor along the edge that faces you: wholly on the dark floor,
   so they read the same on either stage */
.world em {
  position: absolute;
  top: calc(143 * var(--u));
  left: calc((10 + var(--x) * 34) * var(--u));
  width: calc(26 * var(--u));
  color: #949bc0;
  font: 700 calc(10 * var(--u))/calc(12 * var(--u)) Inter, system-ui, sans-serif;
  font-style: normal;
  text-align: center;
}

/* the button IS the roof, lifted by the value: what you see on top of a block is what you point
   at. A button left on the floor would be hidden under the taller blocks in front of it. */
.cell {
  /* cold teal → hot pink with the value */
  --c: color-mix(in srgb, #ff4d9d calc(var(--v) * 100%), #2ee6d6);
  position: absolute;
  top: calc((10 + var(--y) * 34) * var(--u));
  left: calc((10 + var(--x) * 34) * var(--u));
  width: calc(26 * var(--u));
  height: calc(26 * var(--u));
  padding: 0;
  border: 0;
  background: none;
  outline: none;
  transform-style: preserve-3d;
  transform: translateZ(calc(var(--v) * 40 * var(--u)));
  pointer-events: auto;
  cursor: pointer;
}

/* the roof's face */
.cell i {
  position: absolute;
  inset: 0;
  background: color-mix(in srgb, var(--c) 68%, #141830);
  box-shadow: inset 0 0 0 calc(1 * var(--u)) color-mix(in srgb, var(--c) 85%, #fff);
  transform-style: preserve-3d;
  /* the light fades out, so a pointer passing over leaves a short trail, not a white flash */
  transition: background-color 0.35s, box-shadow 0.35s;
}

.cell i::before,
.cell i::after {
  content: '';
  position: absolute;
}

/* the wall hanging from the roof's bottom edge */
.cell i::before {
  top: 100%;
  left: 0;
  width: 100%;
  height: calc(var(--v) * 40 * var(--u));
  background: color-mix(in srgb, var(--c) 46%, #141830);
  transform-origin: top;
  transform: rotateX(-90deg);
}

/* the wall hanging from its right edge, a shade darker */
.cell i::after {
  top: 0;
  left: 100%;
  width: calc(var(--v) * 40 * var(--u));
  height: 100%;
  background: color-mix(in srgb, var(--c) 30%, #141830);
  transform-origin: left;
  transform: rotateY(90deg);
}

/* pointed at: the roof lights up; the cell itself never moves */
.cell:hover i,
.cell:focus-visible i {
  background: color-mix(in srgb, var(--c) 55%, #fff);
  box-shadow: inset 0 0 0 calc(1 * var(--u)) #fff, 0 0 calc(16 * var(--u)) var(--c);
  transition-duration: 0.12s; /* and comes on quickly */
}

/* the control zone: the same object in every model that has one, as tall as what it holds. It
   holds only the caption here, one line that is never empty and never wraps, so a new line
   cannot move the grid. */
.controls {
  display: grid;
  justify-items: center;
  gap: 2vmin;
  text-align: center;
}

.controls .caption {
  font: 500 4.5vmin/1.2 Inter, system-ui, sans-serif;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
  opacity: 0.7;
}

JS 49 lines

// The data, as an API would send it back
const COMMITS = {
  "days": ["Mon", "Tue", "Wed", "Thu", "Fri"],
  "weeks": [
    [3, 8, 5, 12, 7],
    [6, 14, 9, 4, 10],
    [2, 7, 16, 11, 5],
    [9, 12, 6, 15, 13]
  ]
};

const world = document.querySelector('.world');
const out = document.querySelector('.heat .caption');
const max = Math.max(...COMMITS.weeks.flat());

// One block per value: its place in the grid, and its size as a fraction of the largest value
COMMITS.weeks.forEach((week, y) =>
  week.forEach((value, x) => {
    const cell = document.createElement('button');
    cell.type = 'button';
    cell.className = 'cell';
    cell.style.cssText = `--x:${x}; --y:${y}; --v:${value / max}`;
    cell.setAttribute('aria-label', `${COMMITS.days[x]}, week ${y + 1}: ${value} commits`);
    cell.append(document.createElement('i'));
    world.append(cell);
  }),
);
COMMITS.days.forEach((day, x) => {
  const em = document.createElement('em');
  em.style.setProperty('--x', x);
  em.textContent = day;
  world.append(em);
});

// The dock says what the pointed-at block holds, and the total otherwise
const total = COMMITS.weeks.flat().reduce((a, b) => a + b, 0);
const idle = `${total} commits in ${COMMITS.weeks.length} weeks`;
out.textContent = idle;
const show = (e) => {
  const cell = e.target.closest('.cell');
  out.textContent = cell ? cell.getAttribute('aria-label') : idle;
};
const reset = (e) => {
  if (!e.relatedTarget?.closest?.('.cell')) out.textContent = idle;
};
world.addEventListener('pointerover', show);
world.addEventListener('focusin', show);
world.addEventListener('pointerout', reset);
world.addEventListener('focusout', reset);
Standalone snippet · plain CSS, no build step, no dependencies.