Skip to main content

ns-ui

Chart Waterfall ASCII Step

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.

Usage

registry/core/chart-waterfall-ascii-step/demo.tsx
"use client";

import { ChartWaterfallAsciiStep } from "./component";

export default function ChartWaterfallAsciiStepDemo() {
  return (
    <main className="flex min-h-screen items-center justify-center bg-background p-6">
      <div className="w-full max-w-2xl">
        <p className="mb-4 font-mono text-xs tracking-widest text-ns-muted">
          ns-ui / chart-waterfall-ascii-step
        </p>
        <div className="rounded-md border border-border bg-surface p-5">
          <ChartWaterfallAsciiStep title="Synthetic MRR bridge" />
        </div>
      </div>
    </main>
  );
}

Component

registry/core/chart-waterfall-ascii-step/component.tsx
"use client";

import { useEffect, useId, 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(--ns-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 {
  /** the steps, in order; the final bar is the running total */
  data?: WaterfallStepDatum[];
  /** heading above the chart */
  title?: string;
  /** label for the final running-total bar */
  totalLabel?: string;
  /** extra classes merged onto the rendered root element */
  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("--ns-muted", "#4d4d4d"),
    border: get("--border", "#ebebeb"),
    accent: get("--ns-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);
  // useId, not Math.random(): the id is serialized into element ids on the
  // server and must match what the client renders, or React discards the
  // subtree as a hydration mismatch
  const uid = `cwas-${useId().replace(/:/g, "")}`;

  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-ns-muted">{title.toUpperCase()}</span>
        <span className="font-mono text-[10px] tracking-widest text-ns-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"
          className="ns-cwas-canvas"
          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-ns-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(--ns-accent); outline-offset: 2px; }
`;

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/chart-waterfall-ascii-step.tsx, so point the import there.

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.

Install

npx shadcn add https://design.helpmarq.com/r/chart-waterfall-ascii-step.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

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(--ns-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.

Props

PropTypeDefaultDescription
data?WaterfallStepDatum[]DEFAULT_DATAthe steps, in order; the final bar is the running total
title?string"Chart"heading above the chart
totalLabel?string"Total"label for the final running-total bar
className?string—extra classes merged onto the rendered root element