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

3D sales funnel from JSON in CSS + JavaScript

A conversion funnel drawn from JSON: every stage is a glass slab as wide as its share of the first one. Switch the channel and the slabs narrow or widen to the new numbers; point at a stage and one tooltip glides there with its conversion from the stage before. JS only turns the data into one number per stage.

  • 190 lines of CSS
  • 10 lines of HTML
  • 107 lines of JS
  • No dependencies
  • MIT licensed
Video 0.5 MB GitHub

How it works

  1. The data is plain JSON, shaped like an API response. JS turns each stage into one number: --v = value ÷ first stage (0 to 1), written on its row. Everything you see is CSS.
  2. A stage is a slab of three glass faces. The front and the lid are full width and squashed with scaleX(var(--v)) from the middle; the right side has a fixed size and rides out to the new edge with translateX(--v × half the width). Only transform changes, so a new dataset is a smooth transition with no layout work.
  3. The colour comes from where a stage sits, --t from 0 to 1: two nested color-mix(in oklch, …) go violet → teal over the first half and teal → pink over the second, so any number of stages gets a clean ramp (srgb would pass through grey between teal and pink). Mixing with transparent makes the faces glass.
  4. The chart is turned, so part of it lies behind the flat boxes around it and they would catch the pointer. They get pointer-events: none; only the rows take it. A row is the full width of the chart and never moves: the slab inside it is what changes, so pointing never flickers.
  5. One tooltip serves the whole chart. JS writes the pointed-at slab's edge into --tx / --ty and a transform transition glides it there while the text changes. It floats translateZ(40px) so nearer slabs never cover it, and hides 150ms after the pointer leaves every row, so moving between rows never blinks. A finger has no hover, so a tap does the same.

Key techniques

  • JSON → --v per stage (value ÷ first stage)
  • scaleX front + lid, side rides out with translateX
  • color-mix(in oklch) ramp violet → teal → pink along --t
  • one shared tooltip, glided with --tx / --ty

Copy-paste code

HTML 10 lines

<div class="funnel">
  <div class="scene">
    <div class="funnel3d"></div>
  </div>
  <output></output>
  <div class="seg">
    <button type="button" data-set="Web">Web</button>
    <button type="button" data-set="App">App</button>
  </div>
</div>

CSS 190 lines

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

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

/* 132px for the widest slab, 58px on its right for the labels */
.funnel3d {
  position: relative;
  width: 190px;
  transform-style: preserve-3d;
  transform: rotateX(-22deg) rotateY(-18deg);
}

/* violet → teal over the first half of --t, teal → pink over the second; oklch goes round
   the hue wheel instead of through grey as srgb would */
.stage,
.tip {
  --c: color-mix(in oklch,
    color-mix(in oklch, #8b6cff, #2ee6d6 clamp(0%, var(--t, 0) * 200%, 100%)),
    #ff4d9d clamp(0%, (var(--t, 0) * 2 - 1) * 100%, 100%));
}

/* one row per stage: the hit target, full width, never moves */
.stage {
  --v: 1; /* JS writes it: value ÷ first stage */
  position: relative;
  height: 26px;
  outline: none;
  transform-style: preserve-3d;
  pointer-events: auto;
  cursor: pointer;
}

.stage > * {
  position: absolute;
  pointer-events: none; /* the moving parts never catch the pointer */
}

/* coloured glass: the colour mixed with transparent, a fine bright edge, a small inner glow */
.stage i {
  /* 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);
  /* past 1: overshoot and settle; each stage 60ms after the one before */
  transition:
    transform 0.8s cubic-bezier(0.3, 1.3, 0.5, 1) calc(var(--i) * 60ms),
    opacity 0.25s;
}

/* the pointed-at stage fills in and glows: an overlay faded in with opacity */
.stage 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;
}

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

/* and the others step back */
.funnel3d:has(.is-active) .stage:not(.is-active) i {
  opacity: 0.45;
}

/* front: full width, squashed from the middle. It reaches 1px above the lid: on the same spot
   as the lid's (nearly side-on) front edge, the two lines would step over each other as dashes */
.stage i:nth-child(1) {
  top: 4px;
  left: 0;
  width: 132px;
  height: 17px;
  transform: translateZ(14px) scaleX(var(--v));
}

/* the lid: 28px deep, laid flat on top, squashed the same way */
.stage i:nth-child(2) {
  top: -9px;
  left: 0;
  width: 132px;
  height: 28px;
  background: color-mix(in srgb, var(--c) 80%, transparent);
  transform: rotateX(90deg) scaleX(var(--v));
}

/* the right side, darker, keeps its size and rides out to the slab's edge */
.stage i:nth-child(3) {
  top: 5px;
  left: 52px;
  width: 28px;
  height: 16px;
  background: color-mix(in srgb, color-mix(in srgb, var(--c) 55%, #05060c) 64%, transparent);
  transform: translateX(calc(var(--v) * 66px)) rotateY(90deg);
}

/* the name and value, following the slab's right edge */
.stage span {
  top: 1px;
  left: 66px;
  color: #eceefb;
  font: 700 9px/12px system-ui, sans-serif;
  white-space: nowrap;
  transform: translateX(calc(var(--v) * 66px + 12px)) translateZ(14px);
  transition: transform 0.8s cubic-bezier(0.3, 1.3, 0.5, 1) calc(var(--i) * 60ms);
}

.stage strong {
  display: block;
  font-weight: inherit;
}

.stage em {
  display: block;
  color: #949bc0;
  font-style: normal;
  font-weight: 600;
}

/* One tooltip for the chart: JS moves it to the pointed-at slab (--tx = its right edge,
   --ty = its top) and the transition glides it there. It floats 40px towards you; its
   thickness is a hard shadow offset up and right, the way the slabs' depth runs on screen. */
.tip {
  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;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
  opacity: 0;
  pointer-events: none;
  transform: translate(calc(var(--tx, 0px) + 16px), calc(var(--ty, 0px) - 4px)) translate(-100%, -100%) translateZ(40px);
  transition:
    transform 0.35s cubic-bezier(0.2, 0.8, 0.2, 1),
    opacity 0.2s;
}

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

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

// The data, as an API would send it back
const FUNNEL = {
  "datasets": {
    "Web": [
      { "label": "Visitors", "value": 12400 },
      { "label": "Sign-ups", "value": 3720 },
      { "label": "Trials", "value": 1480 },
      { "label": "Paid", "value": 410 }
    ],
    "App": [
      { "label": "Visitors", "value": 8200 },
      { "label": "Sign-ups", "value": 3940 },
      { "label": "Trials", "value": 2050 },
      { "label": "Paid", "value": 780 }
    ]
  }
};

const chart = document.querySelector('.funnel3d');
const out = document.querySelector('.funnel output');
const buttons = document.querySelectorAll('.seg button');
const W = 132, ROW = 26, PAD = 5; // the slab width, row height and slab top, as in the CSS

const fmt = (n) => n.toLocaleString('en-US'); // 12,400
// one decimal below 10% (3.3%), whole numbers above (40%)
const pct = (x) => (x < 0.1 ? (x * 100).toFixed(1) : Math.round(x * 100)) + '%';

// Build the chart once: a row per stage, each with three faces and a label, plus ONE tooltip.
// --t is the stage's place on the colour ramp (0 = first, 1 = last).
const first = Object.values(FUNNEL.datasets)[0];
chart.innerHTML =
  first.map((_, i) => `<div class="stage" style="--i:${i};--t:${i / (first.length - 1)}" tabindex="0"><i></i><i></i><i></i><span><strong></strong><em></em></span></div>`).join('') +
  '<b class="tip" aria-hidden="true"></b>';
const rows = [...chart.querySelectorAll('.stage')];
const tipEl = chart.querySelector('.tip');
let data = first;
let active = -1;
let hideTimer = 0;

const tipText = (i) => {
  const r = data[i], prev = data[i - 1];
  return `${r.label} · ${fmt(r.value)} · ${prev ? pct(r.value / prev.value) + ' of ' + prev.label.toLowerCase() : '100%'}`;
};

// Move the one tooltip to stage i: JS writes the slab's right edge and top, CSS glides it there.
function place(i) {
  clearTimeout(hideTimer);
  const v = data[i].value / data[0].value;
  const wasOn = tipEl.classList.contains('is-on');
  if (!wasOn) tipEl.style.transition = 'none'; // from hidden: appear in place, no fly-in
  tipEl.textContent = tipText(i); // text, never HTML: data from an API is not trusted markup
  tipEl.style.setProperty('--t', i / (data.length - 1));
  tipEl.style.setProperty('--tx', ((1 + v) * W) / 2 + 'px');
  tipEl.style.setProperty('--ty', i * ROW + PAD + 'px');
  if (!wasOn) {
    void tipEl.offsetWidth; // apply the new place before the transition comes back
    tipEl.style.transition = '';
  }
  tipEl.classList.add('is-on');
  rows.forEach((r, k) => r.classList.toggle('is-active', k === i));
  active = i;
}

// hide after a short grace period, so moving from one row to the next never blinks
function hide() {
  clearTimeout(hideTimer);
  hideTimer = setTimeout(() => {
    tipEl.classList.remove('is-on');
    rows.forEach((r) => r.classList.remove('is-active'));
    active = -1;
  }, 150);
}

const rowOf = (e) => e.target.closest('.stage');
const over = (e) => rowOf(e) && place(rows.indexOf(rowOf(e)));
const leave = (e) => { if (!e.relatedTarget?.closest?.('.stage')) 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 stage shows its tooltip, a tap elsewhere hides it
document.addEventListener('pointerup', (e) => {
  if (e.pointerType === 'mouse') return;
  if (rowOf(e)) place(rows.indexOf(rowOf(e)));
  else hide();
});

// Show one dataset: each row gets --v, its value as a fraction of the first stage. CSS does the rest.
// Names and numbers go in with textContent, never as HTML.
function show(name) {
  data = FUNNEL.datasets[name];
  data.forEach((r, i) => {
    rows[i].style.setProperty('--v', r.value / data[0].value);
    rows[i].setAttribute('aria-label', tipText(i));
    rows[i].querySelector('strong').textContent = r.label;
    rows[i].querySelector('em').textContent = fmt(r.value);
  });
  const a = data[0];
  const z = data[data.length - 1];
  out.textContent = `${fmt(a.value)} ${a.label.toLowerCase()} → ${fmt(z.value)} ${z.label.toLowerCase()} · ${pct(z.value / a.value)} overall`;
  buttons.forEach((b) => b.setAttribute('aria-pressed', b.dataset.set === name));
  if (active >= 0) place(active); // the tooltip follows its slab to the new edge
}

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

show('Web');

Sass source 226 lines

// A sales funnel drawn from JSON. JS writes one number per stage, --v: its value divided by the
// first stage's (0–1). Each stage is a slab: the front and the lid are full width and squashed with
// scaleX(--v) from the middle, the right side keeps its size and rides out to the new edge, so a
// new dataset is only a transition on transform, and each stage starts a little after the one
// above (--i). --t (0–1, the stage's place in the list) picks its colour. One shared tooltip
// glides from stage to stage.
$w: 132px; // the widest slab (the first stage)
$labels: 58px; // room on the right for the names
$h: 16px; // slab height
$d: 28px; // slab depth, front to back
$row: 26px; // a row: the slab and the gap under it
$pad: 5px; // the slab's top inside its row
$ease: cubic-bezier(0.3, 1.3, 0.5, 1); // past 1: a slab overshoots and settles

.d-funnel {
  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 part of it sits behind the plane of the flat boxes around it (this
  // one and the view), which would catch the pointer first: only the rows and the dock take it.
  pointer-events: none;

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

  &__chart {
    position: relative;
    width: $w + $labels;
    transform-style: preserve-3d;
    transform: rotateX(-22deg) rotateY(-18deg);
  }

  // violet → teal over the first half of --t, teal → pink over the second; mixed in oklch,
  // which goes round the hue wheel (via blue) instead of through grey as srgb would
  &__row,
  &__tip {
    --c: color-mix(
      in oklch,
      color-mix(in oklch, var(--accent), var(--accent-2) clamp(0%, var(--t, 0) * 200%, 100%)),
      var(--hot) clamp(0%, (var(--t, 0) * 2 - 1) * 100%, 100%)
    );
  }

  // One row per stage: the hit target. It is the chart's full width and never moves; the slab
  // inside it is what changes, so pointing never flickers at a shrinking edge.
  &__row {
    position: relative;
    height: $row;
    outline: none;
    transform-style: preserve-3d;
    pointer-events: auto;
    cursor: pointer;

    > * {
      position: absolute;
      pointer-events: none; // the moving parts never catch the pointer
    }

    // coloured glass: the colour mixed with transparent, a fine bright edge, a small inner glow
    i {
      // 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);
      transition:
        transform 0.8s $ease calc(var(--i) * 60ms),
        opacity 0.25s;

      // the pointed-at stage 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: full width, squashed from the middle. It reaches 1px above the lid: two edge lines
    // on the same spot (the lid's front edge, nearly side-on, and the front's top edge) step
    // over each other and read as a dashed line. Offset, the front's line stays whole.
    i:nth-child(1) {
      top: $pad - 1px;
      left: 0;
      width: $w;
      height: $h + 1px;
      transform: translateZ($d * 0.5) scaleX(var(--v));
    }

    // the lid, laid flat on top (its middle sits on the slab's top edge), squashed the same way
    i:nth-child(2) {
      top: $pad - $d * 0.5;
      left: 0;
      width: $w;
      height: $d;
      background: color-mix(in srgb, var(--c) 80%, transparent);
      transform: rotateX(90deg) scaleX(var(--v));
    }

    // the right side, darker, keeps its size and rides out to the slab's edge
    i:nth-child(3) {
      top: $pad;
      left: ($w - $d) * 0.5;
      width: $d;
      height: $h;
      background: color-mix(in srgb, color-mix(in srgb, var(--c) 55%, #05060c) 64%, transparent);
      transform: translateX(calc(var(--v) * #{$w * 0.5})) rotateY(90deg);
    }

    // the name and value, in the front plane, following the slab's right edge
    span {
      top: $pad + $h * 0.5 - 12px;
      left: $w * 0.5;
      color: var(--text);
      font: 700 9px/12px system-ui, sans-serif;
      white-space: nowrap;
      transform: translateX(calc(var(--v) * #{$w * 0.5} + 12px)) translateZ($d * 0.5);
      transition: transform 0.8s $ease calc(var(--i) * 60ms);
    }

    strong {
      display: block;
      font-weight: inherit;
    }

    em {
      display: block;
      color: var(--muted);
      font-style: normal;
      font-weight: 600;
      font-variant-numeric: tabular-nums;
    }
  }

  // while one stage is pointed at, the others step back
  &__chart:has(.is-active) .d-funnel__row:not(.is-active) i {
    opacity: 0.45;
  }

  // One tooltip for the chart: JS moves it to the pointed-at slab (--tx: its right edge, --ty:
  // its top, --t: its colour) and CSS glides it there, so it slides from stage to stage with
  // its text changing instead of blinking. It ends 16px past the slab's edge (clear of the
  // names, which sit further right on every row above) and floats 40px towards you, so the
  // nearer slabs never cover it. Flat: its thickness is a hard edge offset up and to the right,
  // the way the slabs' depth runs on screen.
  &__tip {
    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(calc(var(--tx, 0px) + 16px), calc(var(--ty, 0px) - 4px)) translate(-100%, -100%) translateZ(40px);
    transition:
      transform 0.35s cubic-bezier(0.2, 0.8, 0.2, 1),
      opacity 0.2s;

    &.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;
      cursor: pointer;

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