CSS + JS Data & tools #controls #hover #data #chart #json #finance

3D waterfall chart from JSON in CSS + JavaScript

A month of cash flow as floating neon glass boxes: the starting balance, each gain stepping up and each loss stepping down, and the ending balance JS works out. Switch the month and every box glides to its new place. JS only turns the data into three fractions per step.

  • 237 lines of CSS
  • 10 lines of HTML
  • 138 lines of JS
  • No dependencies
  • MIT licensed
Video 0.3 MB GitHub

How it works

  1. The data is plain JSON: a starting balance, the gains and losses, and an ending balance with no value. JS walks the steps keeping a running total, so the end is always the sum and can never disagree with the steps.
  2. Every step runs from one level to another (a total from the floor). JS writes three fractions of the scale: --b the box's bottom, --v its height, --e the level the next step starts from. CSS does the drawing.
  3. A box is three see-through faces the full height of the chart, moved with translateY(calc(var(--b) * -100px)) and then squashed with scaleY(var(--v)) from the bottom: translate first, or the lift would be squashed too. Only transform changes, so a new month is a smooth glide, each step 60ms after the last.
  4. The dashed connector is the step's ::after, riding at --e: at the top of a gain or a total, at the bottom of a loss. That is what makes it read as a waterfall.
  5. The chart is turned, so the flat boxes around it would catch the pointer first: they get pointer-events: none, and only each step's still, full-height column takes it. It stands in the plane of the box fronts and never moves, so hovering it never flickers mid-glide.
  6. There is one tooltip. JS moves it to the pointed-at step with --tx / --ty and a transition glides it there while its text changes (put in with textContent, never as HTML). It floats 40px towards you, or the nearer steps on the right would cover it, and a 150ms grace timer keeps it up while you cross a gap.

Key techniques

  • JSON → running total → --b, --v, --e per step
  • translateY(−--b × height) then scaleY(--v), transitioned
  • connector line: ::after riding at the running total
  • still full-height column per step + one gliding tooltip

Copy-paste code

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;
      }
    }
  }
}
Standalone snippet · plain CSS, no build step, no dependencies.