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