HTML 10 lines
<div class="waterfall">
<div class="scene">
<div class="fall3d"></div>
</div>
<output></output>
<div class="seg">
<button type="button" data-month="Aug">Aug</button>
<button type="button" data-month="Sep">Sep</button>
</div>
</div>
CSS 237 lines
.waterfall {
display: grid;
justify-items: center;
gap: 10px;
font-family: system-ui, sans-serif;
}
.scene {
perspective: 800px;
padding: 36px 40px 44px;
pointer-events: none; /* the chart is turned: only the steps take the pointer */
}
/* 7 steps × 18px + 6 gaps × 9px = 180px */
.fall3d {
position: relative;
width: 180px;
height: 100px; /* the top of the scale */
transform-style: preserve-3d;
transform: rotateX(-18deg) rotateY(-28deg);
}
/* the floor: a neon grid laid flat along the bottom */
.floor {
position: absolute;
left: -12px;
top: 76px;
width: 204px;
height: 48px;
border: 1px solid rgb(139 108 255 / 0.45);
border-radius: 6px;
background:
repeating-linear-gradient(90deg, rgb(139 108 255 / 0.22) 0 1px, transparent 1px 16px),
repeating-linear-gradient(rgb(139 108 255 / 0.22) 0 1px, transparent 1px 16px),
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 10px above the scale: a turned layer is clipped at its box, and the top
number pokes out above its line. */
.wall {
position: absolute;
top: -10px;
left: -12px;
width: 204px;
height: 110px;
transform: translateZ(-24px);
}
.wall b {
position: absolute;
right: 0;
bottom: calc(var(--t) * 100px);
left: 0;
height: 1px;
background: rgb(236 238 251 / 0.24);
}
.wall span {
position: absolute;
top: -6px;
left: 100%;
padding-left: 6px;
color: #949bc0;
font: 700 9px/12px 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) * 27px);
width: 18px;
height: 100%;
outline: none;
transform-style: preserve-3d;
transform: translateZ(9px);
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: 18px;
width: 9px;
border-top: 1px dashed rgb(236 238 251 / 0.6);
pointer-events: none;
transform: translateY(calc((1 - var(--e)) * 100px));
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: 18px;
height: 100%;
border: 1px solid color-mix(in srgb, var(--c) 85%, #fff);
background: color-mix(in srgb, var(--c) 40%, transparent);
box-shadow:
inset 0 0 10px color-mix(in srgb, var(--c) 40%, transparent),
0 0 10px 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 20px 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) * -100px)) 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) * -100px)) translateZ(-9px) rotateY(90deg) translateZ(9px) scaleY(var(--v));
}
/* the lid: an 18px square laid flat on the box's top */
.step i:nth-child(3) {
height: 18px;
background: color-mix(in srgb, var(--c) 66%, transparent);
transform-origin: center;
transform: translateY(calc((1 - var(--b) - var(--v)) * 100px)) translateZ(-9px) rotateX(90deg) translateZ(9px);
}
/* the label, in front of the step, tilted so the long ones do not collide */
.step span {
position: absolute;
top: calc(100% + 6px);
right: 50%;
color: #949bc0;
font: 700 8px/10px 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) and it glides
there. 40px towards you, because the steps on the right stand nearer; +18px 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: 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;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transform: translate(calc(var(--tx, 0px) + 18px), calc(var(--ty, 0px) - 24px)) translate(-50%, -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 138 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('.seg 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, 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)} → ${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)} → ${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 + 'px');
tipEl.style.setProperty('--ty', (1 - Math.max(r.from, r.to) / scaleTop) * H + 'px');
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');
Sass source 273 lines
// A cash-flow waterfall drawn from JSON. JS walks the steps keeping a running total and writes three
// fractions of the scale per step: --b the box's bottom, --v its height, --e the level the next
// step starts from. Each box is lifted by translateY(−--b × height) and squashed with scaleY(--v),
// so switching the month is only a transition on transform, every step a little after the last.
$w: 18px; // box width, and depth
$gap: 9px;
$h: 100px; // the top of the scale
$floor: 48px; // the floor's depth, front to back
$n: 7;
$span: $w * $n + $gap * ($n - 1);
$ease: cubic-bezier(0.3, 1.25, 0.5, 1); // past 1: a box overshoots and settles
$move: transform 0.8s $ease calc(var(--i) * 60ms);
.d-waterfall {
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 its left steps sit behind the plane of the flat boxes around it (this
// one and the view), which would catch the pointer first: only the steps and the dock take it.
pointer-events: none;
&__view {
display: grid;
place-items: center;
min-height: 0;
padding: 8px 0 14px; // room for the tooltip above and the tilted labels below
pointer-events: none;
}
&__chart {
position: relative;
width: $span;
height: $h;
transform-style: preserve-3d;
transform: rotateX(-18deg) rotateY(-28deg);
}
// the floor: a neon grid laid flat along the bottom of the chart
&__floor {
position: absolute;
left: -12px;
top: $h - $floor * 0.5;
width: $span + 24px;
height: $floor;
border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent);
border-radius: 6px;
background:
repeating-linear-gradient(90deg, color-mix(in srgb, var(--accent) 22%, transparent) 0 1px, transparent 1px 16px),
repeating-linear-gradient(color-mix(in srgb, var(--accent) 22%, transparent) 0 1px, transparent 1px 16px),
color-mix(in srgb, var(--accent) 9%, transparent);
transform: rotateX(90deg);
}
// the scale: lines at 0, half and the top, on a wall along the back edge of the floor, with the
// numbers at its right end, out past the last step. Its box starts 10px above the scale: a
// turned layer is clipped at its box, and the top number pokes out above its line.
&__wall {
position: absolute;
top: -10px;
left: -12px;
width: $span + 24px;
height: $h + 10px;
transform: translateZ($floor * -0.5);
b {
position: absolute;
right: 0;
bottom: calc(var(--t) * #{$h});
left: 0;
height: 1px;
background: color-mix(in srgb, var(--text) 24%, transparent);
}
span {
position: absolute;
top: -6px;
left: 100%;
padding-left: 6px;
color: var(--muted);
font: 700 9px/12px system-ui, sans-serif;
font-variant-numeric: tabular-nums;
}
}
// A step is a full-height column that never moves: the hit target, so hovering the empty space
// above or below a floating box counts too. It stands in the plane of the boxes' fronts (half a
// box forward), so it covers exactly the front you see; the faces themselves ignore the pointer,
// because they move. Totals violet, gains teal, losses pink.
&__step {
--c: var(--accent);
position: absolute;
top: 0;
left: calc(var(--i) * #{$w + $gap});
width: $w;
height: 100%;
outline: none;
transform-style: preserve-3d;
transform: translateZ($w * 0.5);
pointer-events: auto;
cursor: pointer;
// the connector: a dashed line at the running total, from this box's front edge to the next
&::after {
content: '';
position: absolute;
top: 0;
left: $w;
width: $gap;
border-top: 1px dashed color-mix(in srgb, var(--text) 60%, transparent);
pointer-events: none;
transform: translateY(calc((1 - var(--e)) * #{$h}));
transition: $move;
}
&:last-of-type::after {
content: none;
}
// see-through glass faces with a bright edge: the neon look
i {
position: absolute;
top: 0;
left: 0;
width: $w;
height: 100%;
border: 1px solid color-mix(in srgb, var(--c) 85%, #fff);
background: color-mix(in srgb, var(--c) 40%, transparent);
box-shadow:
inset 0 0 10px color-mix(in srgb, var(--c) 40%, transparent),
0 0 10px color-mix(in srgb, var(--c) 22%, transparent);
pointer-events: none;
transform-origin: bottom center;
transition: $move;
// the pointed-at step 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: lifted to the box's bottom FIRST, then squashed to its height (from the bottom);
// the other way round the lift would be squashed too
i:nth-child(1) {
transform: translateY(calc(var(--b) * -#{$h})) scaleY(var(--v));
}
// right side, darker: back to the box's middle, turned, out to its right edge
i:nth-child(2) {
background: color-mix(in srgb, color-mix(in srgb, var(--c) 55%, #05060c) 48%, transparent);
transform: translateY(calc(var(--b) * -#{$h})) translateZ(-$w * 0.5) rotateY(90deg) translateZ($w * 0.5)
scaleY(var(--v));
}
// the lid: a $w square laid flat on the box's top
i:nth-child(3) {
height: $w;
background: color-mix(in srgb, var(--c) 66%, transparent);
transform-origin: center;
transform: translateY(calc((1 - var(--b) - var(--v)) * #{$h})) translateZ(-$w * 0.5) rotateX(90deg)
translateZ($w * 0.5);
}
// the label, standing in front of the step and tilted so the long ones do not collide
span {
position: absolute;
top: calc(100% + 6px);
right: 50%;
color: var(--muted);
font: 700 8px/10px system-ui, sans-serif;
white-space: nowrap;
pointer-events: none;
transform-origin: 100% 0;
transform: rotate(-38deg);
}
}
&__step,
&__tip {
&.is-gain {
--c: var(--accent-2);
}
&.is-loss {
--c: var(--hot);
}
}
// One tooltip for the chart: JS moves it to the top of the pointed-at box (--tx, --ty) and CSS
// glides it there, so it slides from step to step with its text changing instead of blinking.
// It lives in the chart's turned plane, 40px towards you (each step to the right stands nearer
// than the one before); +18px undoes the sideways drift that lift gets from the 28° turn. Kept
// flat: its thickness is a hard shadow up and to the right, the way the boxes' depth runs.
&__tip {
--c: var(--accent);
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) + 18px), calc(var(--ty, 0px) - 24px)) translate(-50%, -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;
}
}
}
}