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