Funnel chart
Steps one population passes through, with the drop between each pair spelled out.
Server-compatible: no hooks, no client boundary, no recharts. The steps are ordered, so their colour is ordinal rather than categorical — one hue at rising strength, palest at the widest bar and strongest on the step that matters. A funnel is the wrong chart when the steps are not a sequence one population passes through: unrelated counts sorted by size are a bar chart, and a step that can be reached without the one above it is not a funnel step at all. Both readings are shown, because they answer different questions — the share of the first step beside each value, and the step-to-step rate between the bars, which is labelled "continue" so it cannot be read as its own opposite. Every value is on the page as text, so there is no hidden list to keep in sync.
Install
npx shadcn@latest add @vibra/funnel-chartNeeds the @vibra registry in your components.json — set it up once.
Examples
import { formatNumber } from "@/lib/format"
import { ChartCard } from "@/components/ui/chart-card"
import { FunnelChart } from "@/components/ui/funnel-chart"
const SIGNUP = [
{ label: "Visited pricing", value: 48210 },
{ label: "Started signup", value: 20240 },
{ label: "Verified email", value: 14180 },
{ label: "Created a project", value: 8940 },
{ label: "Invited a teammate", value: 4310 },
]
export default function FunnelChartDemo() {
return (
<ChartCard title="Signup funnel" description="Last 30 days" className="w-full">
<FunnelChart
steps={SIGNUP}
valueFormatter={(value) => formatNumber(value, { maximumFractionDigits: 0 })}
/>
</ChartCard>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| steps | { label: string; value: number; color?: ChartToken | string }[] | one hue at rising strength | The steps in order. A colour is a chart token or any CSS colour. |
| valueFormatter | (n: number) => string | whole number with separators | Formats every value the chart prints. |
| showConversion | boolean | true | Shows each step's share of the first, and the drop between neighbours. |
| orientation | "vertical" | "horizontal" | "vertical" | Bars stacked down the card, or columns across it for a wide frame. |
Dependencies
Registry
npm
Source
import * as React from "react"
import { ChevronDown, ChevronRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatNumber } from "@/lib/format"
import { CHART_BG, isChartToken, type ChartToken } from "@/components/ui/percentage-bar"
/**
* Every step as a percentage of the first, rounded to a tenth. A funnel that
* starts at nothing has nothing to convert, so the whole run reads zero rather
* than dividing by it.
*/
export function conversionRates(steps: { value: number }[]): number[] {
const first = steps[0]?.value ?? 0
if (first <= 0) return steps.map(() => 0)
return steps.map((step) => Math.round((step.value / first) * 1000) / 10)
}
// Funnel stages are ordered, so their colour is ordinal rather than
// categorical: one hue at rising strength, ending on the step that matters.
// The widest bar is the palest — a saturated block that size shouts, and the
// converted step is small enough to need the chroma.
const STEP_FILL = [
"bg-chart-1/45",
"bg-chart-1/55",
"bg-chart-1/65",
"bg-chart-1/78",
"bg-chart-1/88",
"bg-chart-1",
]
function stepPaint(color: ChartToken | string | undefined, index: number, count: number) {
if (color === undefined) {
const step = count < 2 ? STEP_FILL.length - 1 : Math.round((index / (count - 1)) * (STEP_FILL.length - 1))
return { className: STEP_FILL[step], style: undefined }
}
if (isChartToken(color)) return { className: CHART_BG[color], style: undefined }
return { className: undefined, style: { backgroundColor: color } as React.CSSProperties }
}
const DEFAULT_FORMAT = (value: number) => formatNumber(value, { maximumFractionDigits: 0 })
/** Rates print to a tenth so a run of steps lines up, e.g. "42.0%", never "42%". */
const rate = (percent: number) => `${percent.toFixed(1)}%`
export type FunnelStep = {
label: string
value: number
/** A chart token, or any CSS colour. Defaults to the ordinal ramp. */
color?: ChartToken | string
}
export type FunnelChartProps = React.ComponentProps<"div"> & {
steps: FunnelStep[]
valueFormatter?: (n: number) => string
/** Shows each step's share of the first, and the drop between neighbours. */
showConversion?: boolean
orientation?: "vertical" | "horizontal"
}
function FunnelChart({
className,
steps,
valueFormatter = DEFAULT_FORMAT,
showConversion = true,
orientation = "vertical",
"aria-label": ariaLabel,
...props
}: FunnelChartProps) {
const cumulative = conversionRates(steps)
const widest = steps.reduce((most, step) => Math.max(most, step.value), 0)
const rows = steps.map((step, i) => {
const previous = steps[i - 1]?.value ?? 0
return {
...step,
reading: valueFormatter(step.value),
share: widest > 0 ? (Math.max(0, step.value) / widest) * 100 : 0,
cumulative: cumulative[i],
// The step-to-step rate, which is the number a funnel is read for: not
// the share of the top of the funnel but how many of the people who got
// this far went on. Named for what it counts — the ones who continued —
// because "42% drop-off" and "42% continue" are opposite readings.
continued: i === 0 ? null : previous > 0 ? (step.value / previous) * 100 : 0,
paint: stepPaint(step.color, i, steps.length),
}
})
const summary =
ariaLabel ??
(rows.length === 0
? "Funnel with no steps."
: `Funnel of ${rows.length} steps, ${rows[0].reading} down to ${rows[rows.length - 1].reading}, ${rate(cumulative[cumulative.length - 1])} converted.`)
return (
<div
data-slot="funnel-chart"
data-orientation={orientation}
data-conversion={showConversion || undefined}
role="group"
aria-label={summary}
className={cn(
"w-full",
orientation === "vertical" ? "flex flex-col gap-1" : "flex items-stretch gap-1",
className
)}
{...props}
>
{rows.map((row, i) => (
<React.Fragment key={`${row.label}-${i}`}>
{i > 0 && showConversion ? (
<div
data-slot="funnel-chart-step-rate"
className={cn(
"flex items-center gap-0.5 text-xs text-muted-foreground tabular-nums",
orientation === "vertical" ? "justify-center py-0.5" : "self-center"
)}
>
{orientation === "vertical" ? (
<ChevronDown className="size-3" aria-hidden="true" />
) : (
<ChevronRight className="size-3" aria-hidden="true" />
)}
{`${rate(row.continued ?? 0)} continue`}
</div>
) : null}
<div
data-slot="funnel-chart-step"
className={cn(
"flex gap-1.5",
orientation === "vertical" ? "flex-col" : "min-w-0 flex-1 flex-col justify-end"
)}
>
{orientation === "vertical" ? (
<div className="flex items-baseline justify-between gap-3 text-sm">
<span className="truncate text-muted-foreground">{row.label}</span>
<span className="shrink-0 font-medium tabular-nums">
{row.reading}
{showConversion ? (
<span className="ps-2 font-normal text-muted-foreground">
{rate(row.cumulative)}
</span>
) : null}
</span>
</div>
) : null}
<div
data-slot="funnel-chart-track"
aria-hidden="true"
className={cn(
"flex",
orientation === "vertical" ? "h-7 justify-center" : "h-32 items-end"
)}
>
<div
data-slot="funnel-chart-bar"
className={cn("rounded-[2px]", row.paint.className)}
style={
orientation === "vertical"
? { width: `${row.share}%`, height: "100%", ...row.paint.style }
: { height: `${row.share}%`, width: "100%", ...row.paint.style }
}
/>
</div>
{orientation === "horizontal" ? (
<div className="flex min-w-0 flex-col gap-0.5 text-center">
<span className="truncate text-xs text-muted-foreground">{row.label}</span>
<span className="text-sm font-medium tabular-nums">{row.reading}</span>
{showConversion ? (
<span className="text-xs text-muted-foreground tabular-nums">
{rate(row.cumulative)}
</span>
) : null}
</div>
) : null}
</div>
</React.Fragment>
))}
</div>
)
}
export { FunnelChart }