Funnel 1: conversion funnel
A conversion funnel: bars sized against the first step, joined by connectors that narrow as people drop away, with share kept and people lost at each step, the biggest leak written out, a group switch and a panel for any chosen step.
The normal view. Pick a state to see what the block shows while it loads, is empty or fails.
// Ballmac UI: Funnel 1. https://ui.ballmac.com/blocks/funnel-1
"use client"
import * as React from "react"
import { ArrowDown, Filter, Lightbulb } from "lucide-react"
import { SegmentedControl, SegmentedControlItem } from "@/components/ballmac/segmented-control"
import { Skeleton } from "@/components/ballmac/skeleton"
import { frameProps, StateView, type StateProps } from "@/components/ballmac/state-view"
import { cn } from "@/lib/utils"
type Funnel1Step = { id: string; name: string; /** People who reached this step. */ count: number }
type Funnel1Segment = { id: string; label: string; steps: Funnel1Step[] }
type Funnel1Props = Omit<React.ComponentProps<"section">, "title"> &
StateProps & {
title?: string
description?: string
/** One list of steps per group of people (all, mobile, from ads). The first is shown at first. */
segments?: Funnel1Segment[]
/** "Last 30 days", already written for people. */
period?: string
locale?: string
/** The step whose details are open at first. */
defaultSelectedId?: string
}
const steps = (counts: number[]): Funnel1Step[] => ["Visited the site", "Viewed a product", "Added to cart", "Started checkout", "Paid"].map((name, i) => ({ id: `s${i}`, name, count: counts[i]! }))
const sample: Funnel1Segment[] = [
{ id: "all", label: "Everyone", steps: steps([48210, 29840, 11620, 6180, 4390]) },
{ id: "mobile", label: "Mobile", steps: steps([31470, 17450, 5980, 2540, 1480]) },
{ id: "desktop", label: "Desktop", steps: steps([16740, 12390, 5640, 3640, 2910]) },
]
function Skel() {
return (
<div aria-hidden="true" className="grid gap-5">
{[100, 70, 45, 28, 16].map((w, i) => (
<div key={i}>
<Skeleton className="h-4 w-40" />
<Skeleton className="mx-auto mt-3 h-12 rounded-xl" style={{ width: `${w}%` }} />
</div>
))}
</div>
)
}
/**
* A conversion funnel: each step as a bar sized against the first, joined by connectors that narrow as people drop away, with what share
* continued and how many were lost between steps. The biggest leak is found for you and written out as a sentence, a segment switch compares
* groups, and choosing a step shows exactly what happened there. Every figure is worked out from the counts.
*/
function Funnel1({
title = "Checkout funnel",
description = "Where people stop on the way from a first visit to a payment.",
segments = sample,
period = "Last 30 days",
locale = "en",
defaultSelectedId,
state = "ready",
onRetry,
emptyState,
errorState,
className,
...props
}: Funnel1Props) {
const id = React.useId()
const list = state === "empty" ? [] : segments
const [seg, setSeg] = React.useState(list[0]?.id ?? "")
const [pick, setPick] = React.useState<string | null>(defaultSelectedId ?? null)
const active = list.find((s) => s.id === seg) ?? list[0]
const n = (x: number) => new Intl.NumberFormat(locale).format(x)
const model = React.useMemo(() => {
if (!active?.steps.length) return null
const st = active.steps
const top = st[0]!.count || 1
const rows = st.map((s, i) => {
const prev = i ? st[i - 1]!.count : s.count
return { ...s, share: s.count / top, kept: prev ? s.count / prev : 0, lost: prev - s.count, i }
})
const leak = rows.slice(1).reduce((b, r) => (!b || r.lost / (rows[r.i - 1]!.count || 1) > b.lost / (rows[b.i - 1]!.count || 1) ? r : b), null as (typeof rows)[number] | null)
return { rows, leak, overall: st.at(-1)!.count / top }
}, [active])
const chosen = model?.rows.find((r) => r.id === pick)
const content = !model ? (
<div className="bg-card rounded-2xl border px-6 py-16 text-center">
<span aria-hidden="true" className="bg-muted mx-auto flex size-12 items-center justify-center rounded-full">
<Filter className="size-6" />
</span>
<h2 className="mt-5 text-lg font-semibold tracking-tight">{emptyState?.title ?? "No funnel data yet"}</h2>
<p className="text-muted-foreground mx-auto mt-1.5 max-w-xs text-sm text-pretty">{emptyState?.description ?? "Once visitors move through the steps, the funnel appears here."}</p>
{emptyState?.action}
</div>
) : (
<div className="grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]">
<div className="bg-card rounded-2xl border p-5 sm:p-8">
<div className="mb-8 flex flex-wrap items-center justify-between gap-4">
<div>
<p className="text-muted-foreground text-sm">Overall conversion</p>
<p className="text-4xl font-semibold tracking-[-0.04em] tabular-nums">
{(model.overall * 100).toFixed(model.overall < 0.1 ? 1 : 0)}%
<span className="text-muted-foreground ms-3 text-base font-normal tracking-normal">
{n(model.rows.at(-1)!.count)} of {n(model.rows[0]!.count)}
</span>
</p>
</div>
{list.length > 1 ? (
<SegmentedControl value={seg} onValueChange={(v) => { setSeg(v); setPick(null) }} aria-label="Group" size="sm">
{list.map((s) => (
<SegmentedControlItem key={s.id} value={s.id}>
{s.label}
</SegmentedControlItem>
))}
</SegmentedControl>
) : null}
</div>
<ol>
{model.rows.map((r, i) => {
const next = model.rows[i + 1]
const w = Math.max(r.share * 100, 6)
const nw = next ? Math.max(next.share * 100, 6) : 0
const isLeak = model.leak && next && model.leak.id === next.id
return (
<li key={r.id}>
<button
type="button"
aria-pressed={pick === r.id}
aria-label={`${r.name}: ${n(r.count)} people, ${(r.share * 100).toFixed(1)}% of the first step${r.i ? `, ${(r.kept * 100).toFixed(1)}% of the step before` : ""}`}
onClick={() => setPick(pick === r.id ? null : r.id)}
className="focus-visible:ring-ring/50 group block w-full rounded-xl text-start outline-none focus-visible:ring-[3px]"
>
<span aria-hidden="true" className="mb-2 flex items-baseline justify-between gap-3 text-sm">
<span className="font-medium">
<span className="text-muted-foreground me-2 tabular-nums">{r.i + 1}</span>
{r.name}
</span>
<span className="tabular-nums">
<span className="font-semibold">{n(r.count)}</span> <span className="text-muted-foreground">· {(r.share * 100).toFixed(r.share < 0.1 ? 1 : 0)}%</span>
</span>
</span>
<span aria-hidden="true" className="flex justify-center">
<span
className={cn("block h-12 rounded-xl transition-[width,filter] duration-700 ease-out group-hover:brightness-110 motion-reduce:transition-none", pick === r.id ? "ring-foreground ring-2 ring-offset-2" : "")}
style={{ width: `${w}%`, background: `color-mix(in oklab, var(--chart-1) ${100 - i * 14}%, var(--chart-2))` }}
/>
</span>
</button>
{next ? (
<div className="relative my-1 flex h-12 items-center justify-center" aria-hidden="true">
<span
className="absolute inset-y-0 start-1/2 -translate-x-1/2 rtl:translate-x-1/2"
style={{ width: `${w}%`, background: "color-mix(in oklab, var(--chart-1) 14%, transparent)", clipPath: `polygon(0 0, 100% 0, ${50 + nw / w * 50}% 100%, ${50 - nw / w * 50}% 100%)` }}
/>
<span className={cn("bg-card relative flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium tabular-nums", isLeak && "border-destructive/50 text-destructive")}>
<ArrowDown className="size-3" />
{n(next.lost)} lost · {((1 - next.kept) * 100).toFixed(0)}%
</span>
</div>
) : null}
</li>
)
})}
</ol>
</div>
<aside aria-label="Insights" className="grid gap-4 lg:sticky lg:top-6">
{model.leak ? (
<div className="bg-card rounded-2xl border p-5">
<h2 className="flex items-center gap-2 text-sm font-semibold">
<Lightbulb aria-hidden="true" className="size-4 text-amber-500" />
Biggest leak
</h2>
<p className="mt-3 text-pretty">
Between <strong>{model.rows[model.leak.i - 1]!.name}</strong> and <strong>{model.leak.name}</strong>, {((1 - model.leak.kept) * 100).toFixed(0)}% of people stop: {n(model.leak.lost)} {model.leak.lost === 1 ? "person" : "people"} in {period.toLowerCase()}.
</p>
<p className="text-muted-foreground mt-2 text-sm text-pretty">Winning back one in ten of them would bring in about {n(Math.round(model.leak.lost / 10 * model.rows.slice(model.leak.i).reduce((p, r, k, a) => (k ? p * (r.kept || 0) : p), 1)))} more at the end.</p>
</div>
) : null}
<div className="bg-card rounded-2xl border p-5" aria-live="polite">
{chosen ? (
<>
<h2 className="text-sm font-semibold">{chosen.name}</h2>
<dl className="mt-3 grid gap-2 text-sm">
<div className="flex justify-between gap-4"><dt className="text-muted-foreground">Reached it</dt><dd className="font-medium tabular-nums">{n(chosen.count)}</dd></div>
<div className="flex justify-between gap-4"><dt className="text-muted-foreground">Of the first step</dt><dd className="font-medium tabular-nums">{(chosen.share * 100).toFixed(1)}%</dd></div>
{chosen.i ? <div className="flex justify-between gap-4"><dt className="text-muted-foreground">Of the step before</dt><dd className="font-medium tabular-nums">{(chosen.kept * 100).toFixed(1)}%</dd></div> : null}
{chosen.i ? <div className="flex justify-between gap-4"><dt className="text-muted-foreground">Lost on the way in</dt><dd className="font-medium tabular-nums">{n(chosen.lost)}</dd></div> : null}
</dl>
</>
) : (
<p className="text-muted-foreground text-sm text-pretty">Choose a step to see what happened there.</p>
)}
</div>
</aside>
</div>
)
return (
<section data-slot="funnel-1" data-state={state} aria-labelledby={`${id}-title`} className={cn("mx-auto w-full max-w-5xl px-4 py-10 sm:px-6", className)} {...frameProps(props)}>
<header className="mb-8">
<p className="text-muted-foreground text-sm font-medium">{period}</p>
<h1 id={`${id}-title`} className="mt-1 text-3xl font-semibold tracking-[-0.035em] text-balance">
{title}
</h1>
<p className="text-muted-foreground mt-2 text-pretty">{description}</p>
</header>
<StateView state={state === "loading" || state === "error" ? state : "ready"} onRetry={onRetry} label="Loading the funnel" loading={<Skel />} error={{ title: errorState?.title ?? "The funnel could not be loaded", description: errorState?.description ?? "Check your connection and try again.", ...errorState }}>
{content}
</StateView>
</section>
)
}
export { Funnel1, type Funnel1Props, type Funnel1Segment, type Funnel1Step }Installation
$ pnpm dlx shadcn@latest add @ballmac/funnel-1Install the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/segmented-control @ballmac/skeleton @ballmac/state-viewCopy the source into your project.
components/ballmac/blocks/funnel-1/funnel-1.tsx// Ballmac UI: Funnel 1. https://ui.ballmac.com/blocks/funnel-1 "use client" import * as React from "react" import { ArrowDown, Filter, Lightbulb } from "lucide-react" import { SegmentedControl, SegmentedControlItem } from "@/components/ballmac/segmented-control" import { Skeleton } from "@/components/ballmac/skeleton" import { frameProps, StateView, type StateProps } from "@/components/ballmac/state-view" import { cn } from "@/lib/utils" type Funnel1Step = { id: string; name: string; /** People who reached this step. */ count: number } type Funnel1Segment = { id: string; label: string; steps: Funnel1Step[] } type Funnel1Props = Omit<React.ComponentProps<"section">, "title"> & StateProps & { title?: string description?: string /** One list of steps per group of people (all, mobile, from ads). The first is shown at first. */ segments?: Funnel1Segment[] /** "Last 30 days", already written for people. */ period?: string locale?: string /** The step whose details are open at first. */ defaultSelectedId?: string } const steps = (counts: number[]): Funnel1Step[] => ["Visited the site", "Viewed a product", "Added to cart", "Started checkout", "Paid"].map((name, i) => ({ id: `s${i}`, name, count: counts[i]! })) const sample: Funnel1Segment[] = [ { id: "all", label: "Everyone", steps: steps([48210, 29840, 11620, 6180, 4390]) }, { id: "mobile", label: "Mobile", steps: steps([31470, 17450, 5980, 2540, 1480]) }, { id: "desktop", label: "Desktop", steps: steps([16740, 12390, 5640, 3640, 2910]) }, ] function Skel() { return ( <div aria-hidden="true" className="grid gap-5"> {[100, 70, 45, 28, 16].map((w, i) => ( <div key={i}> <Skeleton className="h-4 w-40" /> <Skeleton className="mx-auto mt-3 h-12 rounded-xl" style={{ width: `${w}%` }} /> </div> ))} </div> ) } /** * A conversion funnel: each step as a bar sized against the first, joined by connectors that narrow as people drop away, with what share * continued and how many were lost between steps. The biggest leak is found for you and written out as a sentence, a segment switch compares * groups, and choosing a step shows exactly what happened there. Every figure is worked out from the counts. */ function Funnel1({ title = "Checkout funnel", description = "Where people stop on the way from a first visit to a payment.", segments = sample, period = "Last 30 days", locale = "en", defaultSelectedId, state = "ready", onRetry, emptyState, errorState, className, ...props }: Funnel1Props) { const id = React.useId() const list = state === "empty" ? [] : segments const [seg, setSeg] = React.useState(list[0]?.id ?? "") const [pick, setPick] = React.useState<string | null>(defaultSelectedId ?? null) const active = list.find((s) => s.id === seg) ?? list[0] const n = (x: number) => new Intl.NumberFormat(locale).format(x) const model = React.useMemo(() => { if (!active?.steps.length) return null const st = active.steps const top = st[0]!.count || 1 const rows = st.map((s, i) => { const prev = i ? st[i - 1]!.count : s.count return { ...s, share: s.count / top, kept: prev ? s.count / prev : 0, lost: prev - s.count, i } }) const leak = rows.slice(1).reduce((b, r) => (!b || r.lost / (rows[r.i - 1]!.count || 1) > b.lost / (rows[b.i - 1]!.count || 1) ? r : b), null as (typeof rows)[number] | null) return { rows, leak, overall: st.at(-1)!.count / top } }, [active]) const chosen = model?.rows.find((r) => r.id === pick) const content = !model ? ( <div className="bg-card rounded-2xl border px-6 py-16 text-center"> <span aria-hidden="true" className="bg-muted mx-auto flex size-12 items-center justify-center rounded-full"> <Filter className="size-6" /> </span> <h2 className="mt-5 text-lg font-semibold tracking-tight">{emptyState?.title ?? "No funnel data yet"}</h2> <p className="text-muted-foreground mx-auto mt-1.5 max-w-xs text-sm text-pretty">{emptyState?.description ?? "Once visitors move through the steps, the funnel appears here."}</p> {emptyState?.action} </div> ) : ( <div className="grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]"> <div className="bg-card rounded-2xl border p-5 sm:p-8"> <div className="mb-8 flex flex-wrap items-center justify-between gap-4"> <div> <p className="text-muted-foreground text-sm">Overall conversion</p> <p className="text-4xl font-semibold tracking-[-0.04em] tabular-nums"> {(model.overall * 100).toFixed(model.overall < 0.1 ? 1 : 0)}% <span className="text-muted-foreground ms-3 text-base font-normal tracking-normal"> {n(model.rows.at(-1)!.count)} of {n(model.rows[0]!.count)} </span> </p> </div> {list.length > 1 ? ( <SegmentedControl value={seg} onValueChange={(v) => { setSeg(v); setPick(null) }} aria-label="Group" size="sm"> {list.map((s) => ( <SegmentedControlItem key={s.id} value={s.id}> {s.label} </SegmentedControlItem> ))} </SegmentedControl> ) : null} </div> <ol> {model.rows.map((r, i) => { const next = model.rows[i + 1] const w = Math.max(r.share * 100, 6) const nw = next ? Math.max(next.share * 100, 6) : 0 const isLeak = model.leak && next && model.leak.id === next.id return ( <li key={r.id}> <button type="button" aria-pressed={pick === r.id} aria-label={`${r.name}: ${n(r.count)} people, ${(r.share * 100).toFixed(1)}% of the first step${r.i ? `, ${(r.kept * 100).toFixed(1)}% of the step before` : ""}`} onClick={() => setPick(pick === r.id ? null : r.id)} className="focus-visible:ring-ring/50 group block w-full rounded-xl text-start outline-none focus-visible:ring-[3px]" > <span aria-hidden="true" className="mb-2 flex items-baseline justify-between gap-3 text-sm"> <span className="font-medium"> <span className="text-muted-foreground me-2 tabular-nums">{r.i + 1}</span> {r.name} </span> <span className="tabular-nums"> <span className="font-semibold">{n(r.count)}</span> <span className="text-muted-foreground">· {(r.share * 100).toFixed(r.share < 0.1 ? 1 : 0)}%</span> </span> </span> <span aria-hidden="true" className="flex justify-center"> <span className={cn("block h-12 rounded-xl transition-[width,filter] duration-700 ease-out group-hover:brightness-110 motion-reduce:transition-none", pick === r.id ? "ring-foreground ring-2 ring-offset-2" : "")} style={{ width: `${w}%`, background: `color-mix(in oklab, var(--chart-1) ${100 - i * 14}%, var(--chart-2))` }} /> </span> </button> {next ? ( <div className="relative my-1 flex h-12 items-center justify-center" aria-hidden="true"> <span className="absolute inset-y-0 start-1/2 -translate-x-1/2 rtl:translate-x-1/2" style={{ width: `${w}%`, background: "color-mix(in oklab, var(--chart-1) 14%, transparent)", clipPath: `polygon(0 0, 100% 0, ${50 + nw / w * 50}% 100%, ${50 - nw / w * 50}% 100%)` }} /> <span className={cn("bg-card relative flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium tabular-nums", isLeak && "border-destructive/50 text-destructive")}> <ArrowDown className="size-3" /> {n(next.lost)} lost · {((1 - next.kept) * 100).toFixed(0)}% </span> </div> ) : null} </li> ) })} </ol> </div> <aside aria-label="Insights" className="grid gap-4 lg:sticky lg:top-6"> {model.leak ? ( <div className="bg-card rounded-2xl border p-5"> <h2 className="flex items-center gap-2 text-sm font-semibold"> <Lightbulb aria-hidden="true" className="size-4 text-amber-500" /> Biggest leak </h2> <p className="mt-3 text-pretty"> Between <strong>{model.rows[model.leak.i - 1]!.name}</strong> and <strong>{model.leak.name}</strong>, {((1 - model.leak.kept) * 100).toFixed(0)}% of people stop: {n(model.leak.lost)} {model.leak.lost === 1 ? "person" : "people"} in {period.toLowerCase()}. </p> <p className="text-muted-foreground mt-2 text-sm text-pretty">Winning back one in ten of them would bring in about {n(Math.round(model.leak.lost / 10 * model.rows.slice(model.leak.i).reduce((p, r, k, a) => (k ? p * (r.kept || 0) : p), 1)))} more at the end.</p> </div> ) : null} <div className="bg-card rounded-2xl border p-5" aria-live="polite"> {chosen ? ( <> <h2 className="text-sm font-semibold">{chosen.name}</h2> <dl className="mt-3 grid gap-2 text-sm"> <div className="flex justify-between gap-4"><dt className="text-muted-foreground">Reached it</dt><dd className="font-medium tabular-nums">{n(chosen.count)}</dd></div> <div className="flex justify-between gap-4"><dt className="text-muted-foreground">Of the first step</dt><dd className="font-medium tabular-nums">{(chosen.share * 100).toFixed(1)}%</dd></div> {chosen.i ? <div className="flex justify-between gap-4"><dt className="text-muted-foreground">Of the step before</dt><dd className="font-medium tabular-nums">{(chosen.kept * 100).toFixed(1)}%</dd></div> : null} {chosen.i ? <div className="flex justify-between gap-4"><dt className="text-muted-foreground">Lost on the way in</dt><dd className="font-medium tabular-nums">{n(chosen.lost)}</dd></div> : null} </dl> </> ) : ( <p className="text-muted-foreground text-sm text-pretty">Choose a step to see what happened there.</p> )} </div> </aside> </div> ) return ( <section data-slot="funnel-1" data-state={state} aria-labelledby={`${id}-title`} className={cn("mx-auto w-full max-w-5xl px-4 py-10 sm:px-6", className)} {...frameProps(props)}> <header className="mb-8"> <p className="text-muted-foreground text-sm font-medium">{period}</p> <h1 id={`${id}-title`} className="mt-1 text-3xl font-semibold tracking-[-0.035em] text-balance"> {title} </h1> <p className="text-muted-foreground mt-2 text-pretty">{description}</p> </header> <StateView state={state === "loading" || state === "error" ? state : "ready"} onRetry={onRetry} label="Loading the funnel" loading={<Skel />} error={{ title: errorState?.title ?? "The funnel could not be loaded", description: errorState?.description ?? "Check your connection and try again.", ...errorState }}> {content} </StateView> </section> ) } export { Funnel1, type Funnel1Props, type Funnel1Segment, type Funnel1Step }Update the import paths to match your project setup.
Usage
"use client"
import { Funnel1 } from "@/components/ballmac/blocks/funnel-1/funnel-1"
export default function Funnel1Demo() {
return <Funnel1 />
}States
Pass state and the block does the rest: skeletons that match its layout, a clear empty message, an error you can retry, and announcements for screen readers.
Loading
Skeleton bars that narrow like the real funnel, under the real heading, announced as loading.
state="loading"No data yet
Says the funnel appears once visitors move through the steps; emptyState sets the copy.
state="empty" (or segments={[]})Could not load
An alert with Try again (onRetry).
state="error"
Quality scorecard
Every applicable check passes
Measured in Chromium 153.0.8010.12 on October 19, 2026, on version 1.0.0, across 5 examples.
Passed: Accessibility scan
No serious or critical issues in light and dark, on a phone and a desktop (axe-core 4.13.0, WCAG 2.1 AA and best-practice rules).
Passed: Small screens
No sideways scrolling at 360, 390 or 1280 px wide.
Passed: Reduced motion
Every endless animation stops when the visitor asks for less motion.
Passed: Right to left
Mirrors correctly in Arabic, Hebrew, Persian and Urdu layouts (7% of elements off the mirror, limit 15%).
Passed: Clean console
No console errors or React hydration errors while loading.
Passed: Keyboard
11 tab stops, each showing a visible change when it takes focus. 3 documented keys.
Passed: Loading, empty and error
Ships loading, empty, error states, each with an example.
Passed: Automated tests
Covered by funnel-1.test.tsx.
For information: Footprint
3.9 KB of source (gzip) in 1 file. Installs lucide-react.
Automated checks find a subset of problems and do not replace testing with a screen reader and a keyboard in your own app. What they miss.
Dependencies
- npm
- lucide-react
Use with AI
A conversion funnel. <Funnel1 title description period='Last 30 days' segments={[{ id, label, steps: [{ id, name, count }] }]} locale />. Counts must be people who reached each step, first to last. Share of the first step, share kept from the step before, people lost, overall conversion and the biggest leak (the largest proportional drop) are worked out. A segment switch shows when there are two or more groups. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Funnel 1: conversion funnel (@ballmac/funnel-1) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Analytics for signup, onboarding or checkout flows
- Showing where a growth experiment should focus
Not for
- Results of an A/B test (use experiments-1)
- Trends over time (use a chart)
Registry JSON: https://ui.ballmac.com/r/funnel-1.json
Free to use in personal and commercial projects.
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.