FunnelChart
Where a population drained away, one step at a time.
Where a population drained away, one step at a time.
Every stage is a subset of the one above it, in the order the steps happen. That is a much stronger claim than a bar chart makes, and it is what makes the interesting number not any stage’s value but the ratio between two of them: how many of the people who started checking out entered a card, and how many of everyone who looked ended up paying.
It is drawn as one ribbon running across the card rather than a stack of blocks — the stages divide the width between them, and each band is as tall as its value where it starts and as tall as the next stage’s where it ends, with sides curved so consecutive bands meet flush and the run reads as a single narrowing channel.
Installation
FunnelChart ships with the library — no separate install.
import { FunnelChart, type FunnelDatum, Frame } from 'panelui-native';Or copy the source into your project, to own and edit it:
npx panelui-cli@latest add funnel-chartUsage
<FunnelChart data={checkout}>
<FunnelChart.Header title="Product viewed" value="41,800" />
<FunnelChart.Stages />
<FunnelChart.Labels />
</FunnelChart>Composition
<FunnelChart data={…}>
<FunnelChart.Header /> {/* the strip above the run */}
<FunnelChart.Skeleton /> {/* the plain ribbon it waits behind */}
<FunnelChart.Stages /> {/* the ribbon itself */}
<FunnelChart.Labels /> {/* the count, the pill and the name, per stage */}
<FunnelChart.Legend /> {/* a key underneath, for a chart without labels */}
</FunnelChart>FunnelChart.Header— The strip above the run — what the funnel is of, what it reads, and room for a control. The chart introducing itself, as distinct from the caption on the card around it.FunnelChart.Stages— The ribbon. One part rather than one per stage: a stage’s near edge is the previous stage’s far edge, so they cannot be configured apart without the shape coming apart with them. Each band is drawn concentrically, from a wide faint ring to a tight near-solid core.FunnelChart.Labels— The readings, arranged around the ribbon: the count above the band, the name under it, and the conversion in a pill on the band itself. Three places rather than one line, because a name, a count and a percentage sharing a row make a row as wide as all three — and at the width a phone has, it is the name that gives way.FunnelChart.Legend— A swatch, a name and a reading per stage, under the run. For a compact chart drawn withoutLabels, where the run is a shape and the reading is underneath it. A row each by default, because the stages are a sequence and a wrapped centred line loses the order.FunnelChart.Skeleton— The waiting state: one plain ribbon over the whole run, undivided. A placeholder split would be an invented drop-off, and nobody can tell an invented one from a real one until it changes under them.
Examples
The data
Steps in the order they happen, first one first. The order is yours and never the chart’s — stages are a process, and sorting them by size would destroy the only thing the chart is asserting.
Keep the names short. A stage gets a column of the card’s width and no more, so five across a phone is about seventy points each, and a name that does not fit under one is a name the reader never gets to read.
const checkout: FunnelDatum[] = [
{ label: 'Viewed', value: 41800 },
{ label: 'Basket', value: 18240 },
{ label: 'Checkout', value: 9420 },
{ label: 'Payment', value: 6180 },
{ label: 'Paid', value: 5240 },
];How deep it tapers
height is the one measurement the data cannot supply. The run is as wide as the card and the stages divide that between them, but nothing in a set of counts says how far the ribbon should taper through — so it is a decision. Everything else follows from it: the count and the name each take a fixed share of the height at the top and bottom, and the ribbon is centred in what is left.
<FunnelChart data={checkout} height={240}>
<FunnelChart.Stages />
<FunnelChart.Labels />
</FunnelChart>Which conversion the pill reports
Two true readings of one run, answering different questions. top is the share of the first stage — the default, because every stage has one and they read along the run as a single falling series. previous is the drop from the stage above, which is the step-by-step reading; the first stage has nothing above it, so it carries no pill.
<FunnelChart data={checkout}>
<FunnelChart.Stages />
<FunnelChart.Labels share="previous" />
</FunnelChart>Rings, and drawing the shape flat
layers is how many concentric rings each band is drawn as: tall and faint on the outside through to a tight near-solid core, which is what gives the ribbon an edge that falls off rather than stopping dead. Raise it and the shape reads as light; layers={1} draws it once, flat. edges="straight" replaces the curved sides with plain diagonals, which is the honest shape for a drop worth seeing as a corner.
<FunnelChart data={pipeline} layers={1} edges="straight">
<FunnelChart.Stages />
<FunnelChart.Labels />
</FunnelChart>Pulling one stage out of the fade
The run is one hue that fades along it, because the stages are one quantity at successive moments. A color on a single stage takes it out of that fade and draws it at full strength — for the step where the money is taken, or the one the report is about. A colour per stage would say they were unrelated series.
const data = checkout.map((stage) =>
stage.label === 'Paid' ? { ...stage, color: '#34d399' } : stage
);Selecting a stage
Press a band, its column, or its entry in the legend, and its rings spread while the others dim. Pressing it again clears the selection. Control it from outside with activeIndex and onActiveIndexChange when the header — or anything else on the screen — has to stay in step.
const [active, setActive] = useState(-1);
const stage = active >= 0 ? checkout[active] : null;
<FunnelChart data={checkout} activeIndex={active} onActiveIndexChange={setActive}>
<FunnelChart.Header
title={stage ? stage.label : 'Everyone who looked'}
value={(stage ?? checkout[0]).value.toLocaleString()}
/>
<FunnelChart.Stages />
<FunnelChart.Labels />
</FunnelChart>A hard drop-off
Eleven hires out of twelve hundred applications. At true height the last two stages are hairlines, and a hairline reads as nothing happening rather than as something rare — minWidth is the floor that keeps them visible, applied only to stages that have something in them.
<FunnelChart data={pipeline} minWidth={0.16} edges="straight" layers={1}>
<FunnelChart.Stages />
<FunnelChart.Labels />
</FunnelChart>Compact, with a key instead of labels
For a dashboard tile where the funnel is one of six things on the screen and a reading around every stage would be more text than tile. The key is a row per stage by default; layout="inline" runs them together and wraps, where the names are short enough for it.
<FunnelChart data={signup} height={120} gap={3}>
<FunnelChart.Stages />
<FunnelChart.Legend />
</FunnelChart>While the numbers are still coming
status="loading" draws the ribbon and nothing else. An empty chart in that state still reserves the run’s height and a plausible number of stages, so the card does not jump when the data lands.
<FunnelChart data={ready ? checkout : []} status={ready ? 'ready' : 'loading'}>
<FunnelChart.Skeleton />
<FunnelChart.Stages />
<FunnelChart.Labels />
</FunnelChart>Versions
Basic
Five steps of a checkout, each labelled with what it converted at.
<FunnelChart data={checkout}>
<FunnelChart.Header title="Product viewed" value="41,800" caption="13% of them placed an order" />
<FunnelChart.Stages />
<FunnelChart.Labels />
</FunnelChart>Conversion
Each step measured against the one above it, with both readings in the header as one is selected.
<FunnelChart data={checkout} gap={6} activeIndex={active} onActiveIndexChange={setActive}>
<FunnelChart.Header title={stage?.label ?? 'Everyone who looked'} value={value} caption={caption} />
<FunnelChart.Stages />
<FunnelChart.Labels share="previous" />
</FunnelChart>Pipeline
A hard drop-off, drawn flat, with a floor under the stages too small to see.
<FunnelChart data={pipeline} minWidth={0.16} edges="straight" layers={1}>
<FunnelChart.Header title="Applications" value="1,240" caption="11 hires, from 1,240 applications" />
<FunnelChart.Stages />
<FunnelChart.Labels />
</FunnelChart>Glow
A deeper halo, and one stage pulled out of the fade into its own colour.
<FunnelChart data={highlighted} layers={5} height={230}>
<FunnelChart.Header title="Order placed" value="5,240" caption="The one stage worth its own colour" />
<FunnelChart.Stages />
<FunnelChart.Labels />
</FunnelChart>Compact
The run as a shape, with the reading in a key underneath it.
<FunnelChart data={signup} height={120} gap={3}>
<FunnelChart.Stages />
<FunnelChart.Legend />
</FunnelChart>Loading
One plain ribbon while it waits, because an invented drop-off is a lie.
<FunnelChart data={status === 'loading' ? [] : checkout} status={status}>
<FunnelChart.Header title="Product viewed" value={status === 'loading' ? '—' : '41,800'} />
<FunnelChart.Skeleton />
<FunnelChart.Stages />
<FunnelChart.Labels />
</FunnelChart>API Reference
FunnelChart
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | |
data | FunnelDatum[] | — | The steps, in the order they happen. Never reordered. |
height | number | DEFAULT_HEIGHT | How tall the run is drawn, in points. The run is as wide as it is given and as deep as this: the width is the card's, but nothing in the data says how far the ribbon should taper through, so it is a decision rather than a measurement. |
stageSize | number | — | How wide one stage is, in points. Left unset the stages divide the width between them, which is nearly always what a run across a card wants. Worth setting only to make a run stop short of the edge. |
gap | number | 4 | Space between one stage and the next, in points. |
layers | number | DEFAULT_LAYERS | Concentric rings drawn per stage, faint and wide on the outside through to a near-solid core. 1 draws the band once, flat. |
edges | FunnelEdges | 'curved' | Whether the sides of a band are curves or straight diagonals. |
minWidth | number | 0.1 | The shortest a non-zero stage is drawn, as a share of the tallest. A stage worth a fifth of a percent of the first is a hairline: it reads as missing rather than as small, and "missing" is a different claim. The floor is only applied to stages that have something in them — a genuine zero is drawn as nothing, because there it is the truth. |
color | string | — | The funnel's hue. Defaults to the first chart token. |
animationDuration | number | 700 | Milliseconds for one stage to grow. |
staggerDelay | number | STAGGER | Milliseconds between one stage starting and the next. 0 for all at once. |
status | FunnelChartStatus | 'ready' | loading draws one plain muted ribbon until the data arrives. |
activeIndex | number | — | Selected stage. Leave unset to let the chart track it. |
onActiveIndexChange | (index: number) => void | — | Fires with the selected stage, or -1 when the selection is cleared. |
FunnelChart.Stages
| Prop | Type | Default | Description |
|---|---|---|---|
dimOpacity | number | — | Opacity of the stages that are not selected, once one is. |
FunnelChart.Skeleton
| Prop | Type | Default | Description |
|---|---|---|---|
color | string | — |
FunnelChart.Labels
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | |
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. |
share | FunnelShare | 'top' | Which conversion the pill reports. top is the share of the first stage, which every stage has and which reads along the run as one falling series. previous is the drop from the stage above — the step-by-step reading, where the first stage has nothing above it and so carries no pill. |
showValue | boolean | true | Show the count above the ribbon. |
showLabel | boolean | true | Show the stage's name under the ribbon. |
FunnelChart.Legend
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | |
layout | FunnelLegendLayout | 'list' | list gives every stage a row of its own, with the names down one column and the numbers down another. inline runs them together across the width and wraps, which is the denser arrangement where the names are short. |
showValue | boolean | true | Show 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
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | |
title | string | — | Small line above the value — what the funnel is of. |
value | string | — | The readout. The largest thing on the card, and the first thing read. |
caption | string | — | One muted line under the value — a period, a comparison, a caveat. |
Every part also accepts the underlying React Native props (ViewProps or TextProps) and a className for Tailwind utilities.
Notes
Why the readings are split three ways
A stage has three things worth saying about it — what it is called, how many were left at it, and what it converted at — and putting all three on one line makes that line as wide as all three together. At the width a phone actually has, something has to give, and it is always the name: a reader is left with “Checkout st…” next to a number that means nothing without it.
So the count goes above the band, the name under it, and the conversion in a pill on the band itself. Each has the stage’s whole column to itself. The two text strips take a fixed share of the height and the ribbon takes the rest, centred in it, so the tallest stage reaches exactly to the words at both ends — no strip of nothing between the shape and the text, and no shape creeping under it.
The pill is filled rather than bare text for the same reason: it is the one reading that sits over the shape, where the fill behind it is the same token family the text would be drawn in. Punched out of its own background, it reads whatever the band is doing underneath.
One hue, not five
The stages are drawn in a single colour that fades along the run. They are one quantity at successive moments, not five unrelated series, and five hues would say they were — the reader would start looking for what “the green one” means. Set color on the chart to change the hue, or color on a single stage to pull it out of the fade at full strength.
A stage larger than its parent
Heights are measured against the largest value in the run rather than against the first. In a well-formed funnel those are the same number. Where they are not — a stage that somehow counted more people than the step before it — the stage is drawn as given, taller than its parent, because that is a real data problem and a chart that quietly clamped it would be hiding the one thing worth seeing.
When not to reach for it
A funnel asserts that each stage is a subset of the one above it. If that is not true of your data — categories that merely happen to be sorted, quantities measured at the same moment, anything a reader might want to compare exactly — it is a bar chart, where the comparison is a length and lengths are read exactly. Four to six stages is the range a funnel reads well in; past that the columns are too narrow to name and the lower stages are all floor.