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

3D treemap from JSON in CSS + JavaScript

A crypto market map from JSON: each tile’s area is its market cap, its height and colour are the move, teal up and pink down. Switch 24h / 7d and the blocks rise and sink; hover or tap one for its numbers. JS only lays out the tiles and writes one number per tile.

  • 162 lines of CSS
  • 10 lines of HTML
  • 134 lines of JS
  • No dependencies
  • MIT licensed
Video 0.4 MB GitHub

How it works

  1. The JSON is a list of coins with a market cap and a % move per period. JS does two jobs: it lays the tiles out once, and per period it writes one number per tile, --v = |move| ÷ the largest move (0 to 1). Everything you see is CSS.
  2. The layout is a squarified treemap. Sorted biggest first, the coins are laid in rows along the shorter side of the space that is left; a row takes the next coin as long as that makes its worst tile more square, then the row takes its strip off the space and the next row starts in the rest. Each tile gets area = cap × (plate area ÷ total cap), so area is proportional to cap, and it is written as --x / --y / --w / --h in % of the plate.
  3. A tile is a still <button> on the plate (its footprint), and its <i> is the roof, lifted by translateZ(calc(2px + var(--v) * 38px)). The two walls you see are the button's ::before / ::after, stood up from its front edges with rotateX(-90deg) and rotateY(90deg).
  4. The walls are full height and squashed with scaleY / scaleX by the same fraction the roof rises, from the edge they stand on. So a new period changes only transform: roof and walls move in step, the walls always meet the roof, and transition-delay: calc(var(--i) * 45ms) ripples it across the map.
  5. The colour is color-mix(): teal for a gain, pink for a loss (.down swaps it), mixed with grey by 30% + --v × 70%, so small moves are dull and big ones glow. The faces are glass: the colour mixed with transparent (roof 80%, walls about 60%), with a 0.6px bright edge and a soft inner glow.
  6. The plate and the wrappers ignore the pointer (it is tilted back, behind their plane); only the tiles take it. Hovering, focusing or tapping one prints its numbers in the <output> and fades in a brighter layer on its roof (::after, opacity). Leaving waits 150ms before the summary comes back, so moving across the gap between two tiles never blinks.

Key techniques

  • squarified treemap in JS → --x / --y / --w / --h in %
  • roof translateZ + walls scaleY / scaleX, transitioned
  • color-mix(teal | pink, grey) by |change|
  • real <button> per tile, plate ignores the pointer

Copy-paste code

HTML 10 lines

<div class="market">
  <div class="scene">
    <div class="plate"></div>
  </div>
  <output></output>
  <div class="seg">
    <button type="button" data-period="24h">24h</button>
    <button type="button" data-period="7d">7d</button>
  </div>
</div>

CSS 162 lines

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

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

/* the layout's area is 184 × 136px, plus a 6px margin */
.plate {
  position: relative;
  width: 196px;
  height: 148px;
  border: 1px solid rgb(139 108 255 / 0.4);
  border-radius: 9px;
  background: color-mix(in srgb, #8b6cff 10%, #141830);
  transform-style: preserve-3d;
  transform: translateY(14px) rotateX(46deg) rotateZ(28deg);
  pointer-events: none;
}

/* the footprint: --x / --y / --w / --h are % of the 184 × 136 area, with a 3px gap between tiles */
.tile {
  --hue: #2ee6d6;
  /* grey for a small move, full colour for the largest */
  --c: color-mix(in srgb, var(--hue) calc(30% + var(--v) * 70%), #949bc0);
  /* a fine edge: under 1px shows as a hairline on sharp screens */
  --edge: 0.6px solid color-mix(in srgb, color-mix(in srgb, var(--c) 80%, #fff) 75%, transparent);
  position: absolute;
  top: calc(7.5px + var(--y) * 1.36px);
  left: calc(7.5px + var(--x) * 1.84px);
  width: calc(var(--w) * 1.84px - 3px);
  height: calc(var(--h) * 1.36px - 3px);
  padding: 0;
  border: 1px solid color-mix(in srgb, var(--c) 35%, transparent);
  border-radius: 2px;
  background: color-mix(in srgb, var(--c) 12%, transparent);
  color: #eceefb;
  font: 700 8px/1 system-ui, sans-serif;
  outline: none;
  transform-style: preserve-3d;
  pointer-events: auto;
  cursor: pointer;
}

.tile.down {
  --hue: #ff4d9d;
}

/* the two walls you see, glass: each is 40px (the tallest block), turned up from an edge of the
   footprint and squashed to this block's height from that edge, so it always meets the roof */
.tile::before,
.tile::after {
  content: '';
  position: absolute;
  border: var(--edge);
  box-shadow: inset 0 0 8px color-mix(in srgb, var(--c) 30%, transparent);
  transition: transform 0.7s cubic-bezier(0.25, 1, 0.5, 1) calc(var(--i) * 45ms);
}

.tile::before {
  top: calc(100% + 1px - 40px);
  left: -1px;
  width: calc(100% + 2px);
  height: 40px;
  background: color-mix(in srgb, var(--c) 64%, transparent);
  transform-origin: bottom;
  transform: rotateX(-90deg) scaleY(calc((2 + var(--v) * 38) / 40));
}

.tile::after {
  top: -1px;
  left: calc(100% + 1px - 40px);
  width: 40px;
  height: calc(100% + 2px);
  background: color-mix(in srgb, color-mix(in srgb, var(--c) 55%, #05060c) 58%, transparent);
  transform-origin: right;
  transform: rotateY(90deg) scaleX(calc((2 + var(--v) * 38) / 40));
}

/* the roof, glass too, lifted by the move; the labels sit on it, top left (the corner least hidden) */
.tile i {
  position: absolute;
  inset: -1px;
  z-index: 0; /* its own stacking context: the glow below (z -1) stays under the text */
  display: grid;
  align-content: start;
  justify-items: start;
  gap: 2px;
  padding: 3px 4px;
  border: var(--edge);
  background: color-mix(in srgb, var(--c) 80%, transparent);
  box-shadow:
    inset 0 0 8px color-mix(in srgb, var(--c) 30%, transparent),
    0 0 10px color-mix(in srgb, var(--c) 22%, transparent);
  font-style: normal;
  text-shadow: 0 0 3px rgb(11 13 24 / 0.85);
  transform: translateZ(calc(2px + var(--v) * 38px));
  transition: transform 0.7s cubic-bezier(0.25, 1, 0.5, 1) calc(var(--i) * 45ms);
}

/* pointed at, focused or tapped: the roof fills in and glows, faded with opacity */
.tile i::after {
  content: '';
  position: absolute;
  inset: -0.6px;
  z-index: -1;
  background: color-mix(in srgb, color-mix(in srgb, var(--c) 70%, #fff) 60%, transparent);
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--c) 30%, #fff), 0 0 22px color-mix(in srgb, var(--c) 75%, transparent);
  opacity: 0;
  transition: opacity 0.25s;
}

.tile.on i::after,
.tile:focus-visible i::after {
  opacity: 1; /* the tile itself never moves */
}

.tile small {
  color: rgb(236 238 251 / 0.8);
  font-size: 7px;
}

.tile.sm i { padding: 2px; }
.tile.sm small { display: none; } /* the smallest tiles only have room for the name */
.tile.md span { font-size: 9px; }
.tile.lg i { padding: 6px 7px; }
.tile.lg span { font-size: 15px; }
.tile.lg small { font-size: 9px; }

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

.seg {
  display: flex;
  gap: 2px;
  padding: 3px;
  border: 1px solid rgb(255 255 255 / 0.14);
  border-radius: 999px;
}

.seg button {
  padding: 4px 14px;
  border: 0;
  border-radius: 999px;
  background: transparent;
  color: #949bc0;
  font: 700 12px system-ui, sans-serif;
  cursor: pointer;
}

.seg button[aria-pressed='true'] {
  background: linear-gradient(135deg, #8b6cff, #ff4d9d);
  color: #fff;
}

JS 134 lines

// The data, as an API would send it back. cap: market cap in $B; change: % move per period.
const MARKET = {
  "periods": ["24h", "7d"],
  "coins": [
    { "name": "BTC", "cap": 1200, "change": { "24h": 2.4, "7d": 6.8 } },
    { "name": "ETH", "cap": 420, "change": { "24h": -1.8, "7d": 4.2 } },
    { "name": "XRP", "cap": 180, "change": { "24h": 5.6, "7d": -3.5 } },
    { "name": "BNB", "cap": 140, "change": { "24h": 0.6, "7d": 2.1 } },
    { "name": "SOL", "cap": 120, "change": { "24h": -3.9, "7d": 11.4 } },
    { "name": "DOGE", "cap": 80, "change": { "24h": 8.2, "7d": -9.6 } },
    { "name": "TRX", "cap": 70, "change": { "24h": -0.4, "7d": 1.3 } },
    { "name": "ADA", "cap": 65, "change": { "24h": -2.7, "7d": -6.9 } },
    { "name": "LINK", "cap": 55, "change": { "24h": 3.1, "7d": -4.4 } }
  ]
};

const plate = document.querySelector('.plate');
const out = document.querySelector('.market output');
const buttons = document.querySelectorAll('.seg button');
const W = 184; // the plate's inner area in px: the layout is done in these units,
const H = 136; // then written to CSS as % of it

const sum = (list) => list.reduce((s, d) => s + d.cap, 0);

// How far from square (1 = square) the worst tile of a row is, laid along a side `side` long
function worst(row, side, scale) {
  const depth = (sum(row) * scale) / side; // the row's thickness
  return Math.max(...row.map((d) => Math.max((d.cap * scale) / depth ** 2, depth ** 2 / (d.cap * scale))));
}

// Squarified treemap. `scale` is px² per unit of cap, so every tile's area is its cap. Items come
// biggest first. Lay a row of them along the SHORTER side of the space that is left, adding the
// next one while that makes the row's worst tile more square; then the row takes a strip off
// that space and the next row starts in what remains.
function treemap(items, x, y, w, h) {
  const tiles = [];
  const scale = (w * h) / sum(items);
  let rest = [...items];
  while (rest.length) {
    const side = Math.min(w, h);
    const row = [rest[0]];
    while (row.length < rest.length && worst([...row, rest[row.length]], side, scale) <= worst(row, side, scale)) {
      row.push(rest[row.length]);
    }
    const depth = (sum(row) * scale) / side;
    let at = 0; // where the next tile starts along the row
    for (const d of row) {
      const len = (d.cap * scale) / depth;
      tiles.push(w >= h ? { ...d, x, y: y + at, w: depth, h: len } : { ...d, x: x + at, y, w: len, h: depth });
      at += len;
    }
    if (w >= h) {
      x += depth;
      w -= depth;
    } else {
      y += depth;
      h -= depth;
    }
    rest = rest.slice(row.length);
  }
  return tiles;
}

const tiles = treemap([...MARKET.coins].sort((a, b) => b.cap - a.cap), 0, 0, W, H);
// the height scale is shared by both periods, so a week's bigger moves stand taller
const maxMove = Math.max(...MARKET.coins.flatMap((c) => Object.values(c.change).map(Math.abs)));
const pct = (v) => `${v > 0 ? '+' : v < 0 ? '\u2212' : ''}${Math.abs(v).toFixed(1)}%`;

// One button per tile, placed once; its roof <i> carries the name and the move
const els = tiles.map((t, i) => {
  const tile = document.createElement('button');
  tile.type = 'button';
  // label size by footprint: a big name, a name + %, or only the name
  tile.className = 'tile ' + (t.w >= 70 && t.h >= 46 ? 'lg' : t.w >= 38 && t.h >= 26 ? 'md' : 'sm');
  tile.style.cssText = `--i:${i}; --x:${(t.x / W) * 100}; --y:${(t.y / H) * 100}; --w:${(t.w / W) * 100}; --h:${(t.h / H) * 100}`;
  const roof = document.createElement('i');
  const name = document.createElement('span');
  name.textContent = t.name; // text, never HTML: data from an API is not trusted markup
  roof.append(name, document.createElement('small'));
  tile.append(roof);
  plate.append(tile);
  return tile;
});

let period;
function summary() {
  const moves = MARKET.coins.map((c) => c.change[period]);
  const up = moves.filter((v) => v > 0).length;
  const big = MARKET.coins.reduce((a, c) => (Math.abs(c.change[period]) > Math.abs(a.change[period]) ? c : a));
  return `${period} · ${up} up, ${moves.length - up} down · biggest ${big.name} ${pct(big.change[period])}`;
}

// The dock repeats what the pointed-at (or focused, or tapped) tile holds, and lights its roof
function light(tile) {
  els.forEach((t) => t.classList.toggle('on', t === tile));
  out.textContent = tile ? tile.getAttribute('aria-label') : summary();
}

// One period: each tile gets --v (its height, 0–1) and .down for a loss. CSS does the rest.
function show(p) {
  period = p;
  tiles.forEach((t, i) => {
    const change = t.change[p];
    els[i].style.setProperty('--v', Math.abs(change) / maxMove);
    els[i].classList.toggle('down', change < 0);
    els[i].setAttribute('aria-label', `${t.name} · $${t.cap}B · ${pct(change)}`);
    els[i].querySelector('small').textContent = pct(change);
  });
  buttons.forEach((b) => b.setAttribute('aria-pressed', b.dataset.period === p));
  clearTimeout(grace);
  light(null);
}

// Back to the summary only after a short grace period, so crossing the gap between two tiles
// changes the read-out straight from one tile to the next instead of blinking
let grace;
const point = (e) => {
  const tile = e.target.closest('.tile');
  if (!tile) return;
  clearTimeout(grace);
  light(tile);
};
const leave = (e) => {
  if (e.relatedTarget?.closest?.('.tile')) return;
  clearTimeout(grace);
  grace = setTimeout(() => light(null), 150);
};
plate.addEventListener('pointerover', point);
plate.addEventListener('focusin', point);
plate.addEventListener('pointerout', leave);
plate.addEventListener('focusout', leave);
buttons.forEach((b) => b.addEventListener('click', () => show(b.dataset.period)));

show(MARKET.periods[0]);

Sass source 230 lines

@use 'sass:math';

// A market map drawn from JSON. JS lays out the tiles once (area = market cap) and writes them as
// --x / --y / --w / --h, in % of the plate's inner area; per period it writes --v, the move's size
// as a fraction of the largest move (0–1), and .is-down for a loss. CSS makes that a block: the
// button is the still footprint on the plate; its <i> is the roof, lifted by translateZ; the
// button's ::before and ::after are the two walls that face the camera, full height and squashed
// with scaleY / scaleX. So a new period only transitions transform.
$w: 184px; // the plate's inner area (the layout's units)
$h: 136px;
$pad: 6px; // the plate's margin around the tiles
$gap: 3px; // between tiles
$base: 2px; // every block is at least this tall, so a flat move still reads as a block
$hmax: 38px; // the largest move
$full: $base + $hmax;
$ease: cubic-bezier(0.25, 1, 0.5, 1);

// px per 1% of the inner area
$px: math.div($w, 100);
$py: math.div($h, 100);
// the height as a fraction of a full wall (unitless, for scaleX / scaleY)
$frac: calc((#{math.div($base, 1px)} + var(--v) * #{math.div($hmax, 1px)}) / #{math.div($full, 1px)});

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

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

  &__plate {
    position: relative;
    width: $w + $pad * 2;
    height: $h + $pad * 2;
    border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent);
    border-radius: 9px;
    background: color-mix(in srgb, var(--accent) 10%, var(--surface-solid));
    transform-style: preserve-3d;
    // translateY first: the blocks rise upward, so the plate sits a little low in the frame
    transform: translateY(14px) rotateX(46deg) rotateZ(28deg);
    pointer-events: none;
  }

  &__tile {
    // teal for a gain, pink for a loss; grey for a small move, full colour for the largest
    --hue: var(--accent-2);
    --c: color-mix(in srgb, var(--hue) calc(30% + var(--v) * 70%), var(--muted));
    // a fine edge: under 1px shows as a hairline on sharp screens (a standard one draws 1px)
    --edge: 0.6px solid color-mix(in srgb, color-mix(in srgb, var(--c) 80%, #fff) 75%, transparent);
    position: absolute;
    top: calc(#{$pad + $gap * 0.5} + var(--y) * #{$py});
    left: calc(#{$pad + $gap * 0.5} + var(--x) * #{$px});
    width: calc(var(--w) * #{$px} - #{$gap});
    height: calc(var(--h) * #{$py} - #{$gap});
    padding: 0;
    border: 1px solid color-mix(in srgb, var(--c) 35%, transparent);
    border-radius: 2px;
    background: color-mix(in srgb, var(--c) 12%, transparent);
    color: var(--text);
    font: 700 8px/1 system-ui, sans-serif;
    outline: none;
    transform-style: preserve-3d;
    pointer-events: auto;
    cursor: pointer;

    &.is-down {
      --hue: var(--hot);
    }

    // The two walls that face the camera, see-through glass. Each stands on an edge of the
    // footprint: full height, turned up from that edge, and squashed to the block's height from
    // it, so it always meets the roof.
    &::before,
    &::after {
      content: '';
      position: absolute;
      border: var(--edge);
      box-shadow: inset 0 0 8px color-mix(in srgb, var(--c) 30%, transparent);
      transition: transform 0.7s $ease calc(var(--i) * 45ms);
    }

    // +y wall: stands on the bottom edge
    &::before {
      top: calc(100% + 1px - #{$full});
      left: -1px;
      width: calc(100% + 2px);
      height: $full;
      background: color-mix(in srgb, var(--c) 64%, transparent);
      transform-origin: bottom;
      transform: rotateX(-90deg) scaleY($frac);
    }

    // +x wall: stands on the right edge, a shade darker
    &::after {
      top: -1px;
      left: calc(100% + 1px - #{$full});
      width: $full;
      height: calc(100% + 2px);
      background: color-mix(in srgb, color-mix(in srgb, var(--c) 55%, #05060c) 58%, transparent);
      transform-origin: right;
      transform: rotateY(90deg) scaleX($frac);
    }

    // the roof, glass too, lifted by the move; the labels sit on it, top left (the corner the
    // blocks in front hide least)
    i {
      position: absolute;
      inset: -1px;
      z-index: 0; // its own stacking context, so the glow (::after, z -1) stays under the text
      display: grid;
      align-content: start;
      justify-items: start;
      gap: 2px;
      padding: 3px 4px;
      border: var(--edge);
      background: color-mix(in srgb, var(--c) 80%, transparent);
      box-shadow:
        inset 0 0 8px color-mix(in srgb, var(--c) 30%, transparent),
        0 0 10px color-mix(in srgb, var(--c) 22%, transparent);
      font-style: normal;
      // a halo in the page colour keeps the text readable on any tile, in either theme
      text-shadow: 0 0 3px color-mix(in srgb, var(--bg) 85%, transparent);
      transform: translateZ(calc(#{$base} + var(--v) * #{$hmax}));
      transition: transform 0.7s $ease calc(var(--i) * 45ms);

      // pointed at, focused or tapped: the roof fills in and glows, faded with opacity
      &::after {
        content: '';
        position: absolute;
        inset: -0.6px;
        z-index: -1;
        background: color-mix(in srgb, color-mix(in srgb, var(--c) 70%, #fff) 60%, transparent);
        box-shadow:
          inset 0 0 0 1px color-mix(in srgb, var(--c) 30%, #fff),
          0 0 22px color-mix(in srgb, var(--c) 75%, transparent);
        opacity: 0;
        transition: opacity 0.25s;
      }
    }

    small {
      color: color-mix(in srgb, var(--text) 80%, transparent);
      font-size: 7px;
      font-variant-numeric: tabular-nums;
    }

    &.is-sm i {
      padding: 2px;
    }

    // the smallest tiles only have room for the name
    &.is-sm small {
      display: none;
    }

    &.is-md span {
      font-size: 9px;
    }

    &.is-lg {
      i {
        padding: 6px 7px;
      }

      span {
        font-size: 15px;
      }

      small {
        font-size: 9px;
      }
    }

    // (the tile itself never moves: only its roof and walls do)
    &.is-on i::after,
    &:focus-visible i::after {
      opacity: 1;
    }
  }

  &__dock {
    display: grid;
    justify-items: center;
    gap: 6px;
    font-size: 12px;
    pointer-events: auto;

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

  &__seg {
    display: flex;
    gap: 2px;
    padding: 3px;
    border: 1px solid var(--border-strong);
    border-radius: 999px;
    background: color-mix(in srgb, var(--bg) 70%, transparent);

    button {
      padding: 4px 14px;
      border: 0;
      border-radius: 999px;
      background: transparent;
      color: var(--muted);
      font: inherit;
      font-weight: 700;
      font-variant-numeric: tabular-nums;
      cursor: pointer;

      &[aria-pressed='true'] {
        background: linear-gradient(135deg, var(--accent), var(--hot));
        color: #fff;
      }
    }
  }
}
Standalone snippet · plain CSS, no build step, no dependencies.