Chart Waterfall ASCII Step

Chart

ASCII-ramp waterfall chart where toggling a step excludes or restores its delta and every bar after it — plus the running total — visibly recomputes and re-animates, not just a hover tooltip.

Install
npx shadcn add https://design.helpmarq.com/r/chart-waterfall-ascii-step.json
Source
registry/core/chart-waterfall-ascii-step/component.tsx
"use client";

import { useEffect, useMemo, useRef, useState } from "react";

// ---------------------------------------------------------------------------
// ChartWaterfallAsciiStep — the registry's first waterfall chart. Nearest
// neighbours: chart-funnel-stage-drop tapers a single monotonic total and
// animates its OWN drop-off as falling ink; chart-bar-dither/-halftone are
// one static series per bar with no relationship between bars. Neither
// recomputes a chain — a waterfall's whole point is that every bar's
// position depends on every bar before it.
//
// The mechanic: every intermediate step has a real toggle (click or
// Enter/Space on its hit button, aria-pressed). Toggling excludes or
// restores that step's delta, and every bar from that step onward — plus
// the trailing running total — animates its floating top/bottom AND its
// displayed cumulative number to the new value over ~420ms. This is a
// genuine recompute rendered as motion, not a tooltip: excluding "Refunds"
// visibly redraws the shape of every later step and the total. Hovering or
// focusing (without toggling) shows a tooltip with that step's exact
// contribution and its before/after cumulative, so the read-out is
// available before committing to a change.
//
// Bars keep the family's shared 4x4-Bayer-equivalent ASCII ramp
// (' .:-=+*#%@') as a redundant density channel tracking |delta| — pure
// var(--foreground) ink, var(--accent) reserved for the active/focused step.
// ---------------------------------------------------------------------------

const RAMP = " .:-=+*#%@";

export interface WaterfallStepDatum {
  label: string;
  /** first entry is the starting balance; every following entry is a signed delta */
  delta: number;
}

export interface ChartWaterfallAsciiStepProps {
  data?: WaterfallStepDatum[];
  title?: string;
  totalLabel?: string;
  className?: string;
}

const DEFAULT_DATA: WaterfallStepDatum[] = [
  { label: "Start", delta: 1000 },
  { label: "New signups", delta: 420 },
  { label: "Upgrades", delta: 180 },
  { label: "Churn", delta: -260 },
  { label: "Refunds", delta: -90 },
  { label: "Expansion", delta: 140 },
];

const BAR_W = 30;
const SLOT_W = 62;
const PLOT_H = 190;
const TOP_PAD = 30;
const AXIS_H = 8;
const LABEL_H = 32;
const LEFT_PAD = 12;
const RIGHT_PAD = 12;
const CELL = 4;
const RECOMPUTE_MS = 420;

interface Tokens {
  fg: string;
  bg: string;
  muted: string;
  border: string;
  accent: string;
}

function readTokens(): Tokens {
  const cs = getComputedStyle(document.documentElement);
  const get = (name: string, fallback: string) => cs.getPropertyValue(name).trim() || fallback;
  return {
    fg: get("--foreground", "#171717"),
    bg: get("--background", "#ffffff"),
    muted: get("--muted", "#4d4d4d"),
    border: get("--border", "#ebebeb"),
    accent: get("--accent", "#006bff"),
  };
}

function useTokens(): Tokens {
  const [tokens, setTokens] = useState<Tokens>(() =>
    typeof document === "undefined"
      ? { fg: "#171717", bg: "#ffffff", muted: "#4d4d4d", border: "#ebebeb", accent: "#006bff" }
      : readTokens()
  );
  useEffect(() => {
    const sync = () => setTokens(readTokens());
    sync();
    const mo = new MutationObserver(sync);
    mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style"] });
    return () => mo.disconnect();
  }, []);
  return tokens;
}

function easeOutCubic(t: number): number {
  return 1 - Math.pow(1 - t, 3);
}

function formatValue(v: number): string {
  const sign = v < 0 ? "-" : "";
  const abs = Math.abs(v);
  if (abs >= 1_000_000) return `${sign}${(abs / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
  if (abs >= 1_000) return `${sign}${(abs / 1_000).toFixed(1).replace(/\.0$/, "")}K`;
  return `${sign}${Math.round(abs).toLocaleString()}`;
}

interface Bar {
  label: string;
  index: number;
  isStart: boolean;
  isTotal: boolean;
  toggleable: boolean;
  excluded: boolean;
  delta: number;
  from: number;
  to: number;
}

function paintDitherRect(
  ctx: CanvasRenderingContext2D,
  x: number,
  yTop: number,
  yBase: number,
  w: number,
  ch: string,
  ink: string
) {
  if (ch === " ") return;
  ctx.save();
  ctx.beginPath();
  ctx.rect(x, Math.min(yTop, yBase), w, Math.abs(yBase - yTop));
  ctx.clip();
  ctx.fillStyle = ink;
  ctx.font = `${CELL * 2.4}px "GeistMono", ui-monospace, monospace`;
  ctx.textAlign = "left";
  ctx.textBaseline = "top";
  const rowH = CELL * 2.6;
  const top = Math.min(yTop, yBase);
  const bottom = Math.max(yTop, yBase);
  for (let y = top; y < bottom; y += rowH) {
    for (let cx = x; cx < x + w; cx += CELL * 2) {
      ctx.fillText(ch, cx, y);
    }
  }
  ctx.restore();
}

export function ChartWaterfallAsciiStep({
  data = DEFAULT_DATA,
  title = "Chart",
  totalLabel = "Total",
  className = "",
}: ChartWaterfallAsciiStepProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const tokens = useTokens();
  const [excluded, setExcluded] = useState<Set<number>>(new Set());
  const [hoverIndex, setHoverIndex] = useState<number | null>(null);
  // index 0 is always the start bar (display-only, never focusable), so the
  // first real control is index 1
  const [activeIndex, setActiveIndex] = useState(1);
  const uidRef = useRef(`cwas-${Math.random().toString(36).slice(2, 8)}`);
  const uid = uidRef.current;

  const reducedRef = useRef(false);
  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    const sync = () => {
      reducedRef.current = mq.matches;
    };
    sync();
    mq.addEventListener("change", sync);
    return () => mq.removeEventListener("change", sync);
  }, []);

  const bars: Bar[] = useMemo(() => {
    let cumulative = 0;
    const out: Bar[] = [];
    data.forEach((d, i) => {
      const isStart = i === 0;
      const excludedNow = !isStart && excluded.has(i);
      const from = cumulative;
      const delta = excludedNow ? 0 : d.delta;
      cumulative += delta;
      out.push({
        label: d.label,
        index: i,
        isStart,
        isTotal: false,
        toggleable: !isStart,
        excluded: excludedNow,
        delta: d.delta,
        from,
        to: cumulative,
      });
    });
    out.push({
      label: totalLabel,
      index: data.length,
      isStart: false,
      isTotal: true,
      toggleable: false,
      excluded: false,
      delta: cumulative,
      from: 0,
      to: cumulative,
    });
    return out;
  }, [data, excluded, totalLabel]);

  const maxAbs = useMemo(() => Math.max(1, ...bars.map((b) => Math.max(Math.abs(b.from), Math.abs(b.to)))), [bars]);
  const n = bars.length;
  const viewW = LEFT_PAD + n * SLOT_W + RIGHT_PAD;
  const viewH = TOP_PAD + PLOT_H + AXIS_H + LABEL_H;
  const zeroY = TOP_PAD + PLOT_H;
  const valueToY = (v: number) => zeroY - (v / maxAbs) * PLOT_H;

  // only the intermediate delta steps are real, focusable controls — the
  // start and running-total bars are display-only, so roving tabindex must
  // stay confined to this list or Tab can land on nothing (a disabled
  // button ignores tabIndex entirely)
  const toggleableIndices = useMemo(() => bars.filter((b) => b.toggleable).map((b) => b.index), [bars]);

  useEffect(() => {
    if (!toggleableIndices.includes(activeIndex) && toggleableIndices.length > 0) {
      setActiveIndex(toggleableIndices[0]);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [toggleableIndices]);

  const focusByOffset = (offset: number) => {
    const from = toggleableIndices.indexOf(activeIndex);
    const nextPos = Math.min(toggleableIndices.length - 1, Math.max(0, (from < 0 ? 0 : from) + offset));
    const target = toggleableIndices[nextPos];
    if (target === undefined) return;
    setActiveIndex(target);
    document.getElementById(`${uid}-hit-${target}`)?.focus();
  };

  const toggle = (b: Bar) => {
    if (!b.toggleable) return;
    setExcluded((cur) => {
      const next = new Set(cur);
      if (next.has(b.index)) next.delete(b.index);
      else next.add(b.index);
      return next;
    });
  };

  // animated per-bar "to" value + displayed cumulative, lerped every frame
  // toward the freshly computed target so a toggle reads as a live recompute
  const displayToRef = useRef<number[]>([]);
  const rafRef = useRef(0);

  useEffect(() => {
    if (displayToRef.current.length !== n) {
      displayToRef.current = bars.map((b) => b.to);
    }
  }, [n, bars]);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    canvas.width = viewW * dpr;
    canvas.height = viewH * dpr;
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);

    const draw = () => {
      ctx.clearRect(0, 0, viewW, viewH);

      ctx.strokeStyle = tokens.border;
      ctx.lineWidth = 1;
      const zy = Math.round(valueToY(0)) + 0.5;
      ctx.beginPath();
      ctx.moveTo(LEFT_PAD, zy);
      ctx.lineTo(viewW - RIGHT_PAD, zy);
      ctx.stroke();

      let stillAnimating = false;

      bars.forEach((b, i) => {
        const x = LEFT_PAD + i * SLOT_W + (SLOT_W - BAR_W) / 2;
        const curTo = displayToRef.current[i] ?? b.to;
        const nextTo = reducedRef.current ? b.to : curTo + (b.to - curTo) * 0.24;
        displayToRef.current[i] = Math.abs(nextTo - b.to) < 0.5 ? b.to : nextTo;
        if (Math.abs(displayToRef.current[i] - b.to) >= 0.5) stillAnimating = true;

        const yFrom = valueToY(b.isTotal ? 0 : b.from);
        const yTo = valueToY(b.isTotal ? displayToRef.current[i] : displayToRef.current[i]);

        // hoverIndex alone carries real interaction (pointer hover AND
        // keyboard focus both set it) — activeIndex is only roving-tabindex
        // bookkeeping and must never tint a resting, unfocused bar
        const isActive = hoverIndex === i;
        const level = Math.min(
          RAMP.length - 1,
          Math.round((Math.abs(b.isTotal ? b.to : b.to - b.from) / maxAbs) * (RAMP.length - 1))
        );
        const ch = b.excluded ? RAMP[1] : RAMP[Math.max(level, 2)];
        const ink = isActive ? tokens.accent : b.excluded ? tokens.muted : tokens.fg;

        paintDitherRect(ctx, x, yFrom, yTo, BAR_W, ch, ink);
        ctx.strokeStyle = ink;
        ctx.lineWidth = 1;
        ctx.strokeRect(x + 0.5, Math.min(yFrom, yTo) + 0.5, BAR_W, Math.abs(yTo - yFrom));

        // connector to next bar
        if (i < bars.length - 1) {
          ctx.strokeStyle = tokens.border;
          ctx.setLineDash([2, 2]);
          const connY = valueToY(displayToRef.current[i]);
          ctx.beginPath();
          ctx.moveTo(x + BAR_W, connY);
          ctx.lineTo(x + SLOT_W + (SLOT_W - BAR_W) / 2, connY);
          ctx.stroke();
          ctx.setLineDash([]);
        }

        ctx.font = `9.5px "GeistMono", ui-monospace, monospace`;
        ctx.textAlign = "center";
        ctx.fillStyle = ink;
        const labelValue = b.isTotal ? displayToRef.current[i] : b.excluded ? 0 : b.delta;
        const prefix = !b.isStart && !b.isTotal && labelValue >= 0 ? "+" : "";
        ctx.fillText(`${prefix}${formatValue(labelValue)}`, x + BAR_W / 2, Math.min(yFrom, yTo) - 8);
        ctx.fillStyle = tokens.muted;
        ctx.fillText(b.label, x + BAR_W / 2, zeroY + AXIS_H + 12);
        if (b.toggleable) {
          ctx.fillText(b.excluded ? "(excluded)" : "", x + BAR_W / 2, zeroY + AXIS_H + 24);
        }
      });

      if (stillAnimating && !reducedRef.current) {
        rafRef.current = requestAnimationFrame(draw);
      }
    };

    draw();
    return () => cancelAnimationFrame(rafRef.current);
  }, [bars, tokens, viewW, viewH, maxAbs, hoverIndex, activeIndex, zeroY]);

  const hovered = hoverIndex !== null ? bars[hoverIndex] : null;

  return (
    <figure className={`ns-cwas inline-block ${className}`} aria-label={`${title}, waterfall chart`}>
      <style>{CSS}</style>
      <div className="flex items-center justify-between gap-3 pb-2">
        <span className="font-mono text-xs tracking-widest text-muted">{title.toUpperCase()}</span>
        <span className="font-mono text-[10px] tracking-widest text-muted">
          {excluded.size > 0 ? `${excluded.size} STEP${excluded.size > 1 ? "S" : ""} EXCLUDED` : "ALL STEPS ACTIVE"}
        </span>
      </div>

      <div className="relative" style={{ width: viewW, maxWidth: "100%" }}>
        <canvas
          ref={canvasRef}
          aria-hidden="true"
          style={{ width: viewW, height: viewH, maxWidth: "100%", display: "block" }}
        />

        {bars.map((b) =>
          b.toggleable ? (
            <button
              key={b.index}
              type="button"
              id={`${uid}-hit-${b.index}`}
              tabIndex={activeIndex === b.index ? 0 : -1}
              aria-label={`${b.label}: ${formatValue(b.delta)}${b.excluded ? ", excluded" : ""}`}
              aria-pressed={!b.excluded}
              className="ns-cwas-hit absolute cursor-pointer border-0 bg-transparent p-0 outline-none"
              style={{
                left: LEFT_PAD + b.index * SLOT_W,
                top: TOP_PAD - 16,
                width: SLOT_W,
                height: PLOT_H + 16,
              }}
              onPointerEnter={() => setHoverIndex(b.index)}
              onPointerLeave={() => setHoverIndex((c) => (c === b.index ? null : c))}
              onFocus={() => {
                setActiveIndex(b.index);
                setHoverIndex(b.index);
              }}
              onBlur={() => setHoverIndex((c) => (c === b.index ? null : c))}
              onClick={() => toggle(b)}
              onKeyDown={(e) => {
                if (e.key === "ArrowLeft") {
                  e.preventDefault();
                  focusByOffset(-1);
                } else if (e.key === "ArrowRight") {
                  e.preventDefault();
                  focusByOffset(1);
                }
              }}
            />
          ) : (
            // start / running-total bars are display-only, not real controls
            <div
              key={b.index}
              aria-hidden="true"
              className="ns-cwas-hover absolute"
              style={{
                left: LEFT_PAD + b.index * SLOT_W,
                top: TOP_PAD - 16,
                width: SLOT_W,
                height: PLOT_H + 16,
              }}
              onPointerEnter={() => setHoverIndex(b.index)}
              onPointerLeave={() => setHoverIndex((c) => (c === b.index ? null : c))}
            />
          )
        )}

        {hovered && (
          <div
            aria-hidden="true"
            className="ns-cwas-tip pointer-events-none absolute z-10 rounded-sm border border-border bg-background px-2 py-1 font-mono text-[11px] shadow-sm"
            style={{
              left: `${((LEFT_PAD + hovered.index * SLOT_W + SLOT_W / 2) / viewW) * 100}%`,
              top: `${(Math.max(0, valueToY(Math.max(hovered.from, hovered.to)) - 46) / viewH) * 100}%`,
              transform: "translateX(-50%)",
            }}
          >
            <strong className="text-foreground">
              {hovered.toggleable ? formatValue(hovered.delta) : formatValue(hovered.to)}
            </strong>{" "}
            <span className="text-muted">
              {hovered.toggleable ? `${formatValue(hovered.from)} → ${formatValue(hovered.to)}` : "running total"}
            </span>
          </div>
        )}
      </div>
    </figure>
  );
}

const CSS = `
.ns-cwas-hit { touch-action: manipulation; }
.ns-cwas-hit:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
`;
Use when

showing how a sequence of signed deltas bridges a start value to an end value, where the audience needs to see what the total would look like without one step — pick chart-funnel-stage-drop for a single monotonic total's own drop-off, or chart-bar-dither for unrelated categorical bars with no running dependency between them.

Build spec

The registry's first waterfall chart: a start bar, a chain of floating delta bars (each spanning the running cumulative before and after it), and a trailing running-total bar. Every bar fill uses the family's shared 10-step ASCII ramp ' .:-=+*#%@' tiled at a small cell size, with density tracking |delta| relative to the largest bar on the chart — redundant with bar height, exactly like chart-bar-dither's own colour rule (pure var(--foreground) ink, var(--accent) reserved for the hovered or keyboard-focused step). The real mechanic: every intermediate step (not the start, not the total) has a genuine toggle — a transparent hit button sized to its full slot, roving tabindex with ArrowLeft/ArrowRight moving focus between steps, aria-pressed reflecting whether the step is currently included. Activating it (click, or Enter/Space via keyboard) excludes that step's delta from the chain or restores it, and the recompute is rendered as real motion: every bar from that step onward, and the trailing total, eases its floating top/bottom to the newly recomputed cumulative over roughly 420ms, with the displayed numeric label tracking the same lerp — this is what makes it a genuine recompute rather than a tooltip. Hovering or focusing without activating shows a small tooltip with that step's exact contribution and its before/after cumulative, so the numbers are readable before committing to a change. A dashed connector line links each bar's resolved edge to the next bar's start so the chain reads as one continuous bridge even mid-animation. Tokens are read via getComputedStyle at mount and re-read through a MutationObserver on the document root's class attribute, so both themes repaint correctly on toggle with no remount. prefers-reduced-motion snaps every recompute to its final position/value in one paint instead of lerping, and the toggle itself remains fully functional. Zero dependencies.

Tags
chartwaterfalldata-vizasciicanvas