Skip to main content

ns-ui

Chart Funnel Stage Drop

Canvas funnel chart in ordered-dither ink density, where hovering or focusing a stage animates its own drop-off as ink particles falling into the next stage.

Usage

registry/core/chart-funnel-stage-drop/demo.tsx
"use client";

import { ChartFunnelStageDrop } from "./component";

const DATA = [
  { label: "Visited", value: 12400 },
  { label: "Signed up", value: 6200 },
  { label: "Activated", value: 3100 },
  { label: "Subscribed", value: 980 },
];

export default function ChartFunnelStageDropDemo() {
  return (
    <main className="flex min-h-screen items-center justify-center bg-background p-6">
      <div className="w-full max-w-md">
        <p className="mb-4 font-mono text-xs tracking-widest text-ns-muted">
          ns-ui / chart-funnel-stage-drop
        </p>
        <div className="rounded-md border border-border bg-surface p-5">
          <ChartFunnelStageDrop data={DATA} title="Signup funnel" />
        </div>
      </div>
    </main>
  );
}

Component

registry/core/chart-funnel-stage-drop/component.tsx
"use client";

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

// ---------------------------------------------------------------------------
// ChartFunnelStageDrop — the dithered-chart family's funnel / stage-drop
// chart (backlog queue #13). Each stage is a tapering trapezoid, its interior
// filled with the family's shared ordered-dither ramp (4x4 Bayer matrix, 17
// ink levels) at a density tracking the stage's share of the top of the
// funnel — the usual redundant density channel. The mechanic that is new to
// this family member: hovering or focusing a stage animates its OWN drop-off
// — the count lost before the next stage — as ink particles trickling out of
// its bottom edge into the gap toward the next stage, gravity-plus-jitter,
// particle count proportional to the actual drop, seeded per stage so a
// replay of the same stage always looks identical. Nothing else in the
// registry visualizes a delta as falling ink. Pure var(--foreground) ink on
// var(--background) paper; var(--ns-accent) is reserved for keyboard focus
// only, matching the rest of the family's colour rule.
// ---------------------------------------------------------------------------

export interface ChartFunnelStageDropDatum {
  label: string;
  value: number;
}

export interface ChartFunnelStageDropProps {
  /** the funnel stages, in order */
  data?: ChartFunnelStageDropDatum[];
  /** heading above the chart */
  title?: string;
  /** extra classes merged onto the rendered root element */
  className?: string;
}

const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5];
const LEVELS = 16;
const CELL = 5;

const FUNNEL_W = 380;
const MIN_W_FRAC = 0.22;
const LABEL_ROW_H = 32;
const BAR_H = 44;
const GAP_H = 26;
const TOP_PAD = 6;
const BOTTOM_PAD = 6;
const SIDE_PAD = 24;
const MAX_PARTICLES = 34;
const PARTICLE_PERIOD_MS = 1000;
const ENTRANCE_MS = 420;
const ENTRANCE_STAGGER = 60;

function levelFor(norm: number): number {
  return Math.round(Math.min(1, Math.max(0, norm)) * LEVELS);
}

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

function easeOutExpo(t: number): number {
  return t >= 1 ? 1 : 1 - Math.pow(2, -10 * t);
}

function hashStr(s: string): number {
  let h = 2166136261;
  for (let i = 0; i < s.length; i++) {
    h ^= s.charCodeAt(i);
    h = Math.imul(h, 16777619);
  }
  return h >>> 0;
}
function mulberry32(seed: number) {
  let a = seed;
  return function () {
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

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;
}

/** A seeded rain of ink particles falling through a gap between two stage
 *  edges — deterministic per stage label so hovering the same stage twice
 *  looks identical. `phase` is 0..1 loop position. */
function particlesFor(seed: string, count: number, topW: number, botW: number) {
  const rng = mulberry32(hashStr(seed));
  return Array.from({ length: count }, () => ({
    x0: (rng() - 0.5) * topW,
    x1: (rng() - 0.5) * botW,
    offset: rng(),
    jitter: rng() * Math.PI * 2,
  }));
}

export function ChartFunnelStageDrop({ data = [], title = "Chart", className = "" }: ChartFunnelStageDropProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const tokens = useTokens();
  const [activeIndex, setActiveIndex] = useState(0);
  const [hoverIndex, setHoverIndex] = useState<number | null>(null);
  const [showTable, setShowTable] = useState(false);
  // 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 = `cfs-${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 n = data.length;
  const top = data[0]?.value ?? 1;

  const stages = useMemo(
    () =>
      data.map((d, i) => {
        const frac = top > 0 ? d.value / top : 0;
        const w = FUNNEL_W * (MIN_W_FRAC + (1 - MIN_W_FRAC) * frac);
        const next = data[i + 1];
        const nextFrac = next && top > 0 ? next.value / top : frac * 0.9;
        const wBottom = FUNNEL_W * (MIN_W_FRAC + (1 - MIN_W_FRAC) * nextFrac);
        const drop = next ? Math.max(0, d.value - next.value) : 0;
        const dropFrac = top > 0 ? drop / top : 0;
        return { ...d, index: i, wTop: w, wBottom, frac, drop, dropFrac, level: levelFor(frac) };
      }),
    [data, top]
  );

  const rowH = LABEL_ROW_H + BAR_H;
  const viewW = FUNNEL_W + SIDE_PAD * 2;
  const viewH = TOP_PAD + n * rowH + Math.max(0, n - 1) * GAP_H + BOTTOM_PAD;

  const rowTop = (i: number) => TOP_PAD + i * (rowH + GAP_H);

  const focusStage = (i: number) => {
    if (i < 0 || i >= n) return;
    setActiveIndex(i);
    document.getElementById(`${uid}-stage-${i}`)?.focus();
  };

  const mountAtRef = useRef(0);
  const activeAtRef = useRef<number | null>(null);
  const rafRef = useRef(0);

  useEffect(() => {
    mountAtRef.current = performance.now();
  }, []);

  useEffect(() => {
    if (hoverIndex !== null) activeAtRef.current = performance.now();
    else activeAtRef.current = null;
  }, [hoverIndex]);

  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 cx = viewW / 2;

    const draw = () => {
      ctx.clearRect(0, 0, viewW, viewH);
      const now = performance.now();
      let stillAnimating = false;

      stages.forEach((s, i) => {
        const delay = i * ENTRANCE_STAGGER;
        const t = reducedRef.current ? 1 : Math.min(1, Math.max(0, (now - mountAtRef.current - delay) / ENTRANCE_MS));
        if (t < 1) stillAnimating = true;
        const grow = easeOutExpo(t);

        const yTop = rowTop(i) + LABEL_ROW_H;
        const yBottom = yTop + BAR_H;
        const wTop = s.wTop * grow;
        const wBottom = s.wBottom * grow;

        // trapezoid path
        ctx.save();
        ctx.beginPath();
        ctx.moveTo(cx - wTop / 2, yTop);
        ctx.lineTo(cx + wTop / 2, yTop);
        ctx.lineTo(cx + wBottom / 2, yBottom);
        ctx.lineTo(cx - wBottom / 2, yBottom);
        ctx.closePath();
        const isActive = hoverIndex === i;
        ctx.strokeStyle = isActive ? tokens.accent : tokens.border;
        ctx.lineWidth = isActive ? 1.5 : 1;
        ctx.stroke();
        ctx.clip();

        ctx.fillStyle = tokens.fg;
        const startCol = Math.floor((cx - FUNNEL_W / 2) / CELL);
        const endCol = Math.ceil((cx + FUNNEL_W / 2) / CELL);
        const startRow = Math.floor(yTop / CELL);
        const endRow = Math.ceil(yBottom / CELL);
        for (let gy = startRow; gy < endRow; gy++) {
          for (let gx = startCol; gx < endCol; gx++) {
            const idx = (((gy % 4) + 4) % 4) * 4 + (((gx % 4) + 4) % 4);
            if (BAYER[idx] < s.level) ctx.fillRect(gx * CELL, gy * CELL, CELL, CELL);
          }
        }
        ctx.restore();

        // label + value row above the bar
        ctx.textAlign = "center";
        ctx.font = `10px "GeistMono", ui-monospace, monospace`;
        ctx.fillStyle = tokens.muted;
        ctx.globalAlpha = grow;
        ctx.fillText(s.label.toUpperCase(), cx, rowTop(i) + 12);
        ctx.font = `11px "GeistMono", ui-monospace, monospace`;
        ctx.fillStyle = tokens.fg;
        ctx.fillText(
          `${formatValue(s.value)}  (${Math.round(s.frac * 100)}%)`,
          cx,
          rowTop(i) + 26
        );
        ctx.globalAlpha = 1;

        // the drop-off rain, only in the gap after this stage
        if (i < n - 1 && s.drop > 0) {
          const isHot = hoverIndex === i;
          const count = Math.max(3, Math.round(s.dropFrac * MAX_PARTICLES));
          const parts = particlesFor(`${uid}:${s.label}`, count, s.wBottom, stages[i + 1].wTop);
          const activeAt = activeAtRef.current;

          if (isHot && !reducedRef.current && activeAt !== null) {
            stillAnimating = true;
            ctx.fillStyle = tokens.fg;
            parts.forEach((p) => {
              const elapsed = (now - activeAt + p.offset * PARTICLE_PERIOD_MS) % PARTICLE_PERIOD_MS;
              const phase = elapsed / PARTICLE_PERIOD_MS;
              const y = yBottom + phase * GAP_H;
              const wig = Math.sin(phase * Math.PI * 3 + p.jitter) * 3;
              const x = cx + p.x0 + (p.x1 - p.x0) * phase + wig;
              const alpha = phase < 0.12 ? phase / 0.12 : phase > 0.75 ? Math.max(0, (1 - phase) / 0.25) : 1;
              ctx.globalAlpha = alpha;
              ctx.beginPath();
              ctx.arc(x, y, 1.4, 0, Math.PI * 2);
              ctx.fill();
            });
            ctx.globalAlpha = 1;
          } else if (isHot && reducedRef.current) {
            // reduced motion: one static frame of evenly-spaced particles,
            // no animation loop, still communicates the drop-off count
            ctx.fillStyle = tokens.fg;
            parts.forEach((p, k) => {
              const phase = (k + 0.5) / parts.length;
              const y = yBottom + phase * GAP_H;
              const x = cx + p.x0 + (p.x1 - p.x0) * phase;
              ctx.beginPath();
              ctx.arc(x, y, 1.4, 0, Math.PI * 2);
              ctx.fill();
            });
          }
        }
      });

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

    draw();
    return () => cancelAnimationFrame(rafRef.current);
  }, [stages, tokens, viewW, viewH, n, hoverIndex, uid]);

  const hovered = hoverIndex;

  return (
    <figure className={`ns-cfs inline-block ${className}`} aria-label={`${title}, funnel 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>
        <button
          type="button"
          onClick={() => setShowTable((s) => !s)}
          className="ns-cfs-toggle rounded-sm border border-border px-2 py-1 font-mono text-[10px] tracking-widest text-ns-muted transition-colors duration-150 hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent"
          aria-pressed={showTable}
        >
          {showTable ? "VIEW CHART" : "VIEW TABLE"}
        </button>
      </div>

      {showTable ? (
        <table className="ns-cfs-table w-full border-collapse font-mono text-xs">
          <caption className="sr-only">{title}</caption>
          <thead>
            <tr>
              <th scope="col" className="border-b border-border px-2 py-1.5 text-left text-ns-muted">
                Stage
              </th>
              <th scope="col" className="border-b border-border px-2 py-1.5 text-right text-ns-muted tabular-nums">
                Value
              </th>
              <th scope="col" className="border-b border-border px-2 py-1.5 text-right text-ns-muted tabular-nums">
                Dropped
              </th>
            </tr>
          </thead>
          <tbody>
            {stages.map((s) => (
              <tr key={s.label}>
                <td className="border-b border-border px-2 py-1.5 text-foreground">{s.label}</td>
                <td className="border-b border-border px-2 py-1.5 text-right text-foreground tabular-nums">
                  {s.value.toLocaleString()}
                </td>
                <td className="border-b border-border px-2 py-1.5 text-right text-foreground tabular-nums">
                  {s.index < n - 1 ? s.drop.toLocaleString() : "—"}
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      ) : (
        <div className="relative" style={{ width: viewW, maxWidth: "100%" }}>
          <canvas
            ref={canvasRef}
            aria-hidden="true"
            className="ns-cfsd-canvas"
            style={{ width: viewW, height: viewH, maxWidth: "100%", display: "block" }}
          />

          {stages.map((s) => (
            <button
              key={s.label}
              type="button"
              id={`${uid}-stage-${s.index}`}
              tabIndex={activeIndex === s.index ? 0 : -1}
              aria-label={
                s.index < n - 1
                  ? `${s.label}: ${s.value.toLocaleString()}, ${Math.round(s.frac * 100)}% of top, ${s.drop.toLocaleString()} dropped before next stage`
                  : `${s.label}: ${s.value.toLocaleString()}, ${Math.round(s.frac * 100)}% of top`
              }
              className="ns-cfs-hit absolute cursor-pointer border-0 bg-transparent p-0 outline-none"
              style={{
                left: 0,
                top: rowTop(s.index),
                width: viewW,
                height: rowH + (s.index < n - 1 ? GAP_H : 0),
              }}
              onPointerEnter={() => setHoverIndex(s.index)}
              onPointerLeave={() => setHoverIndex((c) => (c === s.index ? null : c))}
              onFocus={() => {
                setActiveIndex(s.index);
                setHoverIndex(s.index);
              }}
              onBlur={() => setHoverIndex((c) => (c === s.index ? null : c))}
              onKeyDown={(e) => {
                if (e.key === "ArrowUp") {
                  e.preventDefault();
                  focusStage(s.index - 1);
                } else if (e.key === "ArrowDown") {
                  e.preventDefault();
                  focusStage(s.index + 1);
                }
              }}
            />
          ))}

          {hovered !== null && stages[hovered] && stages[hovered].index < n - 1 && (
            <div
              aria-hidden="true"
              className="ns-cfs-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: "50%",
                top: `${((rowTop(hovered) + rowH + GAP_H / 2) / viewH) * 100}%`,
                transform: "translate(-50%, -50%)",
              }}
            >
              <strong className="text-foreground">{stages[hovered].drop.toLocaleString()}</strong>{" "}
              <span className="text-ns-muted">dropped</span>
            </div>
          )}
        </div>
      )}
    </figure>
  );
}

const CSS = `
.ns-cfs-hit { touch-action: manipulation; }
.ns-cfs-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-funnel-stage-drop.tsx, so point the import there.

Use when a sequential conversion funnel where the drop-off between two specific stages is the fact worth dwelling on, not just the totals — pick chart-bar-dither instead when the categories are not sequential stages, or chart-donut-halftone when the data is a part-to-whole ordinal breakdown rather than a step-by-step process.

Install

npx shadcn add https://design.helpmarq.com/r/chart-funnel-stage-drop.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 dithered-chart family's funnel/stage-drop chart (backlog queue #13), a CANVAS member alongside chart-bar-dither and chart-line-dither. Each stage renders as a trapezoid tapering from its own share-of-top width to the next stage's width, filled with the family's shared ordered-dither ramp (4x4 Bayer matrix, 17 ink levels) at a density tracking that stage's own fraction of the top of the funnel — the usual redundant density channel, pure var(--foreground) ink on var(--background) paper, var(--ns-accent) reserved for the hovered stage's outline and keyboard focus only. The mechanic unique to this family member: hovering or focusing a stage animates its own drop-off — the count lost before the next stage — as a seeded rain of ink particles trickling out of the stage's bottom edge, falling with a sinusoidal horizontal jitter through the gap toward the next stage's top edge, fading in over the first 12% of the fall and out over the last 25%. Particle count is proportional to the actual drop-off as a fraction of the funnel's top value (capped at 34), and the fall is a continuous, looping, gravity-style animation only while that stage is hovered or focused — nothing else in the registry visualizes a delta as literally falling ink. Both the particle offsets and jitter come from a mulberry32 PRNG seeded on the stage's own label, so hovering the same stage twice always reproduces the identical rain, never Math.random(). Each stage has a real, full-width <button> hit target spanning its row plus the gap beneath it, with an aria-label carrying its value, share of top, and (for every stage but the last) its drop-off count, and roving tabindex — ArrowUp/ArrowDown move focus between stages. Tokens are read via getComputedStyle on the document root at mount and re-read through a MutationObserver on its class attribute. On mount, stages unfurl from zero width over 420ms with a 60ms per-stage stagger; prefers-reduced-motion skips the stagger and, on hover or focus, renders one static frame of evenly spaced particles along the fall path instead of animating them, so the drop-off is still communicated without a persistent rAF loop. A VIEW TABLE toggle swaps the chart for a real HTML table listing each stage's value and drop-off. Zero dependencies.

Props

PropTypeDefaultDescription
data?ChartFunnelStageDropDatum[][]the funnel stages, in order
title?string"Chart"heading above the chart
className?string—extra classes merged onto the rendered root element