Ledger — waterfall bridge
Variant: Waterfall bridge
Opening balance to closing balance, posted one transaction at a time. Each delta hangs from the level the previous one left and grows from that edge — credits up from their floor, debits down from their ceiling — because a withdrawal that rose off the axis would read as a bar, not a loss. Each 360ms posting is two phases on one registered playhead — a linear 90ms in which a dashed link crosses the gap, then 270ms of ease-out for the bar — so the eye is carried to the level before anything grows from it and the curve shapes the growth rather than being spent on the flick. A 60ms beat separates the postings: a ledger is entered line by line, and a continuous sweep would blur six transactions into one gesture. The opening level is ruled across the plot only as the close posts, the comparison drawn at the moment it can be made, and the net waits for it: a +26 grant, spent exactly twice over. Only the totals and the credit carry figures, each arriving with its bar. Reach for it for cash flow, budget bridges, and any variance explanation where the order of the deltas is the story.
The source
A complete HTML document. Paste it into an empty .html file and it plays — no stylesheet, script, font, or image to fetch.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Ledger — waterfall bridge</title>
<style>
/* The playhead, in postings. Every bar, link and label reads it against
its own index, so the whole bridge is one clock and nothing can post
out of order. */
@property --_p {
syntax: '<number>';
inherits: true;
initial-value: 0;
}
:root {
/* Neutrals — fixed, never remixed. */
--ivory: #faf9f5;
--paper: #ffffff;
--oat: #e3dacc;
--slate: #141413;
/* Knobs — the remix API. A knob exists iff declared here and used below. */
--hue: 38.8;
--lift: 0;
--chroma: 1;
--round: 1;
--speed: 1;
/* Accents — derived from the knobs with the family's offsets baked in,
so one hue roll moves them together. Defaults reproduce the original hex. */
--clay: oklch(calc(0.6724 + var(--lift)) calc(0.1308 * var(--chroma)) var(--hue));
--clay-d: oklch(calc(0.5797 + var(--lift)) calc(0.127 * var(--chroma)) var(--hue));
--olive: oklch(
calc(0.6118 + var(--lift)) calc(0.0713 * var(--chroma)) calc(var(--hue) + 88.3)
);
--rust: oklch(
calc(0.5408 + var(--lift)) calc(0.1357 * var(--chroma)) calc(var(--hue) - 10.3)
);
/* Clock — 6s: six postings of 360ms with a 60ms beat between them,
then a long hold, so the bridge is read rather than watched. */
--cycle: 6s;
--t: calc(var(--cycle) / var(--speed));
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
--_muted: color-mix(in srgb, var(--slate) 52%, transparent);
--_total: color-mix(in srgb, var(--slate) 74%, var(--paper));
--_axis: color-mix(in srgb, var(--slate) 26%, transparent);
--_link: color-mix(in srgb, var(--slate) 38%, transparent);
--_gap: clamp(6px, 2.2vw, 10px);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
min-height: 100vh;
display: grid;
place-items: center;
background: var(--ivory);
font-family:
system-ui,
-apple-system,
'Segoe UI',
Roboto,
sans-serif;
color: var(--slate);
}
.chart {
width: min(300px, 86vw);
display: grid;
gap: 10px;
animation:
post var(--t) infinite,
frame var(--t) infinite;
}
.head {
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: clamp(11px, 3.2vw, 12px);
}
.label {
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--_muted);
}
/* The verdict waits for the closing bar: the net is the sum, and stating
it before the last posting lands is answering before the working. */
.net {
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--rust);
opacity: clamp(0, (var(--_p) - 5.55) * 4, 1);
}
.plot {
position: relative;
height: clamp(104px, 30vw, 128px);
display: grid;
grid-template-columns: repeat(6, 1fr);
column-gap: var(--_gap);
border-bottom: 1px solid var(--_axis);
}
.col {
position: relative;
}
/* Each delta hangs from the level the previous one left, so it grows
from that edge: credits up from their floor, debits down from their
ceiling. The origin is the argument — a debit that grew upward from
the axis would be a bar, not a withdrawal. */
.bar {
position: absolute;
left: 0;
right: 0;
bottom: var(--_lo);
height: var(--_h);
border-radius: calc(2px * var(--round));
transform-origin: center var(--_from);
--_s: clamp(0, (var(--_p) - var(--_i) - 0.25) / 0.75, 1);
transform: scaleY(calc(0.04 + 0.96 * var(--_s)));
opacity: clamp(0, (var(--_p) - var(--_i) - 0.25) * 20, 1);
}
.total {
background: var(--_total);
border-radius: calc(2px * var(--round)) calc(2px * var(--round)) 0 0;
}
.credit {
background: var(--olive);
}
.debit {
background: var(--clay);
}
/* The link draws across the gap first and the next bar starts from where
it lands — a linear 90ms, the first quarter of the posting, so the eye
is carried to the level before anything grows from it. */
.link {
position: absolute;
right: 100%;
width: var(--_gap);
bottom: var(--_lo);
border-top: 1px dashed var(--_link);
transform-origin: left center;
transform: scaleX(clamp(0, (var(--_p) - var(--_i)) / 0.25, 1));
}
/* The opening level, ruled across the whole plot as the close posts:
the comparison the chart exists to make, drawn at the moment it can
be made. */
.open {
position: absolute;
left: 0;
right: 0;
bottom: 62%;
border-top: 1px dashed var(--_link);
transform-origin: left center;
transform: scaleX(clamp(0, (var(--_p) - 5) / 0.6, 1));
opacity: 0.8;
}
/* Figures on the two totals and the one credit — enough to read the
story off the chart — each arriving with its own bar. */
.val {
position: absolute;
left: 0;
right: 0;
bottom: calc(var(--_lo) + var(--_h) + 3px);
font-size: 11px;
line-height: 1;
font-weight: 600;
font-variant-numeric: tabular-nums;
text-align: center;
color: var(--_muted);
opacity: clamp(0, (var(--_p) - var(--_i) - 0.7) / 0.3, 1);
}
.names {
display: grid;
grid-template-columns: repeat(6, 1fr);
column-gap: var(--_gap);
font-size: clamp(11px, 3vw, 12px);
color: var(--_muted);
text-align: center;
}
/* Six postings separated by a 60ms beat — a ledger is entered line by
line, and a continuous sweep would blur six transactions into one
gesture. Each posting is two phases on the one playhead: a linear 90ms
for the link, then 270ms eased out for the bar, so the curve shapes the
growth instead of being spent on the flick. The reset lands in the
dark window. */
@keyframes post {
0%,
5% {
--_p: 0;
animation-timing-function: linear;
}
6.5% {
--_p: 0.25;
animation-timing-function: var(--ease-out);
}
11%,
12% {
--_p: 1;
animation-timing-function: linear;
}
13.5% {
--_p: 1.25;
animation-timing-function: var(--ease-out);
}
18%,
19% {
--_p: 2;
animation-timing-function: linear;
}
20.5% {
--_p: 2.25;
animation-timing-function: var(--ease-out);
}
25%,
26% {
--_p: 3;
animation-timing-function: linear;
}
27.5% {
--_p: 3.25;
animation-timing-function: var(--ease-out);
}
32%,
33% {
--_p: 4;
animation-timing-function: linear;
}
34.5% {
--_p: 4.25;
animation-timing-function: var(--ease-out);
}
39%,
40% {
--_p: 5;
animation-timing-function: linear;
}
41.5% {
--_p: 5.25;
animation-timing-function: var(--ease-out);
}
46%,
93% {
--_p: 6;
}
94%,
100% {
--_p: 0;
}
}
@keyframes frame {
0% {
opacity: 0;
animation-timing-function: var(--ease-out);
}
4%,
88% {
opacity: 1;
animation-timing-function: var(--ease-out);
}
92%,
100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
/* The postings stay — each bar's length and direction is the value —
but the loop stretches to 10.8s, so each lands at a little over half
the pace and the reset still falls inside the dark window. */
.chart {
animation-duration: calc(var(--t) * 1.8);
}
}
</style>
</head>
<body>
<div
class="chart"
role="img"
aria-label="A waterfall chart posting line by line: an opening balance of 62, a grant of plus 26, then payroll minus 32, a trip minus 13 and tools minus 7, closing at 36 — below the opening level, net minus 26"
>
<div class="head">
<span class="label">Cash · March</span>
<span class="net">Net −26k</span>
</div>
<div class="plot">
<div class="open"></div>
<div class="col">
<div class="bar total" style="--_i: 0; --_lo: 0%; --_h: 62%; --_from: bottom"></div>
<span class="val" style="--_i: 0; --_lo: 0%; --_h: 62%">62</span>
</div>
<div class="col">
<div class="link" style="--_i: 1; --_lo: 62%"></div>
<div class="bar credit" style="--_i: 1; --_lo: 62%; --_h: 26%; --_from: bottom"></div>
<span class="val" style="--_i: 1; --_lo: 62%; --_h: 26%">+26</span>
</div>
<div class="col">
<div class="link" style="--_i: 2; --_lo: 88%"></div>
<div class="bar debit" style="--_i: 2; --_lo: 56%; --_h: 32%; --_from: top"></div>
</div>
<div class="col">
<div class="link" style="--_i: 3; --_lo: 56%"></div>
<div class="bar debit" style="--_i: 3; --_lo: 43%; --_h: 13%; --_from: top"></div>
</div>
<div class="col">
<div class="link" style="--_i: 4; --_lo: 43%"></div>
<div class="bar debit" style="--_i: 4; --_lo: 36%; --_h: 7%; --_from: top"></div>
</div>
<div class="col">
<div class="link" style="--_i: 5; --_lo: 36%"></div>
<div class="bar total" style="--_i: 5; --_lo: 0%; --_h: 36%; --_from: bottom"></div>
<span class="val" style="--_i: 5; --_lo: 0%; --_h: 36%">36</span>
</div>
</div>
<div class="names">
<span>Open</span><span>Grant</span><span>Pay</span><span>Trip</span><span>Tools</span
><span>Close</span>
</div>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.