Basic Funnel Chart — Highcharts

A funnel chart visualizes sequential stages of a process where values progressively decrease from one stage to the next. Each stage is represented as a trapezoidal segment that narrows from top to bottom, making it easy to identify drop-offs between stages. This chart is ideal for tracking conversion rates, sales pipelines, and multi-step processes where understanding stage-to-stage transitions is critical.

Basic Funnel Chart rendered with Highcharts

Renders

JavaScript source (Highcharts)

// anyplot.ai
// funnel-basic: Basic Funnel Chart
// Library: highcharts 12.6.0 | JavaScript 22.23.2
// Quality: 92/100 | Created: 2026-09-05

//# anyplot-orientation: square

// Only the core Highcharts bundle is loaded (no `funnel` module), so the
// trapezoid stack is computed by hand and drawn with `chart.renderer.path()`
// — the same hand-drawn technique treemap-basic / sunburst-basic / sankey-basic
// use for series types the core bundle doesn't ship. Each segment gets a
// native SVG `<title>` for hover (same trick as treemap-basic).

const t = window.ANYPLOT_TOKENS;

// --- Data: e-commerce checkout funnel (unique site sessions) ---------------
const STAGES = ["Site Visitors", "Product Views", "Added to Cart", "Started Checkout", "Completed Purchase"];
const VALUES = [48000, 26400, 11200, 6100, 3850];

// Position 5 (matte red) is the deferred bad/error anchor — skip it here
// since the narrowest stage is a successful purchase, not a failure, and
// pick cyan instead so palette order never implies the funnel's happy
// ending is "bad". See default-style-guide.md "Semantic exception".
const COLORS = [t.palette[0], t.palette[1], t.palette[2], t.palette[3], t.palette[5]];

const n = STAGES.length;
const pct = (v) => `${((v / VALUES[0]) * 100).toFixed(1)}%`;
const fmt = (v) => v.toLocaleString("en-US");

// --- Title (fontsize scaled off the 67-char baseline) -----------------------
const TITLE_TEXT = "Checkout Conversion · funnel-basic · javascript · highcharts · anyplot.ai";
const TITLE_FS = Math.max(Math.round(22 * Math.min(1, 67 / TITLE_TEXT.length)), 14);

// --- Chart shell (no series/axes — the funnel is drawn by hand) ------------
const chart = Highcharts.chart("container", {
  chart: {
    backgroundColor: "transparent",
    animation: false,
    style: { fontFamily: "inherit" },
    marginTop: 130,
    marginBottom: 40,
    marginLeft: 40,
    marginRight: 40,
  },
  credits: { enabled: false },
  title: {
    text: TITLE_TEXT,
    style: { color: t.ink, fontSize: TITLE_FS + "px", fontWeight: "600" },
  },
  subtitle: {
    text: `Simulated site sessions, ${fmt(VALUES[0])} visitors → ${fmt(VALUES[n - 1])} purchases (${pct(VALUES[n - 1])} overall)`,
    style: { color: t.inkSoft, fontSize: "14px" },
  },
  xAxis: { visible: false },
  yAxis: { visible: false },
  legend: { enabled: false },
  plotOptions: { series: { animation: false } },
  series: [],
});

const PLOT_W = chart.plotWidth;
const PLOT_H = chart.plotHeight;
const GAP = 10;

// First-pass layout: a rough block split just wide enough to hold the
// funnel + label gutter. The label strings are variable-width, so this
// guess is deliberately not centered on the canvas yet — the whole
// composition (funnel + leader lines + labels) is drawn into group `g`
// below, then re-centered on `g`'s *actual* rendered bounding box (real
// text extents, not the abstract split) so left/right whitespace balances
// symmetrically regardless of how wide the label strings turn out to be.
const BLOCK_W = PLOT_W * 0.86;
const blockLeft = chart.plotLeft + (PLOT_W - BLOCK_W) / 2;
const funnelZoneW = BLOCK_W * 0.5;

const centerX = blockLeft + funnelZoneW / 2;
const maxHalfWidth = (funnelZoneW / 2) * 0.9;
const labelX = blockLeft + funnelZoneW + 24;

const scale = maxHalfWidth / VALUES[0];
const halfWidths = VALUES.map((v) => v * scale);
const bandHeight = (PLOT_H - (n - 1) * GAP) / n;

// Steepest stage-to-stage drop-off (largest relative loss from one stage
// to the next, i.e. the transition with the lowest stage-over-stage
// conversion rate), called out below with a native Highcharts renderer
// "callout" label pointing at the transition.
const dropRates = VALUES.slice(0, -1).map((v, i) => 1 - VALUES[i + 1] / v);
const steepestIdx = dropRates.indexOf(Math.max(...dropRates));
let dropAnchor = null;

function addTooltip(el, text) {
  const titleEl = document.createElementNS("http://www.w3.org/2000/svg", "title");
  titleEl.textContent = text;
  el.element.appendChild(titleEl);
}

function labelColorFor(bgHex) {
  const c = parseInt(bgHex.slice(1), 16);
  const r = (c >> 16) & 255;
  const g = (c >> 8) & 255;
  const b = c & 255;
  const luma = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
  return luma > 0.55 ? "#1A1A17" : "#FFFDF6";
}

const g = chart.renderer.g("funnel").add();

STAGES.forEach((stage, i) => {
  const y0 = chart.plotTop + i * (bandHeight + GAP);
  const y1 = y0 + bandHeight;
  const topHalf = halfWidths[i];
  // Last stage has no next value to taper into — render it as a flat-bottomed
  // band (its own width top and bottom) rather than inventing a taper amount.
  const bottomHalf = i < n - 1 ? halfWidths[i + 1] : halfWidths[i];

  if (i === steepestIdx) {
    dropAnchor = { x: centerX - bottomHalf, y: y1 };
  }

  const path = [
    ["M", centerX - topHalf, y0],
    ["L", centerX + topHalf, y0],
    ["L", centerX + bottomHalf, y1],
    ["L", centerX - bottomHalf, y1],
    ["Z"],
  ];

  const segment = chart.renderer
    .path(path)
    .attr({ fill: COLORS[i], stroke: t.pageBg, "stroke-width": 2, zIndex: 2 })
    .add(g);
  addTooltip(segment, `${stage}\n${fmt(VALUES[i])} (${pct(VALUES[i])} of visitors)`);

  // In-segment value label when the band is wide enough to hold it legibly.
  const midY = (y0 + y1) / 2;
  const avgHalf = (topHalf + bottomHalf) / 2;
  if (avgHalf > 55) {
    chart.renderer
      .text(fmt(VALUES[i]), centerX, midY + 6)
      .attr({ align: "center", zIndex: 3 })
      .css({ color: labelColorFor(COLORS[i]), fontSize: "16px", fontWeight: "600", pointerEvents: "none" })
      .add(g);
  }

  // Leader line + stage label in the right-hand gutter.
  const leaderStartX = centerX + avgHalf + 6;
  chart.renderer
    .path([
      ["M", leaderStartX, midY],
      ["L", labelX - 8, midY],
    ])
    .attr({ stroke: t.inkSoft, "stroke-width": 1, zIndex: 1 })
    .add(g);

  chart.renderer
    .text(stage, labelX, midY - 4)
    .attr({ align: "left", zIndex: 3 })
    .css({ color: t.ink, fontSize: "16px", fontWeight: "600" })
    .add(g);
  chart.renderer
    .text(`${fmt(VALUES[i])} · ${pct(VALUES[i])}`, labelX, midY + 16)
    .attr({ align: "left", zIndex: 3 })
    .css({ color: t.inkSoft, fontSize: "14px" })
    .add(g);
});

// Call out the steepest stage-to-stage drop-off with a native Highcharts
// SVGRenderer "callout" label (bordered, pointer anchored at the transition)
// — a distinctive Highcharts component, not a plain SVG <text>.
if (dropAnchor) {
  const calloutText = `Steepest drop: −${(dropRates[steepestIdx] * 100).toFixed(1)}%`;
  const calloutX = Math.max(chart.plotLeft + 4, dropAnchor.x - 210);
  const calloutY = dropAnchor.y - 34;
  chart.renderer
    .label(calloutText, calloutX, calloutY, "callout", dropAnchor.x, dropAnchor.y)
    .css({ color: t.ink, fontSize: "13px", fontWeight: "600" })
    .attr({ fill: t.elevatedBg, stroke: t.amber, "stroke-width": 1.5, padding: 8, r: 5, zIndex: 4 })
    .add(g);
}

// Re-center the whole composition (funnel + leader lines + labels +
// callout) on its *actual* rendered bounding box — real text extents,
// not the abstract BLOCK_W split above — so left/right whitespace
// balances symmetrically instead of trailing off wherever the
// variable-width label strings happen to end.
const bbox = g.getBBox(true);
const shift = chart.plotLeft + PLOT_W / 2 - (bbox.x + bbox.width / 2);
g.attr({ translateX: shift });

Retrieve this implementation

Runnable source as JSON, for any HTTP client: https://api.anyplot.ai/specs/funnel-basic/highcharts/code. Any spec id and library id listed in llms-full.txt fit the same URL shape; every URL below is complete and callable.

{
  "spec_id": "funnel-basic",
  "language": "javascript",
  "library": "highcharts",
  "page": "https://anyplot.ai/funnel-basic/javascript/highcharts",
  "hub": "https://anyplot.ai/funnel-basic",
  "code_json": "https://api.anyplot.ai/specs/funnel-basic/highcharts/code",
  "spec_json": "https://api.anyplot.ai/specs/funnel-basic",
  "render_light_png": "https://storage.googleapis.com/anyplot-images/plots/funnel-basic/javascript/highcharts/plot-light.png",
  "render_dark_png": "https://storage.googleapis.com/anyplot-images/plots/funnel-basic/javascript/highcharts/plot-dark.png",
  "interactive_light_html": "https://storage.googleapis.com/anyplot-images/plots/funnel-basic/javascript/highcharts/plot-light.html",
  "interactive_dark_html": "https://storage.googleapis.com/anyplot-images/plots/funnel-basic/javascript/highcharts/plot-dark.html",
  "quality_score": 92.0,
  "license": "MIT",
  "guide": "https://anyplot.ai/llms.txt"
}

Part of Basic Funnel Chart on anyplot.ai.

Other implementations