HTML 14 lines
<div class="funnel">
<div class="view">
<div class="scene">
<div class="funnel3d"></div>
</div>
</div>
<div class="controls">
<output class="caption"></output>
<div class="row">
<button type="button" data-set="Web">Web</button>
<button type="button" data-set="App">App</button>
</div>
</div>
</div>
CSS 260 lines
.funnel {
/* one base unit: every length in the chart is a multiple of it, so it is the same share of a
card, the editor, a full screen and a recording canvas. The control zone under it is in
plain vmin, because it is the same object in every model. */
--u: 0.38vmin;
display: grid;
justify-items: center;
gap: 4vmin; /* the band's gap between the model and the control zone */
font-family: Inter, system-ui, sans-serif;
}
/* the model box: the same height in every model that has controls */
.view {
display: grid;
place-items: center;
height: 50vmin;
}
.scene {
perspective: calc(800 * var(--u));
/* the last stage's label hangs below the chart: more room under it than over it keeps the
drawing centred in the model box and clear of the caption */
padding: calc(24 * var(--u)) calc(20 * var(--u)) calc(44 * var(--u));
pointer-events: none; /* the chart is turned: only the rows take the pointer */
}
/* 132 units for the widest slab, 58 on its right for the labels */
.funnel3d {
position: relative;
width: calc(190 * var(--u));
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: calc(26 * var(--u));
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 {
box-sizing: content-box; /* its size is its inside, its edge outside it, on any page */
/* under one unit shows as a hairline on sharp screens */
border: calc(0.6 * var(--u)) 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 calc(8 * var(--u)) color-mix(in srgb, var(--c) 30%, transparent),
0 0 calc(10 * var(--u)) 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 calc(20 * var(--u)) color-mix(in srgb, var(--c) 60%, transparent);
opacity: 0;
transition: opacity 0.25s;
}
.stage.is-active i::after {
opacity: 1;
}
/* but not round the side: that face stands almost edge-on and its glow reaches out towards you
far enough to cut through the tooltip floating over it, leaving a slash across its text */
.stage i:nth-child(3)::after {
box-shadow: none;
}
/* 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 one unit 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: calc(4 * var(--u));
left: 0;
width: calc(132 * var(--u));
height: calc(17 * var(--u));
transform: translateZ(calc(14 * var(--u))) scaleX(var(--v));
}
/* the lid: 28 units deep, laid flat on top, squashed the same way */
.stage i:nth-child(2) {
top: calc(-9 * var(--u));
left: 0;
width: calc(132 * var(--u));
height: calc(28 * var(--u));
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: calc(5 * var(--u));
left: calc(52 * var(--u));
width: calc(28 * var(--u));
height: calc(16 * var(--u));
background: color-mix(in srgb, color-mix(in srgb, var(--c) 55%, #05060c) 64%, transparent);
transform: translateX(calc(var(--v) * calc(66 * var(--u)))) rotateY(90deg);
}
/* the name and value, following the slab's right edge */
.stage span {
top: calc(1 * var(--u));
left: calc(66 * var(--u));
/* no colour of its own: the names are written on the stage, so they take the stage's ink */
font: 700 calc(12 * var(--u))/calc(13 * var(--u)) Inter, system-ui, sans-serif;
white-space: nowrap;
transform: translateX(calc(var(--v) * calc(66 * var(--u)) + calc(12 * var(--u)))) translateZ(calc(14 * var(--u)));
transition:
transform 0.8s cubic-bezier(0.3, 1.3, 0.5, 1) calc(var(--i) * 60ms),
opacity 0.25s;
}
/* while the tooltip is up it says the pointed-at stage's name and value itself, and it floats
over the names of the stages around it: the names step aside so no two texts overlap */
.funnel3d:has(.is-active) .stage span {
opacity: 0;
}
.stage strong {
display: block;
font-weight: inherit;
}
.stage em {
display: block;
/* the stage's ink, softened (by opacity: a colour mixed from currentColor here kept the old
stage's ink when the stage changed theme) */
opacity: 0.7;
font-style: normal;
font-weight: 600;
}
/* One tooltip for the chart: JS moves it to the pointed-at slab (--tx = its middle,
--ty = its top, as plain numbers in the chart's own units that CSS multiplies by --u) and the
transition glides it there. It floats 40 units 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: calc(3 * var(--u)) calc(7 * var(--u));
border: calc(1 * var(--u)) solid var(--c);
border-radius: calc(6 * var(--u));
background: #141830;
box-shadow:
calc(3 * var(--u)) calc(-3 * var(--u)) 0 color-mix(in srgb, var(--c) 55%, #05060c),
0 0 calc(14 * var(--u)) color-mix(in srgb, var(--c) 40%, transparent);
color: #eceefb;
font: 700 calc(10 * var(--u))/calc(13 * var(--u)) Inter, system-ui, sans-serif;
font-variant-numeric: tabular-nums;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transform: translate(calc(var(--tx, 0) * var(--u)), calc((var(--ty, 0) - 4) * var(--u))) translate(-50%, -100%) translateZ(calc(40 * var(--u)));
transition:
transform 0.35s cubic-bezier(0.2, 0.8, 0.2, 1),
opacity 0.2s;
}
.tip.is-on {
opacity: 1;
}
/* the control zone: the same object, at the same size, in every model that has one — so it is
written in plain vmin and not in the chart's own unit. The caption is on its own line above
the row, and its line box never changes height, so a new summary cannot move the chart. */
.controls {
display: grid;
justify-items: center;
gap: 2vmin;
text-align: center;
}
.controls .caption {
font: 500 4.5vmin/1.2 Inter, system-ui, sans-serif;
font-variant-numeric: tabular-nums;
white-space: nowrap;
opacity: 0.7;
}
.controls .row {
display: flex;
gap: 2vmin;
}
.controls button,
.controls label {
height: 8vmin;
min-width: 8vmin;
padding: 0 3vmin;
box-sizing: border-box; /* a label is content-box, so min-width would add to its padding */
border: 0;
border-radius: 999px;
/* see-through, so the pill sits on the stage, and its word is the stage's own ink at full
strength: no colour of the model's, which would vanish on one of the two stages */
background: rgb(140 150 220 / 0.2);
color: inherit;
font: 600 4vmin Inter, system-ui, sans-serif;
cursor: pointer;
transition: background-color 0.35s;
}
.controls button:hover,
.controls label:hover {
background-color: rgb(140 150 220 / 0.34);
}
.controls :focus-visible {
outline: 0.6vmin solid #6a45f5;
outline-offset: 0.6vmin;
}
/* the selected pill: the brand gradient, deep enough that white text on it passes */
.controls [aria-pressed='true'] {
background: linear-gradient(135deg, #6a45f5, #d1206f);
color: #fff;
}
/* The pill already chosen answers the pointer too.
Hover sets background-color; this rule sets the background SHORTHAND, so its gradient image
was painted straight over that colour. The one control that was already selected was the one
control that did nothing when you pointed at it. check-motion named it on six of these charts
('hover button#1 "24h": changes nothing on screen').
Darker, not lighter: the gradient above is as pale as it can be with white text still passing. */
.controls [aria-pressed='true']:hover {
background-image: linear-gradient(135deg, #5730dd, #b31859);
}
JS 106 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('.controls .row button');
const W = 132, ROW = 26, PAD = 5; // the slab width, row height and slab top, in the chart's own units (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 middle and top, CSS glides it there.
function place(i) {
clearTimeout(hideTimer);
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', W / 2); // plain numbers: CSS multiplies them by --u
tipEl.style.setProperty('--ty', i * ROW + PAD);
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()} to ${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');