A funnel as bars that narrow step by step. Between steps it says how many carried on and how many dropped out, flags the biggest drop, and ends with the overall result as a sentence.
Visit to purchase, last 30 days
41.1% carried on, 28,400 dropped outBiggest drop
20.8% carried on, 15,680 dropped out
33.6% carried on, 2,735 dropped out
2.9% of everyone who started reached the end (1,385 of 48,200).
"use client"
import * as React from "react"
import { FunnelChart } from "@/components/ballmac/funnel-chart"
const steps = [
{ id: "visit", label: "Visited the site", value: 48200, hint: "Unique visitors" },
{ id: "pricing", label: "Viewed pricing", value: 19800, hint: "Opened /pricing" },
{ id: "start", label: "Started checkout", value: 4120 },
{ id: "pay", label: "Paid", value: 1385, hint: "Completed payment" },
]
export default function FunnelChartDemo() {
const [sel, setSel] = React.useState<string | null>(null)
return (
<div className="w-full max-w-xl">
<FunnelChart title="Visit to purchase, last 30 days" steps={steps} selected={sel} onSelect={setSel} />
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/funnel-chartInstall the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/i18nCopy the source into your project.
components/ballmac/funnel-chart.tsx// Ballmac UI: Funnel Chart. https://ui.ballmac.com/components/funnel-chart "use client" import * as React from "react" import { ArrowDown } from "lucide-react" import { cn } from "@/lib/utils" import { useMessages } from "@/lib/ballmac/i18n" type FunnelStep = { id: string label: string /** How many reached this step. */ value: number /** A note under the label, for example "Viewed the pricing page". */ hint?: string } type FunnelChartProps = Omit<React.ComponentProps<"div">, "onSelect" | "title"> & { steps: FunnelStep[] title?: string format?: (value: number) => string /** The selected step. Controlled. */ selected?: string | null onSelect?: (id: string | null) => void } const nf = new Intl.NumberFormat("en-US") const pct = (n: number) => `${Math.round(n * 1000) / 10}%` /** * A funnel as a stack of bars that narrow step by step. Between each pair it says how many carried on and how many dropped out, in words, and the biggest drop is * flagged. Steps are buttons that report a selection, and a table version is there for screen readers. */ function FunnelChart({ steps, title, format = (v) => nf.format(v), selected, onSelect, className, ...props }: FunnelChartProps) { const msg = useMessages() const id = React.useId() const first = steps[0]?.value || 1 let worst = -1 let worstDrop = 0 steps.forEach((s, i) => { if (i === 0) return const drop = steps[i - 1].value - s.value if (drop > worstDrop) { worstDrop = drop worst = i } }) const last = steps[steps.length - 1] return ( <div data-slot="funnel-chart" className={cn("grid min-w-0 gap-3", className)} {...props}> {title && <h3 id={`${id}-t`} className="text-sm font-medium">{title}</h3>} <ol aria-labelledby={title ? `${id}-t` : undefined} className="grid grid-cols-[minmax(0,1fr)] gap-0"> {steps.map((s, i) => { const prev = steps[i - 1] const w = Math.max(6, (s.value / first) * 100) const on = selected === s.id const kept = prev && prev.value > 0 ? s.value / prev.value : 1 const content = ( <> <span className="grid min-w-0 gap-0.5"> <span className="flex items-baseline gap-2"> <span className="text-xs font-medium tabular-nums text-muted-foreground">{i + 1}</span> <span className="truncate text-sm font-medium">{s.label}</span> </span> {s.hint && <span className="truncate ps-4 text-xs text-muted-foreground">{s.hint}</span>} </span> <span className="text-end"> <span className="block text-lg font-semibold tracking-[-0.02em] tabular-nums">{format(s.value)}</span> <span className="block text-xs text-muted-foreground tabular-nums">{msg("funnel-chart.ofFirst", "{percent} of the start", { percent: pct(s.value / first) })}</span> </span> </> ) return ( <li key={s.id} className="min-w-0"> {prev && ( <p className={cn("flex flex-wrap items-center gap-x-1.5 gap-y-1 py-1.5 ps-4 text-xs", i === worst ? "font-medium text-foreground" : "text-muted-foreground")}> <ArrowDown aria-hidden="true" className="size-3" /> {msg("funnel-chart.kept", "{kept} carried on, {lost} dropped out", { kept: pct(kept), lost: format(prev.value - s.value) })} {i === worst && <span className="rounded-full border border-foreground px-1.5 py-px text-[11px] whitespace-nowrap">{msg("funnel-chart.biggest", "Biggest drop")}</span>} </p> )} <div className="relative"> <span aria-hidden="true" className={cn("absolute inset-y-0 start-0 overflow-hidden rounded-lg transition-[width] duration-500 motion-reduce:transition-none", on ? "bg-foreground/20" : "bg-foreground/[0.07]")} style={{ width: `${w}%` }}> <span className="absolute inset-x-0 bottom-0 h-0.5 bg-foreground" /> </span> {onSelect ? ( <button type="button" aria-pressed={on} onClick={() => onSelect(on ? null : s.id)} className="relative flex min-h-14 w-full items-center justify-between gap-4 rounded-lg px-4 py-2 text-start outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"> {content} </button> ) : ( <div className="relative flex min-h-14 items-center justify-between gap-4 px-4 py-2">{content}</div> )} </div> </li> ) })} </ol> {last && steps.length > 1 && ( <p className="border-t pt-3 text-sm text-muted-foreground"> {msg("funnel-chart.overall", "{percent} of everyone who started reached the end ({value} of {first}).", { percent: pct(last.value / first), value: format(last.value), first: format(first) })} </p> )} </div> ) } export { FunnelChart, type FunnelChartProps, type FunnelStep }Update the import paths to match your project setup.
Usage
import { FunnelChart } from "@/components/ballmac/funnel-chart"The full example is in the Code tab above.
Examples
Three steps
- 1Invited200100% of the start
70% carried on, 60 dropped outBiggest drop
2Signed up14070% of the start90% carried on, 14 dropped out
3Active12663% of the start
63% of everyone who started reached the end (126 of 200).
"use client"
import { FunnelChart } from "@/components/ballmac/funnel-chart"
export default function FunnelChartStatic() {
return (
<div className="w-full max-w-md">
<FunnelChart steps={[{ id: "a", label: "Invited", value: 200 }, { id: "b", label: "Signed up", value: 140 }, { id: "c", label: "Active", value: 126 }]} />
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
steps* | FunnelStep[] | — |
title | string | — |
format | (value: number) => string | (v) => nf.format(v) |
selectedThe selected step. Controlled. | string | null | — |
onSelect | (id: string | null) => void | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Steps | An ordered list; drop-off and conversion are text, never only bar length |
| Selection | Optional buttons with pressed state |
| Biggest drop | Tagged with the words 'Biggest drop' |
Use with AI
steps is [{ id, label, value, hint? }] from first to last. title, format(value). Between steps: '40% carried on, 600 dropped out', and the biggest drop is tagged in words. onSelect(id | null) with selected makes steps toggle buttons. Ends with 'X% of everyone who started reached the end'. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Funnel Chart (@ballmac/funnel-chart) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Checkout, signup or onboarding conversion
- Any process where people drop out between steps
Not for
- Steps that can happen in any order; use bar-list
- Flow with branches; use flow-sankey
Registry JSON: https://ui.ballmac.com/r/funnel-chart.json
Credits
Free to use in personal and commercial projects.
- npm
- lucide-react
- Registry
- @ballmac/i18nshadcn/utils
Pairs well with
Bar List
A ranked list of bars with the name and number on top of the bar, so both stay readable at any width. Rows can be links or buttons, a long list folds to its top few, and every value is real text.
Stat Card
A metric card with a clear comparison trend, optional visual, and readable value hierarchy.
KPI Row
A compact, semantic row of key metrics that adapts from one to four columns.
Flow Sankey
A dependency-free flow diagram: columns placed by the longest chain, crossings reduced, links as thick as their value, nodes as buttons that light up their flows, a detail line, and the same flows as a table for screen readers.
Ballmac UI Pro
Need the whole screen, not one piece?
150 premium blocks (dashboards, settings, calendars, mail, shops, onboarding), a private shadcn registry and Figma tokens. Blocks that show data come with loading, empty and error states built in.
Not ready? Try three Pro blocks free
Leave your email and the blocks appear right here, ready to copy. Yours to use in any project.