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.

  • 110 lines of CSS
  • 6 lines of HTML
  • 49 lines of JS
  • No dependencies
  • MIT licensed
Video 1.7 MB 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: the element itself is the roof, lifted by translateZ(calc(var(--v) * 70px)); ::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 × 70px.
  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 floor has pointer-events: none: blocks in 3D share a plane, and only the buttons should be hit.

Key techniques

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

Copy-paste code

HTML 6 lines

<div class="heat">
  <div class="scene">
    <div class="world"></div>
  </div>
  <output></output>
</div>

CSS 110 lines

.heat {
  display: grid;
  justify-items: center;
  gap: 18px;
  font-family: system-ui, sans-serif;
}

.scene {
  perspective: 800px;
  padding: 50px 30px 10px;
  pointer-events: none; /* the floor is tilted back: only the blocks take the pointer */
}

/* 5 columns and 4 rows: a 34px pitch, 26px blocks, 10px margin */
.world {
  position: relative;
  width: 182px;
  height: 148px;
  border: 1px solid rgb(139 108 255 / 0.4);
  border-radius: 10px;
  background: #1c1d3d;
  transform-style: preserve-3d;
  transform: rotateX(58deg) rotateZ(36deg);
  animation: sway 9s ease-in-out infinite alternate;
  pointer-events: none; /* only the blocks are hit */
}

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

/* the day names, printed on the floor along the edge that faces you */
.world em {
  position: absolute;
  top: 140px;
  left: calc(10px + var(--x) * 34px);
  width: 26px;
  color: #949bc0;
  font: 700 8px/10px system-ui, sans-serif;
  font-style: normal;
  text-align: center;
}

.cell {
  /* cold teal → hot pink with the value */
  --c: color-mix(in srgb, #ff4d9d calc(var(--v) * 100%), #2ee6d6);
  position: absolute;
  top: calc(10px + var(--y) * 34px);
  left: calc(10px + var(--x) * 34px);
  width: 26px;
  height: 26px;
  padding: 0;
  border: 1px solid color-mix(in srgb, var(--c) 45%, transparent);
  border-radius: 3px;
  background: color-mix(in srgb, var(--c) 14%, transparent);
  outline: none;
  transform-style: preserve-3d;
  pointer-events: auto;
  cursor: pointer;
}

/* the roof, lifted by the value */
.cell i {
  position: absolute;
  inset: -1px;
  background: color-mix(in srgb, var(--c) 68%, #141830);
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--c) 85%, #fff);
  transform-style: preserve-3d;
  transform: translateZ(calc(var(--v) * 70px));
}

.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) * 70px);
  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) * 70px);
  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 1px #fff, 0 0 16px var(--c);
}

output {
  color: #949bc0;
  font-size: 12px;
}

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 output');
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);

Sass source 139 lines

// A heatmap drawn from JSON: one block per value on an isometric floor. JS writes --v per cell
// (value ÷ the largest value, 0–1); CSS makes it a height and a colour. Each block is the city's
// construction: the element is the roof, lifted by translateZ; ::before and ::after are the two
// walls that face the camera, hanging down from its +y and +x edges.
$cell: 34px; // grid pitch
$lot: 26px; // block footprint
$pad: 10px; // floor margin
$cols: 5;
$rows: 4;
$hmax: 70px; // the tallest block

.d-heatmap {
  display: grid;
  grid-template-rows: minmax(0, 1fr) auto;
  width: 100%;
  height: 100%;
  padding: 10px 14px 14px;
  // the floor is tilted back, so blocks sit behind the plane of the flat boxes around it (this
  // one and the view): they must not catch the pointer, only the blocks do
  pointer-events: none;

  &__view {
    display: grid;
    place-items: center;
    min-height: 0;
    pointer-events: none;
  }

  // The floor. It ignores the pointer, so only the cells (real buttons) are hit.
  &__world {
    position: relative;
    width: $pad * 2 + $cell * $cols - ($cell - $lot);
    height: $pad * 2 + $cell * $rows - ($cell - $lot);
    border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent);
    border-radius: 10px;
    background: color-mix(in srgb, var(--accent) 12%, var(--surface-solid));
    transform-style: preserve-3d;
    transform: translateY(22px) rotateX(58deg) rotateZ(36deg);
    animation: d-heatmap-sway 9s ease-in-out infinite alternate;
    pointer-events: none;

    // the day names, printed on the floor along the edge that faces you
    em {
      position: absolute;
      top: $pad + $cell * $rows - ($cell - $lot) + 2px;
      left: calc(#{$pad} + var(--x) * #{$cell});
      width: $lot;
      color: var(--muted);
      font: 700 8px/10px system-ui, sans-serif;
      font-style: normal;
      text-align: center;
    }
  }

  &__cell {
    // cold teal → hot pink with the value
    --c: color-mix(in srgb, var(--hot) calc(var(--v) * 100%), var(--accent-2));
    position: absolute;
    top: calc(#{$pad} + var(--y) * #{$cell});
    left: calc(#{$pad} + var(--x) * #{$cell});
    width: $lot;
    height: $lot;
    padding: 0;
    border: 1px solid color-mix(in srgb, var(--c) 45%, transparent);
    border-radius: 3px;
    background: color-mix(in srgb, var(--c) 14%, transparent);
    outline: none;
    transform-style: preserve-3d;
    pointer-events: auto;
    cursor: pointer;

    // the roof
    i {
      position: absolute;
      inset: -1px;
      background: color-mix(in srgb, var(--c) 68%, var(--surface-solid));
      box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--c) 85%, #fff);
      transform-style: preserve-3d;
      transform: translateZ(calc(var(--v) * #{$hmax}));

      &::before,
      &::after {
        content: '';
        position: absolute;
      }

      // +y wall: hangs from the roof's bottom edge
      &::before {
        top: 100%;
        left: 0;
        width: 100%;
        height: calc(var(--v) * #{$hmax});
        background: color-mix(in srgb, var(--c) 46%, var(--surface-solid));
        box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--c) 60%, transparent);
        transform-origin: top;
        transform: rotateX(-90deg);
      }

      // +x wall: hangs from its right edge, a shade darker
      &::after {
        top: 0;
        left: 100%;
        width: calc(var(--v) * #{$hmax});
        height: 100%;
        background: color-mix(in srgb, var(--c) 30%, var(--surface-solid));
        box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--c) 50%, transparent);
        transform-origin: left;
        transform: rotateY(90deg);
      }
    }

    // pointed at: the roof lights up (the cell itself never moves)
    &:hover i,
    &:focus-visible i {
      background: color-mix(in srgb, var(--c) 55%, #fff);
      box-shadow:
        inset 0 0 0 1px #fff,
        0 0 16px var(--c);
    }
  }

  &__dock {
    display: grid;
    justify-items: center;
    font-size: 12px;

    output {
      color: var(--muted);
      font-variant-numeric: tabular-nums;
      white-space: nowrap;
    }
  }
}

@keyframes d-heatmap-sway {
  to {
    transform: translateY(22px) rotateX(58deg) rotateZ(50deg);
  }
}
Standalone snippet · plain CSS, no build step, no dependencies.