Funnel Chart

Where a population drained away, one step at a time.

'use client';

import { useState } from 'react';
import { FunnelChart, type FunnelDatum } from '@/components/panelui/funnel-chart';
import { Frame } from '@/components/panelui/frame';

const CHECKOUT: FunnelDatum[] = [
  { label: 'Viewed', value: 41800 },
  { label: 'Basket', value: 18240 },
  { label: 'Address', value: 9420 },
  { label: 'Card', value: 6180 },
  { label: 'Paid', value: 5240 },
];

export default function FunnelChartOverview() {
  const [active, setActive] = useState<FunnelDatum | null>(null);
  const stage = active ?? CHECKOUT[0]!;

  return (
    <Frame variant="band" className="w-full max-w-lg">
      <Frame.Header>
        <Frame.Title>Checkout</Frame.Title>
        <Frame.Action>Hover to read</Frame.Action>
      </Frame.Header>
      <Frame.Panel className="p-5">
        <FunnelChart data={CHECKOUT} height={240} onActiveIndexChange={(_index, next) => setActive(next)}>
          <FunnelChart.Header title={stage.label} value={stage.value.toLocaleString('en')} caption="Last 30 days" />
          <FunnelChart.Stages />
          <FunnelChart.Labels />
        </FunnelChart>
      </Frame.Panel>
    </Frame>
  );
}

Use it to show how many were left at each step of a process, such as a checkout or a signup. Each stage is drawn as a band of one ribbon, as tall as its value where it starts and as tall as the next stage's where it ends.

Every stage has to be a subset of the one above it, in the order the steps happen. The chart never sorts them.

For categories that do not nest, use a Bar Chart.

Installation

npx shadcn@latest add https://panelui.dev/r/web/funnel-chart.json

The source is copied into components/panelui/, where you can edit it.

Usage

import { FunnelChart } from "@/components/panelui/funnel-chart"
<FunnelChart data={checkout}>
  <FunnelChart.Header title="Viewed" value="41,800" />
  <FunnelChart.Stages />
  <FunnelChart.Labels />
</FunnelChart>

Composition

<FunnelChart data={…}>
  <FunnelChart.Header />
  <FunnelChart.Skeleton />
  <FunnelChart.Stages />
  <FunnelChart.Labels />
  <FunnelChart.Legend />
</FunnelChart>
  • FunnelChart.Header — The strip above the run: title, value and caption, with your own control on the trailing side. The value is a string you pass, so it can follow onActiveIndexChange.
  • FunnelChart.Skeleton — One plain ribbon over the whole run, drawn only while status is loading. It has no divisions, because placeholder stages would read as a real drop-off.
  • FunnelChart.Stages — The ribbon. It is one part rather than one per stage, because a stage's near edge is the previous stage's far edge.
  • FunnelChart.Labels — The readings around the ribbon: the count above each band, the name under it, and the conversion in a pill on it. share picks which conversion.
  • FunnelChart.Legend — A swatch, a name and a reading per stage, under the run. Use it in place of Labels on a compact chart. layout is list or inline.

Examples

Which conversion the pill reports

share="top" is the share of the first stage, which every stage has. share="previous" is the share of the stage above, so the first stage has no pill. share="none" leaves the pill out.

'use client';

import { FunnelChart, type FunnelDatum } from '@/components/panelui/funnel-chart';

const CHECKOUT: FunnelDatum[] = [
  { label: 'Viewed', value: 41800 },
  { label: 'Basket', value: 18240 },
  { label: 'Checkout', value: 9420 },
  { label: 'Paid', value: 5240 },
];

export default function FunnelChartConversion() {
  return (
    <div className="flex w-full max-w-lg flex-col gap-6">
      <FunnelChart data={CHECKOUT}>
        <FunnelChart.Header title="Share of the first stage" />
        <FunnelChart.Stages />
        <FunnelChart.Labels share="top" />
      </FunnelChart>
      <FunnelChart data={CHECKOUT}>
        <FunnelChart.Header title="Share of the stage above" />
        <FunnelChart.Stages />
        <FunnelChart.Labels share="previous" />
      </FunnelChart>
    </div>
  );
}

Straight sides, one layer

edges="straight" draws the sides as diagonals, and layers={1} draws each band once with a flat fill. color names the hue as one of the theme's roles.

'use client';

import { FunnelChart, type FunnelDatum } from '@/components/panelui/funnel-chart';

const SIGNUP: FunnelDatum[] = [
  { label: 'Visited', value: 12400 },
  { label: 'Signed up', value: 4310 },
  { label: 'Verified', value: 2980 },
  { label: 'Active', value: 1640 },
];

export default function FunnelChartStraight() {
  return (
    <div className="w-full max-w-lg">
      <FunnelChart data={SIGNUP} edges="straight" layers={1} color="info" gap={2}>
        <FunnelChart.Stages />
        <FunnelChart.Labels />
      </FunnelChart>
    </div>
  );
}

One stage in its own colour

color on a stage draws it at full strength instead of the fading hue. More layers give a deeper halo.

'use client';

import { FunnelChart, type FunnelDatum } from '@/components/panelui/funnel-chart';

const HIGHLIGHTED: FunnelDatum[] = [
  { label: 'Viewed', value: 41800 },
  { label: 'Basket', value: 18240 },
  { label: 'Address', value: 9420 },
  { label: 'Card', value: 6180 },
  { label: 'Paid', value: 5240, color: 'success' },
];

export default function FunnelChartGlow() {
  return (
    <div className="w-full max-w-lg">
      <FunnelChart data={HIGHLIGHTED} layers={5} height={230}>
        <FunnelChart.Header title="Order placed" value="5,240" caption="Paid, as a share of everyone who looked" />
        <FunnelChart.Stages />
        <FunnelChart.Labels />
      </FunnelChart>
    </div>
  );
}

Compact, with a key

Without Labels the run is a shape, so the numbers go in FunnelChart.Legend. Its rows read and pin a stage the same way the plot does.

'use client';

import { FunnelChart, type FunnelDatum } from '@/components/panelui/funnel-chart';

const SIGNUP: FunnelDatum[] = [
  { label: 'Visited', value: 12400 },
  { label: 'Signed up', value: 4310 },
  { label: 'Verified', value: 2980 },
  { label: 'Active', value: 1640 },
];

export default function FunnelChartCompact() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-6">
      <FunnelChart data={SIGNUP} height={120} gap={3}>
        <FunnelChart.Stages />
        <FunnelChart.Legend />
      </FunnelChart>
      <FunnelChart data={SIGNUP} height={120} gap={3}>
        <FunnelChart.Stages />
        <FunnelChart.Legend layout="inline" />
      </FunnelChart>
    </div>
  );
}

Loading, then data

Add FunnelChart.Skeleton and drive status. The stages grow in one after another when it turns ready.

'use client';

import { useState } from 'react';
import { Button } from '@/components/panelui/button';
import { FunnelChart, type FunnelChartStatus, type FunnelDatum } from '@/components/panelui/funnel-chart';

const CHECKOUT: FunnelDatum[] = [
  { label: 'Viewed', value: 41800 },
  { label: 'Basket', value: 18240 },
  { label: 'Checkout', value: 9420 },
  { label: 'Paid', value: 5240 },
];

export default function FunnelChartLoading() {
  const [status, setStatus] = useState<FunnelChartStatus>('loading');

  return (
    <div className="flex w-full max-w-lg flex-col items-start gap-4">
      <FunnelChart data={status === 'loading' ? [] : CHECKOUT} status={status}>
        <FunnelChart.Header title="Viewed" value={status === 'loading' ? 'None yet' : '41,800'} />
        <FunnelChart.Skeleton />
        <FunnelChart.Stages />
        <FunnelChart.Labels />
      </FunnelChart>
      <Button variant="outline" onClick={() => setStatus(status === 'loading' ? 'ready' : 'loading')}>
        {status === 'loading' ? 'Finish loading' : 'Load again'}
      </Button>
    </div>
  );
}

Changing data

When data changes, the ribbon moves to its new heights. A ref on the chart has replay(), which runs the entrance again.

'use client';

import { useRef, useState } from 'react';
import { Button } from '@/components/panelui/button';
import { FunnelChart, type FunnelChartHandle, type FunnelDatum } from '@/components/panelui/funnel-chart';

const PERIODS: Record<'This week' | 'Last week', FunnelDatum[]> = {
  'This week': [
    { label: 'Viewed', value: 41800 },
    { label: 'Basket', value: 18240 },
    { label: 'Checkout', value: 9420 },
    { label: 'Paid', value: 5240 },
  ],
  'Last week': [
    { label: 'Viewed', value: 36200 },
    { label: 'Basket', value: 21100 },
    { label: 'Checkout', value: 7300 },
    { label: 'Paid', value: 2900 },
  ],
};

export default function FunnelChartChangingData() {
  const chart = useRef<FunnelChartHandle>(null);
  const [period, setPeriod] = useState<keyof typeof PERIODS>('This week');

  return (
    <div className="flex w-full max-w-lg flex-col items-start gap-4">
      <FunnelChart ref={chart} data={PERIODS[period]} className="w-full">
        <FunnelChart.Header title={period} />
        <FunnelChart.Stages />
        <FunnelChart.Labels />
      </FunnelChart>
      <div className="flex gap-2">
        <Button variant="outline" onClick={() => setPeriod(period === 'This week' ? 'Last week' : 'This week')}>
          Show {period === 'This week' ? 'last week' : 'this week'}
        </Button>
        <Button variant="ghost" onClick={() => chart.current?.replay()}>
          Replay
        </Button>
      </div>
    </div>
  );
}

API Reference

FunnelChart

PropTypeDefaultDescription
datareadonly FunnelDatum[]—The steps, in the order they happen. Never reordered.
heightnumber200How tall the run is drawn, in pixels. The width is the container's, and nothing in the data says how deep the ribbon should taper.
stageSizenumber—How wide one stage is, in pixels. Left unset, the stages divide the width between them.
gapnumber4Space between one stage and the next, in pixels.
layersnumber3Concentric rings drawn per stage. 1 draws the band once, flat.
edgesFunnelEdges'curved'Whether the sides of a band are curves or straight diagonals.
minWidthnumber0.1The shortest a non-zero stage is drawn, as a share of the tallest. A genuine zero is still drawn as nothing.
colorFunnelChartColor'chart-1'The funnel's hue, as one of the theme's roles.
animationDurationnumber700Milliseconds for one stage to grow.
staggerDelaynumber90Milliseconds between one stage starting and the next. 0 for all at once.
statusFunnelChartStatus'ready'loading holds the stages back and grows them in when it turns ready.
activeIndexnumber—The stage being read. Leave unset to let the chart track it.
onActiveIndexChange(index: number, stage: FunnelDatum | null) => void—The stage being read, and -1/null when there is none. Fires when the index changes, not per frame.
labelstring—What a screen reader calls the chart.
labelForDatum(stage: FunnelDatum, index: number) => string—What a screen reader says for one stage. Defaults to its name, count and share of the first.

FunnelChart.Stages

PropTypeDefaultDescription
dimOpacitynumber0.3Opacity of the stages that are not being read, once one is.

FunnelChart.Skeleton

PropTypeDefaultDescription
colorFunnelChartColor—Which of the theme's roles to draw the placeholder in. Defaults to the theme's skeleton colour.

FunnelChart.Labels

PropTypeDefaultDescription
classNamestring—Added to each stage's column.
formatValue(value: number, stage: FunnelDatum) => string—Format the count. Defaults to a compact number.
formatShare(share: number, stage: FunnelDatum) => string—Format the conversion in the pill. Defaults to a whole percent.
shareFunnelShare'top'Which conversion the pill reports. top is the share of the first stage. previous is the share of the stage above, so the first stage has no pill.
showValuebooleantrueShow the count above the ribbon.
showLabelbooleantrueShow the stage's name under the ribbon.

FunnelChart.Legend

PropTypeDefaultDescription
layoutFunnelLegendLayout'list'list gives every stage a row, with the numbers in a column. inline runs them together and wraps, which is denser where the names are short.
showValuebooleantrueShow each stage's reading beside its name.
formatValue(value: number, stage: FunnelDatum) => string—Format the value in a list key. Defaults to a compact number.

FunnelChart.Header

PropTypeDefaultDescription
titleReactNode—Small line above the value: what the funnel is of.
valueReactNode—The readout. The largest thing on the card, and the first thing read.
captionReactNode—One muted line under the value: a period, a comparison, a caveat.

FunnelChart, FunnelChart.Header and FunnelChart.Legend also take the props of the element they render, and a className. FunnelChart.Labels puts its className on each stage's column. FunnelChart.Stages and FunnelChart.Skeleton draw nothing themselves and take only the props listed.

Notes

Sizing

The chart fills its container's width and takes its height from height, which applies to the run alone, so a header or a legend does not shrink it. The stages divide the width between them unless stageSize is set. Keep the stage names short: five stages at phone width leave each name about 70 pixels.

The plot draws in the browser after it has been measured, and the space is reserved until then, so it is not in server-rendered HTML.

Bundle size

The chart adds about 60 KB of gzipped JavaScript to a page that uses it. A page that does not import it pays nothing, including one that imports other components from the package root.

Data

Heights are measured against the largest value, so a stage larger than the one above it is drawn as given, wider than its parent. Check the data if you see one.

A non-zero stage is never drawn shorter than minWidth, a share of the tallest, so a very small stage stays visible. A stage of zero draws nothing. A negative value counts as zero.

Colour

color on the chart names a role in the theme, and the stages fade along the run in that one hue. color on a stage draws that stage at full strength.

Reading a stage

Hovering a column reads its stage, and the whole column is the target, so a thin stage is as easy to reach as a wide one. A click or a tap pins the reading until the next press elsewhere, or Escape. On a touch screen a sideways drag reads the stages it crosses, and a vertical drag scrolls the page.

onActiveIndexChange fires when the stage changes, not on every frame, so it can drive a readout outside the chart, as the first example does. Inside the chart, useFunnelChart gives the same stage with its shares. activeIndex makes the stage controlled.

Keyboard

The plot takes focus. The arrow keys move between stages, Home and End go to the first and last, Enter or Space pins the reading, and Escape clears it. FunnelChart.Legend rows are buttons in the tab order as well.

Screen readers

Each stage is given as text beside the drawing, and the stage being read is announced as it changes. label names the chart and labelForDatum writes a stage, for values with a unit. Without it a stage is read as its name, count and share of the first stage.

Reduced motion

With reduced motion set, the stages appear at their final heights.

Server Components

FunnelChart is a client component. In a file that starts with "use client", write its parts with a dot, as on this page. A Server Component cannot read FunnelChart.Header off the import, so each part is also exported by name: FunnelChartHeader is FunnelChart.Header, and so on for the others.

import { FunnelChart, FunnelChartHeader, FunnelChartStages, FunnelChartLabels, FunnelChartLegend, FunnelChartSkeleton } from "@/components/panelui/funnel-chart"

On this page