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.

  • 126 lines of CSS
  • 9 lines of HTML
  • 35 lines of JS
  • No dependencies
  • MIT licensed
Video 1.0 MB 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 8, 16 and 22px, so they float over the glass at different depths.
  3. 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.
  4. The neon glow is the same path drawn again, wide and faint, underneath: cheaper than a blur filter and sharp at any zoom.
  5. Because the tilt is on the container, the same CSS works around a chart from any library that draws SVG or canvas.

Key techniques

  • JSON → SVG path (M x y L x y …)
  • layers at translateZ 0 / 8 / 16 / 22px
  • 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 126 lines

.panel3d {
  position: relative;
  width: 200px;
  height: 110px;
  margin: 40px;
  outline: none;
  transform-style: preserve-3d;
  perspective: 800px;
  font-family: 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: 1px solid rgb(139 108 255 / 0.5);
  border-radius: 12px;
  background:
    repeating-linear-gradient(transparent 0 21px, rgb(236 238 251 / 0.1) 21px 22px) 0 12px / 100% calc(100% - 24px) no-repeat,
    linear-gradient(150deg, #2a2560, #141830);
  box-shadow: 0 18px 36px -14px rgb(0 0 0 / 0.55);
}

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

.area {
  transform: translateZ(8px);
}

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

.line {
  transform: translateZ(16px);
}

.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(22px);
}

.dots i {
  position: absolute;
  top: var(--y);
  left: var(--x);
  width: 7px;
  height: 7px;
  margin: -3.5px 0 0 -3.5px;
  border-radius: 50%;
  background: #fff;
  box-shadow: 0 0 0 2px #2ee6d6, 0 0 10px #2ee6d6;
}

/* values: hidden while tilted (except the latest), shown when the panel lies flat */
.dots span {
  position: absolute;
  bottom: 9px;
  left: 50%;
  padding: 1px 3px;
  border-radius: 4px;
  background: rgb(20 24 48 / 0.85);
  color: #eceefb;
  font: 700 8px/10px 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: 9px;
  left: 12px;
  color: #eceefb;
  font: 800 10px/12px system-ui, sans-serif;
  letter-spacing: 0.04em;
  transform: translateZ(10px);
}

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

JS 35 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 × 110px and the SVG viewBox is the same, so one SVG unit is one pixel
const W = 200, H = 110, PAD = 12;
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
// (14px of headroom at the top for the title)
const points = TREND.values.map((v, i) => [
  PAD + (i * (W - 2 * PAD)) / (TREND.values.length - 1),
  H - PAD - ((v - lo) / (hi - lo)) * (H - 2 * PAD - 14),
]);

// 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}px; --y:${y}px`;
  const label = document.createElement('span');
  label.textContent = TREND.values[i];
  dot.append(label);
  dots.append(dot);
});

Sass source 141 lines

// A line chart on a glass panel, tilted in 3D. The chart is plain SVG (JS turns a JSON array
// into its path); the 3D is all on the containers: the panel tilts, and the area, the line and
// the dots float at different depths over it. Hovering the (still) wrapper lays the panel flat
// so the values can be read, and shows them.
$w: 200px;
$h: 110px;

.d-chartpanel {
  position: relative;
  width: $w;
  height: $h;
  outline: none;
  transform-style: preserve-3d;

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

  &:hover &__panel,
  &:focus-visible &__panel {
    transform: rotateX(0deg) rotateY(0deg) rotateZ(0deg);
  }

  // the glass, with faint value lines
  &__glass {
    position: absolute;
    inset: 0;
    border: 1px solid color-mix(in srgb, var(--accent) 50%, transparent);
    border-radius: 12px;
    background:
      repeating-linear-gradient(transparent 0 21px, color-mix(in srgb, var(--text) 10%, transparent) 21px 22px) 0 12px / 100% calc(100% - 24px) no-repeat,
      linear-gradient(150deg, color-mix(in srgb, var(--accent) 24%, var(--surface-solid)), var(--surface-solid));
    box-shadow: 0 18px 36px -14px rgb(0 0 0 / 0.55);
  }

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

  &__area {
    transform: translateZ(8px);

    path {
      fill: color-mix(in srgb, var(--accent-2) 16%, transparent);
    }
  }

  &__line {
    transform: translateZ(16px);

    path {
      fill: none;
      stroke: var(--accent-2);
      stroke-width: 2.5;
      stroke-linecap: round;
      stroke-linejoin: round;
    }
  }

  // the glow: the same path, wide and faint, under the line (cheaper than a blur filter)
  &__glow {
    stroke-width: 9;
    stroke-opacity: 0.22;
  }

  &__dots {
    position: absolute;
    inset: 0;
    transform: translateZ(22px);

    i {
      position: absolute;
      top: var(--y);
      left: var(--x);
      width: 7px;
      height: 7px;
      margin: -3.5px 0 0 -3.5px;
      border-radius: 50%;
      background: #fff;
      box-shadow:
        0 0 0 2px var(--accent-2),
        0 0 10px var(--accent-2);
    }

    // values: hidden while tilted (except the latest), shown when the panel lies flat
    span {
      position: absolute;
      bottom: 9px;
      left: 50%;
      padding: 1px 3px;
      border-radius: 4px;
      background: color-mix(in srgb, var(--surface-solid) 85%, transparent);
      color: var(--text);
      font: 700 8px/10px system-ui, sans-serif;
      font-variant-numeric: tabular-nums;
      white-space: nowrap;
      opacity: 0;
      translate: -50% 0;
      transition: opacity 0.3s;
    }

    i:last-child span {
      opacity: 1;
    }
  }

  &:hover &__dots span,
  &:focus-visible &__dots span {
    opacity: 1;
  }

  &__title {
    position: absolute;
    top: 9px;
    left: 12px;
    color: var(--text);
    font: 800 10px/12px system-ui, sans-serif;
    letter-spacing: 0.04em;
    transform: translateZ(10px);

    small {
      color: var(--muted);
      font-weight: 700;
    }
  }
}

// The vertical video is narrower, for its zoom, than a card, and the flat panel is this model's
// widest state: a touch smaller there so it stays in frame (cards and pages are unaffected).
[data-reel] .d-chartpanel {
  scale: 0.88;
}
Standalone snippet · plain CSS, no build step, no dependencies.