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

3D bar chart from JSON in CSS + JavaScript

Neon bars drawn from a JSON dataset: switch the year and each bar grows or shrinks to its new value, the scale on the back wall follows, and the peak lights up. Hover or tap a bar for its value. JS only turns the data into one number per bar.

  • 221 lines of CSS
  • 11 lines of HTML
  • 126 lines of JS
  • No dependencies
  • MIT licensed
Video 0.4 MB GitHub

How it works

  1. The data is plain JSON, shaped like an API response. JS turns each value into one number: --v = value ÷ top of the scale (0 to 1), written on the bar. Everything you see is CSS.
  2. A bar is a full-height box. Its front and side are squashed with scaleY(var(--v)) from the bottom, and its lid rides down by (1 − --v) × height. Only transform changes, so a new dataset is a smooth transition with no layout work.
  3. transition-delay: calc(var(--i) * 60ms) starts each bar a little after the one before, and a cubic-bezier that goes past 1 makes it overshoot and settle.
  4. The neon look is see-through glass faces with a bright 1px edge and a soft glow (box-shadow inside and out). Colours come from color-mix() along the row, and the tallest bar gets .is-peak. The pointed-at bar fills in and glows: a ::after layer on each face whose opacity fades in, so the change is smooth.
  5. The scale on the back wall is rounded up to a tidy step (Math.ceil(max / 20) * 20), so the numbers stay round whatever the data.
  6. There is one tooltip for the whole chart. JS moves it to the pointed-at bar with two custom properties (--tx, --ty) and a transition glides it there, so it slides from bar to bar with its text changing instead of blinking. It floats 40px towards you: the chart is turned, so each bar to the right stands nearer. A tap does the same on a touch screen, where there is no hover.

Key techniques

  • JSON → --v per bar (value ÷ scale top)
  • scaleY walls + translateY lid, transitioned
  • staggered transition-delay: calc(var(--i) × 60ms)
  • dark faces, bright edge + inset glow: the neon look

Copy-paste code

HTML 11 lines

<div class="chart">
  <div class="scene">
    <div class="bars3d"></div>
  </div>
  <output></output>
  <div class="seg">
    <button type="button" data-year="2024">2024</button>
    <button type="button" data-year="2025">2025</button>
    <button type="button" data-year="2026">2026</button>
  </div>
</div>

CSS 221 lines

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

.scene {
  perspective: 800px;
  padding: 30px 40px 40px;
  pointer-events: none; /* the chart is turned: only the bars take the pointer */
}

/* 6 bars × 19px + 5 gaps × 10px = 164px */
.bars3d {
  position: relative;
  width: 164px;
  height: 100px; /* the top of the scale */
  transform-style: preserve-3d;
  transform: rotateX(-18deg) rotateY(-28deg);
}

/* the floor: a neon grid laid flat along the bottom */
.floor {
  position: absolute;
  left: -12px;
  top: 74px;
  width: 188px;
  height: 52px;
  border: 1px solid rgb(139 108 255 / 0.45);
  border-radius: 6px;
  background:
    repeating-linear-gradient(90deg, rgb(139 108 255 / 0.24) 0 1px, transparent 1px 17px),
    repeating-linear-gradient(rgb(139 108 255 / 0.24) 0 1px, transparent 1px 17px),
    rgb(139 108 255 / 0.1);
  transform: rotateX(90deg);
}

/* the scale: lines at 0, half and the top, along the back of the floor; the numbers at the
   right end, which reaches out past the last bar */
.wall {
  position: absolute;
  top: -10px; /* 10px of room above the scale, so the top number is not clipped */
  left: -12px;
  width: 188px;
  height: 110px;
  transform: translateZ(-26px);
}

.wall b {
  position: absolute;
  right: 0;
  bottom: calc(var(--t) * 100px);
  left: 0;
  height: 1px;
  background: rgb(236 238 251 / 0.26);
}

.wall span {
  position: absolute;
  top: -6px;
  left: 100%;
  padding-left: 6px;
  color: #949bc0;
  font: 700 9px/12px system-ui, sans-serif;
}

.bars {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: flex-end;
  gap: 10px;
  transform-style: preserve-3d;
}

.bar {
  --v: 0; /* JS writes it: value ÷ top of the scale */
  --c: color-mix(in srgb, #8b6cff, #2ee6d6 calc(var(--i) * 20%));
  position: relative;
  flex: none;
  width: 19px;
  height: 100%;
  transform-style: preserve-3d;
}

.bar.is-peak {
  --c: #ff4d9d;
}

.bar {
  outline: none;
  pointer-events: auto;
  cursor: pointer;
}

/* One tooltip for the whole chart: JS moves it to the pointed-at bar (--tx, --ty) and it glides
   there, text changing on the way. 40px towards you (the chart is turned, so the bars to the
   right stand nearer); its thickness is a flat edge up and to the right, like the bars' depth */
.tip {
  --c: color-mix(in srgb, #8b6cff, #2ee6d6 calc(var(--i, 0) * 20%));
  position: absolute;
  top: 0;
  left: 0;
  padding: 3px 7px;
  border: 1px solid var(--c);
  border-radius: 6px;
  background: #141830;
  box-shadow:
    3px -3px 0 color-mix(in srgb, var(--c) 55%, #05060c),
    0 0 14px color-mix(in srgb, var(--c) 40%, transparent);
  color: #eceefb;
  font: 700 9px/12px system-ui, sans-serif;
  white-space: nowrap;
  opacity: 0;
  pointer-events: none;
  transform: translate(var(--tx, 0px), calc(var(--ty, 0px) - 30px)) translate(-50%, -100%) translateZ(40px);
  transition:
    transform 0.35s cubic-bezier(0.2, 0.8, 0.2, 1),
    opacity 0.2s;
}

.tip.is-peak {
  --c: #ff4d9d;
}

.tip.is-on {
  opacity: 1;
}

/* see-through glass faces with a bright edge: the neon look */
.bar i {
  position: absolute;
  top: 0;
  left: 0;
  box-sizing: border-box;
  width: 19px;
  height: 100%;
  /* a fine edge: under 1px shows as a hairline on sharp screens */
  border: 0.6px solid color-mix(in srgb, color-mix(in srgb, var(--c) 80%, #fff) 75%, transparent);
  background: color-mix(in srgb, var(--c) 58%, 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);
  transform-origin: bottom center;
  /* past 1: overshoot and settle; each bar 60ms after the one before */
  transition: transform 0.8s cubic-bezier(0.3, 1.3, 0.5, 1) calc(var(--i) * 60ms);
}

/* the pointed-at bar fills in and glows; faded with opacity, so it is smooth */
.bar i::after {
  content: '';
  position: absolute;
  inset: 0;
  background: color-mix(in srgb, var(--c) 50%, transparent);
  box-shadow: 0 0 20px color-mix(in srgb, var(--c) 60%, transparent);
  opacity: 0;
  transition: opacity 0.25s;
}

.bar.is-active i::after {
  opacity: 1;
}

/* front */
.bar i:nth-child(1) {
  transform: translateZ(9.5px) scaleY(var(--v));
}

/* right side, darker glass */
.bar i:nth-child(2) {
  background: color-mix(in srgb, color-mix(in srgb, var(--c) 55%, #05060c) 64%, transparent);
  transform: rotateY(90deg) translateZ(9.5px) scaleY(var(--v));
}

/* the lid: a 19px square laid flat, riding down with the value */
.bar i:nth-child(3) {
  height: 19px;
  background: color-mix(in srgb, var(--c) 80%, transparent);
  transform-origin: center;
  transform: translateY(calc((1 - var(--v)) * 100px)) rotateX(90deg) translateZ(9.5px);
}

.bar span {
  position: absolute;
  top: calc(100% + 8px);
  left: -7px;
  width: 33px;
  color: #949bc0;
  font: 700 9px/12px system-ui, sans-serif;
  text-align: center;
  transform: translateZ(9.5px);
}

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 126 lines

// The data, as an API would send it back
const SALES = {
  "prefix": "$",
  "suffix": "k",
  "years": {
    "2024": [
      { "label": "Jan", "value": 42 },
      { "label": "Feb", "value": 58 },
      { "label": "Mar", "value": 51 },
      { "label": "Apr", "value": 73 },
      { "label": "May", "value": 66 },
      { "label": "Jun", "value": 88 }
    ],
    "2025": [
      { "label": "Jan", "value": 64 },
      { "label": "Feb", "value": 71 },
      { "label": "Mar", "value": 97 },
      { "label": "Apr", "value": 82 },
      { "label": "May", "value": 109 },
      { "label": "Jun", "value": 94 }
    ],
    "2026": [
      { "label": "Jan", "value": 118 },
      { "label": "Feb", "value": 96 },
      { "label": "Mar", "value": 131 },
      { "label": "Apr", "value": 124 },
      { "label": "May", "value": 142 },
      { "label": "Jun", "value": 157 }
    ]
  }
};

const chart = document.querySelector('.bars3d');
const out = document.querySelector('.chart output');
const buttons = document.querySelectorAll('.seg button');
const TICKS = [0, 0.5, 1]; // the scale's lines, as fractions of its top

// Build the chart once from the data: the floor, the scale, and a bar with three faces per value
const first = Object.values(SALES.years)[0];
chart.innerHTML =
  '<div class="floor"></div>' +
  '<div class="wall">' + TICKS.map((t) => `<b style="--t:${t}"><span></span></b>`).join('') + '</div>' +
  '<div class="bars">' + first.map((_, i) => `<div class="bar" style="--i:${i}" tabindex="0"><i></i><i></i><i></i><span></span></div>`).join('') + '</div>' +
  '<b class="tip" aria-hidden="true"></b>';
const bars = [...chart.querySelectorAll('.bar')];
const ticks = chart.querySelectorAll('.wall span');
const tipEl = chart.querySelector('.tip');
// labels go in as text, never as HTML: data from an API is not trusted markup
first.forEach((d, i) => (bars[i].querySelector('span').textContent = d.label));

// money as most dashboards write it: the sign in front, the k right after the number ($88k)
const money = (v) => SALES.prefix + v + SALES.suffix;
const W = 19, PITCH = 29, H = 100; // a bar's width, width + gap, and the scale's height (as in the CSS)
let shown; // the year on screen: { rows, top, peak }
let active = -1; // the bar the tooltip is on
let hideTimer;

// Show one year: each bar gets --v, its value as a fraction of the scale's top. CSS does the rest.
function show(year) {
  const rows = SALES.years[year];
  const values = rows.map((r) => r.value);
  const top = Math.ceil(Math.max(...values) / 20) * 20; // a tidy top for the scale
  const peak = values.indexOf(Math.max(...values));
  shown = { rows, top, peak };
  rows.forEach((r, i) => {
    bars[i].style.setProperty('--v', r.value / top);
    bars[i].classList.toggle('is-peak', i === peak);
    bars[i].setAttribute('aria-label', `${r.label} · ${money(r.value)}`);
  });
  ticks.forEach((t, k) => (t.textContent = Math.round(TICKS[k] * top)));
  out.textContent = `${year} · peak ${rows[peak].label}, ${money(rows[peak].value)}`;
  buttons.forEach((b) => b.setAttribute('aria-pressed', b.dataset.year === year));
  if (active >= 0) place(active); // the tooltip follows its bar to the new height
}

// One tooltip for the chart: it glides to the bar (--tx, --ty) and its text changes on the way
function place(i) {
  clearTimeout(hideTimer);
  const r = shown.rows[i];
  const wasOn = tipEl.classList.contains('is-on');
  if (!wasOn) tipEl.style.transition = 'none'; // from hidden: appear in place, don't fly in
  tipEl.textContent = `${r.label} · ${money(r.value)}`;
  tipEl.style.setProperty('--i', i);
  tipEl.style.setProperty('--tx', i * PITCH + W / 2 + 'px');
  tipEl.style.setProperty('--ty', (1 - r.value / shown.top) * H + 'px');
  tipEl.classList.toggle('is-peak', i === shown.peak);
  if (!wasOn) {
    void tipEl.offsetWidth; // apply the new place before the transition comes back
    tipEl.style.transition = '';
  }
  tipEl.classList.add('is-on');
  bars.forEach((b, k) => b.classList.toggle('is-active', k === i)); // the bar fills in and glows
  active = i;
}
// a short grace period, so crossing the gap between two bars does not flicker it
function hide() {
  clearTimeout(hideTimer);
  hideTimer = setTimeout(() => {
    tipEl.classList.remove('is-on');
    bars.forEach((b) => b.classList.remove('is-active'));
    active = -1;
  }, 150);
}

const over = (e) => {
  const bar = e.target.closest('.bar');
  if (bar) place(bars.indexOf(bar));
};
const leave = (e) => {
  if (!e.relatedTarget?.closest?.('.bar')) hide();
};
chart.addEventListener('pointerover', over);
chart.addEventListener('focusin', over);
chart.addEventListener('pointerout', leave);
chart.addEventListener('focusout', leave);
// a finger has no hover: a tap on a bar shows its tooltip, a tap elsewhere hides it
document.addEventListener('pointerup', (e) => {
  if (e.pointerType === 'mouse') return;
  const bar = e.target.closest('.bar');
  if (bar) place(bars.indexOf(bar));
  else hide();
});

buttons.forEach((b) => b.addEventListener('click', () => show(b.dataset.year)));

show('2024');

Sass source 253 lines

// A bar chart drawn from JSON. JS writes one number per bar, --v: its value divided by the top of
// the scale (0–1). Each bar is a full-height box whose walls are squashed with scaleY(--v) and
// whose lid rides down by (1 − --v) of the height, so a new dataset is only a transition on
// transform: no layout, and every bar starts a little after the one before (--i).
$w: 19px; // bar width, and depth
$gap: 10px;
$h: 100px; // the top of the scale
$floor: 52px; // the floor's depth, front to back
$n: 6;
$span: $w * $n + $gap * ($n - 1);
$ease: cubic-bezier(0.3, 1.3, 0.5, 1); // past 1: a bar overshoots and settles

.d-neonbars {
  display: grid;
  grid-template-rows: minmax(0, 1fr) auto;
  width: 100%;
  height: 100%;
  padding: 10px 14px 14px; // the control dock: the same place in every model with controls
  // The chart is turned, so its left bars sit behind the plane of the flat boxes around it (this
  // one and the view), which would catch the pointer first: only the bars and the dock take it.
  pointer-events: none;

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

  &__chart {
    position: relative;
    width: $span;
    height: $h;
    transform-style: preserve-3d;
    transform: rotateX(-18deg) rotateY(-28deg);
  }

  // the floor: a neon grid laid flat along the bottom of the bars
  &__floor {
    position: absolute;
    left: -12px;
    top: $h - $floor * 0.5;
    width: $span + 24px;
    height: $floor;
    border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent);
    border-radius: 6px;
    background:
      repeating-linear-gradient(90deg, color-mix(in srgb, var(--accent) 24%, transparent) 0 1px, transparent 1px 17px),
      repeating-linear-gradient(color-mix(in srgb, var(--accent) 24%, transparent) 0 1px, transparent 1px 17px),
      color-mix(in srgb, var(--accent) 10%, transparent);
    transform: rotateX(90deg);
  }

  // the scale: lines at 0, half and the top, on a wall along the back edge of the floor. Its
  // numbers are at the right end, which reaches out past the last bar (the left end is hidden
  // behind the first one).
  // (its box starts 10px above the scale: a turned layer is clipped at its box, and the top
  // number, which pokes out above its line, was cut in half)
  &__wall {
    position: absolute;
    top: -10px;
    left: -12px;
    width: $span + 24px;
    height: $h + 10px;
    transform: translateZ($floor * -0.5);

    b {
      position: absolute;
      right: 0;
      bottom: calc(var(--t) * #{$h});
      left: 0;
      height: 1px;
      background: color-mix(in srgb, var(--text) 26%, transparent);
    }

    span {
      position: absolute;
      top: -6px;
      left: 100%;
      padding-left: 6px;
      color: var(--muted);
      font: 700 9px/12px system-ui, sans-serif;
      font-variant-numeric: tabular-nums;
    }
  }

  &__bars {
    position: absolute;
    inset: 0;
    display: flex;
    align-items: flex-end;
    gap: $gap;
    transform-style: preserve-3d;
  }

  &__bar {
    // violet → teal along the row; the tallest bar of the dataset turns pink
    --c: color-mix(in srgb, var(--accent), var(--accent-2) calc(var(--i) * 20%));
    position: relative;
    flex: none;
    width: $w;
    height: 100%;
    outline: none;
    transform-style: preserve-3d;
    pointer-events: auto;
    cursor: pointer;

    &.is-peak {
      --c: var(--hot);
    }

    // see-through glass faces with a bright edge: the neon look
    i {
      position: absolute;
      top: 0;
      left: 0;
      width: $w;
      height: 100%;
      // a fine edge: under 1px shows as a hairline on sharp screens (a standard one draws 1px)
      border: 0.6px solid color-mix(in srgb, color-mix(in srgb, var(--c) 80%, #fff) 75%, transparent);
      background: color-mix(in srgb, var(--c) 58%, 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);
      transform-origin: bottom center;
      transition: transform 0.8s $ease calc(var(--i) * 60ms);

      // the pointed-at bar fills in and glows (.is-active, set with the tooltip); faded in with
      // opacity, so it is smooth
      &::after {
        content: '';
        position: absolute;
        inset: 0;
        background: color-mix(in srgb, var(--c) 50%, transparent);
        box-shadow: 0 0 20px color-mix(in srgb, var(--c) 60%, transparent);
        opacity: 0;
        transition: opacity 0.25s;
      }
    }

    &.is-active i::after {
      opacity: 1;
    }

    // front
    i:nth-child(1) {
      transform: translateZ($w * 0.5) scaleY(var(--v));
    }

    // right side, darker
    i:nth-child(2) {
      background: color-mix(in srgb, color-mix(in srgb, var(--c) 55%, #05060c) 64%, transparent);
      transform: rotateY(90deg) translateZ($w * 0.5) scaleY(var(--v));
    }

    // the lid: a $w square laid flat, riding down with the value
    i:nth-child(3) {
      height: $w;
      background: color-mix(in srgb, var(--c) 80%, transparent);
      transform-origin: center;
      transform: translateY(calc((1 - var(--v)) * #{$h})) rotateX(90deg) translateZ($w * 0.5);
    }

    // the label, standing in front of the bar
    span {
      position: absolute;
      top: calc(100% + 8px);
      left: -7px;
      width: $w + 14px;
      color: var(--muted);
      font: 700 9px/12px system-ui, sans-serif;
      text-align: center;
      transform: translateZ($w * 0.5);
    }
  }

  // One tooltip for the chart: JS moves it to the pointed-at bar (--tx, --ty, --i) and CSS glides
  // it there, so it slides from bar to bar with its text changing instead of blinking. It lives
  // in the chart's turned plane, 40px towards you (each bar to the right stands nearer than the
  // one before). Its thickness is a flat edge offset up and to the right, the way the bars'
  // depth runs: a real plate behind it was split into strips by the browser's depth sorting.
  &__tip {
    --c: color-mix(in srgb, var(--accent), var(--accent-2) calc(var(--i, 0) * 20%));
    position: absolute;
    top: 0;
    left: 0;
    padding: 3px 7px;
    border: 1px solid var(--c);
    border-radius: 6px;
    background: var(--surface-solid);
    box-shadow:
      3px -3px 0 color-mix(in srgb, var(--c) 55%, #05060c),
      0 0 14px color-mix(in srgb, var(--c) 40%, transparent);
    color: var(--text);
    font: 700 9px/12px system-ui, sans-serif;
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
    opacity: 0;
    pointer-events: none;
    transform: translate(var(--tx, 0px), calc(var(--ty, 0px) - 30px)) translate(-50%, -100%) translateZ(40px);
    transition:
      transform 0.35s cubic-bezier(0.2, 0.8, 0.2, 1),
      opacity 0.2s;

    &.is-peak {
      --c: var(--hot);
    }

    &.is-on {
      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.