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

3D chart panel (SVG) in CSS + JavaScript

A neon line chart drawn as SVG from a JSON array, floating on a tilted glass panel in layers. Hover and it lies flat so you can read the values. The tilt is on the container, so the same works for a chart from any library.

  • 134 lines of CSS
  • 9 lines of HTML
  • 37 lines of JS
  • No dependencies
  • Snippet under MIT
GitHub

How it works

  1. The chart is ordinary SVG. JS maps each value in the JSON to a point (x along the width, y by where the value sits between the lowest and highest), then joins them into one path: M x y L x y …. The same path, closed down to the bottom, is the shaded area.
  2. The 3D is only on containers: the panel is tilted with rotateX / rotateY / rotateZ, and the area, the line and the dots sit at translateZ 5, 10 and 14 units, so they float over the glass at different depths.
  3. Anything floating over a tilted panel is seen shifted from the spot under it, up and to the side, by about its depth × sin(tilt): 9 units for the dots. So the points keep 24 units from the sides, 16 from the bottom and 36 from the top (room for the label over the highest value as well), and every dot and label stays over the glass at every angle.
  4. Hovering the still wrapper sets the panel to rotateX(0) rotateY(0) rotateZ(0): it lies flat, and the values fade in, so you can read it. The wrapper never moves, so the hover never flickers.
  5. The neon glow is the same path drawn again, wide and faint, underneath: cheaper than a blur filter and sharp at any zoom.
  6. Because the tilt is on the container, the same CSS works around a chart from any library that draws SVG or canvas.
  7. Every length is a multiple of one base unit, --u, and the SVG viewBox is the panel's size in those units, so one SVG unit is one --u. JS writes each dot's place as plain numbers that CSS multiplies by the unit; a px string from JS would stay put while the panel scaled, and the dots would slide off the line on a big screen.

Key techniques

  • JSON → SVG path (M x y L x y …)
  • layers at translateZ 0 / 5 / 10 / 14 units
  • tilt on :hover of a still wrapper → flat
  • glow = a wide faint stroke under the line (no filter)

Copy-paste code

HTML 9 lines

<div class="panel3d" tabindex="0" aria-label="Monthly revenue">
  <div class="panel">
    <div class="glass"></div>
    <svg class="area" viewBox="0 0 200 110" aria-hidden="true"><path /></svg>
    <svg class="line" viewBox="0 0 200 110" aria-hidden="true"><path class="glow" /><path /></svg>
    <div class="dots"></div>
    <b class="title">Revenue <small>$k</small></b>
  </div>
</div>

CSS 134 lines

.panel3d {
  /* one base unit: every length of the panel is a multiple of it, so it is the same share of a
     card, the editor, a full screen and a recording canvas */
  --u: 0.4vmin;
  position: relative;
  width: calc(200 * var(--u));
  height: calc(110 * var(--u));
  /* more room below than above: the panel's shadow falls well below it, so this is what
     centres what is drawn rather than the panel's box. None at the sides: with them the box was
     112vmin wide, wider than a square canvas, and a box wider than the canvas does not centre, it
     hangs off the right-hand side (6vmin off the middle on a 1:1 or 9:16 canvas) */
  margin: calc(32 * var(--u)) 0 calc(48 * var(--u));
  outline: none;
  transform-style: preserve-3d;
  perspective: calc(800 * var(--u));
  font-family: Inter, system-ui, sans-serif;
}

/* the tilt lives here; the wrapper above stays still, so hovering it never flickers */
.panel {
  position: absolute;
  inset: 0;
  transform-style: preserve-3d;
  transform: rotateX(40deg) rotateY(-16deg) rotateZ(5deg);
  transition: transform 0.7s cubic-bezier(0.2, 0.8, 0.2, 1);
  pointer-events: none;
}

.panel3d:hover .panel,
.panel3d:focus-visible .panel {
  transform: rotateX(0deg) rotateY(0deg) rotateZ(0deg);
}

/* the glass, with faint value lines */
.glass {
  position: absolute;
  inset: 0;
  border: calc(1 * var(--u)) solid rgb(139 108 255 / 0.5);
  border-radius: calc(12 * var(--u));
  background:
    repeating-linear-gradient(transparent 0 calc(21 * var(--u)), rgb(236 238 251 / 0.1) calc(21 * var(--u)) calc(22 * var(--u))) 0 calc(12 * var(--u)) / 100% calc(100% - calc(24 * var(--u))) no-repeat,
    linear-gradient(150deg, #2a2560, #141830);
  box-shadow: 0 calc(18 * var(--u)) calc(36 * var(--u)) calc(-14 * var(--u)) rgb(0 0 0 / 0.55);
}

.panel svg {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: visible;
}

.area {
  transform: translateZ(calc(5 * var(--u)));
}

.area path {
  fill: rgb(46 230 214 / 0.16);
}

.line {
  transform: translateZ(calc(10 * var(--u)));
}

.line path {
  fill: none;
  stroke: #2ee6d6;
  stroke-width: 2.5;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* the glow: the same path, wide and faint, under the line */
.line .glow {
  stroke-width: 9;
  stroke-opacity: 0.22;
}

.dots {
  position: absolute;
  inset: 0;
  transform: translateZ(calc(14 * var(--u)));
}

.dots i {
  position: absolute;
  /* --x, --y: plain numbers in the panel's own units, from JS */
  top: calc(var(--y) * var(--u));
  left: calc(var(--x) * var(--u));
  width: calc(7 * var(--u));
  height: calc(7 * var(--u));
  margin: calc(-3.5 * var(--u)) 0 0 calc(-3.5 * var(--u));
  border-radius: 50%;
  background: #fff;
  box-shadow: 0 0 0 calc(2 * var(--u)) #2ee6d6, 0 0 calc(10 * var(--u)) #2ee6d6;
}

/* values: hidden while tilted (except the latest), shown when the panel lies flat */
.dots span {
  position: absolute;
  bottom: calc(9 * var(--u));
  left: 50%;
  padding: calc(1 * var(--u)) calc(3 * var(--u));
  border-radius: calc(4 * var(--u));
  background: rgb(20 24 48 / 0.85);
  color: #eceefb;
  font: 700 calc(10 * var(--u))/calc(12 * var(--u)) Inter, system-ui, sans-serif;
  white-space: nowrap;
  opacity: 0;
  translate: -50% 0;
  transition: opacity 0.3s;
}

.dots i:last-child span,
.panel3d:hover .dots span,
.panel3d:focus-visible .dots span {
  opacity: 1;
}

.title {
  position: absolute;
  top: calc(9 * var(--u));
  left: calc(12 * var(--u));
  color: #eceefb;
  font: 800 calc(10 * var(--u))/calc(12 * var(--u)) Inter, system-ui, sans-serif;
  letter-spacing: 0.04em;
  transform: translateZ(calc(6 * var(--u)));
}

.title small {
  color: #949bc0;
  font-weight: 700;
}

JS 37 lines

// The data, as an API would send it back
const TREND = {
  "unit": "$k",
  "labels": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
  "values": [18, 24, 21, 30, 27, 36, 33, 41, 37, 47, 44, 55]
};

// The panel is 200 × 110 units of --u and the SVG viewBox is the same, so one SVG unit is one --u
const W = 200, H = 110;
// room round the points: a dot floats 14 units over the tilted glass and is seen up to 9 units
// off the spot under it, and the highest value's label stands over its dot
const SIDE = 24, TOP = 36, BOTTOM = 16;
const lo = Math.min(...TREND.values);
const hi = Math.max(...TREND.values);

// JSON → points: x evenly along the width, y by where the value sits between lo and hi
const points = TREND.values.map((v, i) => [
  SIDE + (i * (W - 2 * SIDE)) / (TREND.values.length - 1),
  H - BOTTOM - ((v - lo) / (hi - lo)) * (H - BOTTOM - TOP),
]);

// points → one path: "M x y L x y …"; closed down to the bottom, it is the area
const line = 'M' + points.map(([x, y]) => `${x.toFixed(1)} ${y.toFixed(1)}`).join(' L');
const area = `${line} L${points.at(-1)[0]} ${H} L${points[0][0]} ${H} Z`;
document.querySelectorAll('.line path').forEach((p) => p.setAttribute('d', line));
document.querySelector('.area path').setAttribute('d', area);

// a dot per value, with its number (shown when the panel lies flat)
const dots = document.querySelector('.dots');
points.forEach(([x, y], i) => {
  const dot = document.createElement('i');
  dot.style.cssText = `--x:${x.toFixed(1)}; --y:${y.toFixed(1)}`; // plain numbers: CSS multiplies them by --u
  const label = document.createElement('span');
  label.textContent = TREND.values[i];
  dot.append(label);
  dots.append(dot);
});
Standalone snippet · plain CSS, no build step, no dependencies.