HTML 14 lines
<div class="waterfall">
<div class="view">
<div class="scene">
<div class="fall3d"></div>
</div>
</div>
<div class="controls">
<output class="caption"></output>
<div class="row">
<button type="button" data-month="Aug">Aug</button>
<button type="button" data-month="Sep">Sep</button>
</div>
</div>
</div>
CSS 301 lines
.waterfall {
/* 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.29vmin;
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 scale's numbers stand off the right end: the room on the right balances them, and the
chart's shift left (on .fall3d) centres the solid drawing in the model box. The room below is
more than above, so the chart sits high in the box: the tooltip's room over the tallest step
is empty at rest, and with the chart in the middle the drawn stack sat 5vmin low */
padding: calc(21 * var(--u)) calc(60 * var(--u)) calc(59 * var(--u)) calc(40 * var(--u));
pointer-events: none; /* the chart is turned: only the steps take the pointer */
}
/* 7 steps × 18 + 6 gaps × 9 = 180 units */
.fall3d {
position: relative;
width: calc(180 * var(--u));
height: calc(100 * var(--u)); /* the top of the scale */
transform-style: preserve-3d;
/* turned, the solid steps and the scale's numbers lean right of the middle; the faint floor
reaches further left than they do. Shifted left, the solid chart is centred */
translate: calc(-7 * var(--u)) 0;
transform: rotateX(-18deg) rotateY(-28deg);
}
/* the floor: a neon grid laid flat along the bottom */
.floor {
box-sizing: content-box; /* its size is its inside, its edge outside it, on any page */
position: absolute;
left: calc(-12 * var(--u));
top: calc(76 * var(--u));
width: calc(204 * var(--u));
height: calc(48 * var(--u));
border: calc(1 * var(--u)) solid rgb(139 108 255 / 0.45);
border-radius: calc(6 * var(--u));
background:
repeating-linear-gradient(90deg, rgb(139 108 255 / 0.22) 0 calc(1 * var(--u)), transparent calc(1 * var(--u)) calc(16 * var(--u))),
repeating-linear-gradient(rgb(139 108 255 / 0.22) 0 calc(1 * var(--u)), transparent calc(1 * var(--u)) calc(16 * var(--u))),
rgb(139 108 255 / 0.09);
transform: rotateX(90deg);
}
/* the scale: lines at 0, half and the top, along the back of the floor, numbers at the right.
Its box starts 10 units above the scale: a turned layer is clipped at its box, and the top
number pokes out above its line. */
.wall {
position: absolute;
top: calc(-10 * var(--u));
left: calc(-12 * var(--u));
width: calc(204 * var(--u));
height: calc(110 * var(--u));
transform: translateZ(calc(-24 * var(--u)));
}
.wall b {
position: absolute;
right: 0;
bottom: calc(var(--t) * calc(100 * var(--u)));
left: 0;
height: calc(1 * var(--u));
background: rgb(236 238 251 / 0.24);
}
.wall span {
position: absolute;
top: calc(-6 * var(--u));
left: 100%;
padding-left: calc(6 * var(--u));
color: inherit; /* the stage's own ink, softened with opacity: a fixed pale lavender was 2.3:1 on the light stage */
opacity: 0.72;
font: 700 calc(12 * var(--u))/calc(14 * var(--u)) Inter, system-ui, sans-serif;
}
/* A step is a still, full-height column: the hit target. It stands in the plane of the box
fronts (half a box forward), so it covers exactly the front you see. */
.step {
--b: 0; /* JS writes these three: the box's bottom, its height, where the next step starts */
--v: 0;
--e: 0;
--c: #8b6cff; /* totals violet */
position: absolute;
top: 0;
left: calc(var(--i) * calc(27 * var(--u)));
width: calc(18 * var(--u));
height: 100%;
outline: none;
transform-style: preserve-3d;
transform: translateZ(calc(9 * var(--u)));
pointer-events: auto;
cursor: pointer;
}
.step.is-gain,
.tip.is-gain {
--c: #2ee6d6;
}
.step.is-loss,
.tip.is-loss {
--c: #ff4d9d;
}
/* the connector: a dashed line at the running total, over to the next step */
.step::after {
content: '';
position: absolute;
top: 0;
left: calc(18 * var(--u));
width: calc(9 * var(--u));
border-top: calc(1 * var(--u)) dashed rgb(236 238 251 / 0.6);
pointer-events: none;
transform: translateY(calc((1 - var(--e)) * calc(100 * var(--u))));
transition: transform 0.8s cubic-bezier(0.3, 1.25, 0.5, 1) calc(var(--i) * 60ms);
}
.step:last-of-type::after {
content: none;
}
/* see-through glass faces with a bright edge: the neon look */
.step i {
position: absolute;
top: 0;
left: 0;
box-sizing: border-box;
width: calc(18 * var(--u));
height: 100%;
border: calc(1 * var(--u)) solid color-mix(in srgb, var(--c) 85%, #fff);
background: color-mix(in srgb, var(--c) 40%, transparent);
box-shadow:
inset 0 0 calc(10 * var(--u)) color-mix(in srgb, var(--c) 40%, transparent),
0 0 calc(10 * var(--u)) color-mix(in srgb, var(--c) 22%, transparent);
pointer-events: none; /* the faces move: only the still column is hovered */
transform-origin: bottom center;
/* past 1: overshoot and settle; each step 60ms after the one before */
transition: transform 0.8s cubic-bezier(0.3, 1.25, 0.5, 1) calc(var(--i) * 60ms);
}
/* the pointed-at step fills in and glows (.is-active, set with the tooltip) */
.step 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;
}
.step.is-active i::after {
opacity: 1;
}
/* front: lifted to the box's bottom FIRST, then squashed to its height */
.step i:nth-child(1) {
transform: translateY(calc(var(--b) * calc(-100 * var(--u)))) scaleY(var(--v));
}
/* right side, darker: back to the box's middle, turned, out to its right edge */
.step i:nth-child(2) {
background: color-mix(in srgb, color-mix(in srgb, var(--c) 55%, #05060c) 48%, transparent);
transform: translateY(calc(var(--b) * calc(-100 * var(--u)))) translateZ(calc(-9 * var(--u))) rotateY(90deg) translateZ(calc(9 * var(--u))) scaleY(var(--v));
}
/* the lid: an 18-unit square laid flat on the box's top */
.step i:nth-child(3) {
height: calc(18 * var(--u));
background: color-mix(in srgb, var(--c) 66%, transparent);
transform-origin: center;
transform: translateY(calc((1 - var(--b) - var(--v)) * calc(100 * var(--u)))) translateZ(calc(-9 * var(--u))) rotateX(90deg) translateZ(calc(9 * var(--u)));
}
/* the label, in front of the step, tilted so the long ones do not collide */
.step span {
position: absolute;
top: calc(100% + calc(6 * var(--u)));
right: 50%;
color: inherit; /* the stage's ink, softened: see .wall span */
opacity: 0.72;
font: 700 calc(13 * var(--u))/calc(15 * var(--u)) Inter, system-ui, sans-serif;
white-space: nowrap;
transform-origin: 100% 0;
transform: rotate(-38deg);
}
/* ONE tooltip for the chart: JS sets --tx / --ty (the top of the pointed-at box, as plain
numbers in the chart's own units that CSS multiplies by --u) and it glides there, 6 units over
the box: close, so the room it needs above the tallest step is small and the chart at rest can
sit centred with its caption. --ax is the share of its width that hangs left of the step (JS: from
0.2 at the first step to 0.8 at the last), so a long tip never hangs off a side. 40 units
towards you, because the steps on the right stand nearer; +18 undoes the
sideways drift that lift gets from the 28deg turn. Kept flat: its thickness is a hard shadow
up and to the right, the way the boxes' depth runs on screen. */
.tip {
--c: #8b6cff;
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(12 * var(--u))/calc(14 * var(--u)) Inter, system-ui, sans-serif;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transform: translate(calc((var(--tx, 0) + 18) * var(--u)), calc((var(--ty, 0) - 6) * var(--u))) translate(calc(var(--ax, 0.5) * -100%), -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 141 lines
// The data, as an API would send it back. "End" has no value: it is worked out below.
const CASHFLOW = {
"prefix": "$",
"suffix": "k",
"months": {
"Aug": [
{ "label": "Start", "value": 120, "kind": "total" },
{ "label": "Sales", "value": 85 },
{ "label": "Refunds", "value": -18 },
{ "label": "Salaries", "value": -64 },
{ "label": "Ads", "value": -22 },
{ "label": "Grants", "value": 30 },
{ "label": "End", "kind": "total" }
],
"Sep": [
{ "label": "Start", "value": 131, "kind": "total" },
{ "label": "Sales", "value": 92 },
{ "label": "Refunds", "value": -25 },
{ "label": "Salaries", "value": -64 },
{ "label": "Ads", "value": -41 },
{ "label": "Grants", "value": 12 },
{ "label": "End", "kind": "total" }
]
}
};
const chart = document.querySelector('.fall3d');
const out = document.querySelector('.waterfall output');
const buttons = document.querySelectorAll('.controls .row button');
const TICKS = [0, 0.5, 1]; // the scale's lines, as fractions of its top
const W = 18, PITCH = 27, H = 100; // a step's width, its pitch and the scale's height, in the chart's own units (as in the CSS)
// money the way dashboards write it: sign in front, a real minus (−), the k glued on
const money = (v, sign) =>
(v < 0 ? '−' : sign && v > 0 ? '+' : '') + CASHFLOW.prefix + Math.abs(v) + CASHFLOW.suffix;
// Build the chart once: the floor, the scale, a column with three faces per step, one tooltip
const first = Object.values(CASHFLOW.months)[0];
chart.innerHTML =
'<div class="floor"></div>' +
'<div class="wall">' + TICKS.map((t) => `<b style="--t:${t}"><span></span></b>`).join('') + '</div>' +
first.map((_, i) => `<div class="step" style="--i:${i}" tabindex="0"><i></i><i></i><i></i><span></span></div>`).join('') +
'<b class="tip" aria-hidden="true"></b>';
const steps = [...chart.querySelectorAll('.step')];
const ticks = chart.querySelectorAll('.wall span');
const tipEl = chart.querySelector('.tip');
let rows = [];
let scaleTop = 1; // (not "top": that is window.top in a plain script)
let active = -1;
let hideTimer = 0;
// Show one month: walk the steps with a running total, then give each step three fractions of the
// scale. CSS turns them into a lift, a height and a connector, and animates the change.
function show(month) {
let run = 0;
rows = CASHFLOW.months[month].map((s) => {
if (s.kind === 'total') {
if (s.value !== undefined) run = s.value; // a total with no value is the running total
return { label: s.label, kind: 'total', value: run, from: 0, to: run };
}
const from = run;
run += s.value;
return { label: s.label, kind: s.value < 0 ? 'loss' : 'gain', value: s.value, from, to: run };
});
scaleTop = Math.ceil(Math.max(...rows.map((r) => Math.max(r.from, r.to))) / 20) * 20; // a tidy top
rows.forEach((r, i) => {
const s = steps[i];
s.style.setProperty('--b', Math.min(r.from, r.to) / scaleTop); // the box's bottom
s.style.setProperty('--v', Math.abs(r.to - r.from) / scaleTop); // its height
s.style.setProperty('--e', r.to / scaleTop); // where the next step starts
s.classList.remove('is-total', 'is-gain', 'is-loss');
s.classList.add('is-' + r.kind);
// text from the data goes in as text, never as HTML: data from an API is not trusted markup
s.querySelector('span').textContent = r.label;
s.setAttribute('aria-label', tipText(r));
});
ticks.forEach((t, k) => (t.textContent = Math.round(TICKS[k] * scaleTop)));
const start = rows[0].to;
const end = rows[rows.length - 1].to;
const pct = Math.round(((end - start) / start) * 100);
out.textContent = `${money(start)} to ${money(end)} · ${pct < 0 ? '−' : pct > 0 ? '+' : ''}${Math.abs(pct)}%`;
buttons.forEach((b) => b.setAttribute('aria-pressed', b.dataset.month === month));
if (active >= 0) place(active); // the tooltip follows its step to the new place
}
function tipText(r) {
return r.kind === 'total' ? `${r.label} · ${money(r.to)}` : `${r.label} · ${money(r.value, true)}, to ${money(r.to)}`;
}
// One tooltip: JS gives it the top of the step's box, CSS glides it there
function place(i) {
clearTimeout(hideTimer);
const r = rows[i];
const wasOn = tipEl.classList.contains('is-on');
if (!wasOn) tipEl.style.transition = 'none'; // from hidden: appear in place, don't fly in
tipEl.textContent = tipText(r);
tipEl.style.setProperty('--tx', i * PITCH + W / 2); // plain numbers: CSS multiplies them by --u
// which share of its own width hangs left of its step: the first steps' tips open to the right
// and the last ones' to the left, so a long tip never hangs off the chart's side
tipEl.style.setProperty('--ax', (0.2 + (0.6 * i) / Math.max(1, rows.length - 1)).toFixed(3));
tipEl.style.setProperty('--ty', (1 - Math.max(r.from, r.to) / scaleTop) * H);
tipEl.className = 'tip is-on is-' + r.kind;
if (!wasOn) {
void tipEl.offsetWidth; // apply the new place before the transition comes back
tipEl.style.transition = '';
}
steps.forEach((s, k) => s.classList.toggle('is-active', k === i));
active = i;
}
// a short grace period, so crossing the gap between two steps does not flicker it
function hide() {
clearTimeout(hideTimer);
hideTimer = setTimeout(() => {
tipEl.classList.remove('is-on');
steps.forEach((s) => s.classList.remove('is-active'));
active = -1;
}, 150);
}
const over = (e) => {
const step = e.target.closest('.step');
if (step) place(steps.indexOf(step));
};
const leave = (e) => {
if (!e.relatedTarget?.closest?.('.step')) 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 step shows its tooltip, a tap elsewhere hides it
document.addEventListener('pointerup', (e) => {
if (e.pointerType === 'mouse') return;
const step = e.target.closest('.step');
if (step) place(steps.indexOf(step));
else hide();
});
buttons.forEach((b) => b.addEventListener('click', () => show(b.dataset.month)));
show('Aug');