Shadcn Funnel Chart

Show how many people make it through each step, with the conversion rate and drop-off between stages, as a connected funnel or rounded bars.

CSV, TSV from Excel/Sheets, a Markdown table, or JSON.

CSV
Install with the shadcn CLIOne command adds this chart and its dependencies to your project.
Shape
Conversion
Colors
Show
Code
  1. Visited site
    12,400100%
  2. Viewed pricing
    6,80054.8%−45.2%
  3. Started trial
    3,10045.6%−54.4%
  4. Activated
    1,90061.3%−38.7%
  5. Paid
    86045.3%−54.7%
% of previous stageOverall conversion 6.9%
Image
chart.tsx
"use client"

import { useState } from "react"

const data = [
  { Stage: "Visited site", Users: 12400 },
  { Stage: "Viewed pricing", Users: 6800 },
  { Stage: "Started trial", Users: 3100 },
  { Stage: "Activated", Users: 1900 },
  { Stage: "Paid", Users: 860 },
]

/** One base color, faded per stage from the top of the funnel to the bottom. */
const funnelColor = "var(--chart-1)"

function stageColor(index: number, count: number) {
  const mix = count > 1 ? Math.round(100 - (index * 55) / (count - 1)) : 100
  return mix === 100 ? funnelColor : `color-mix(in oklab, ${funnelColor} ${mix}%, transparent)`
}

type Stage = {
  label: string
  value: number
  /** Width relative to the largest stage, 0..1. */
  share: number
  fromPrevious: number | null
  fromFirst: number | null
  lost: number | null
}

const formatRate = (rate: number) =>
  new Intl.NumberFormat("en-US", { style: "percent", maximumFractionDigits: 1 }).format(rate)

/** Column 1 names each stage, column 2 is its count. Blank rows are skipped
 * and negative counts clamp to 0. */
function toStages(rows: typeof data): Stage[] {
  const counted = rows.filter((row) => typeof row.Users === "number")
  const values = counted.map((row) => Math.max(0, row.Users ?? 0))
  const max = Math.max(0, ...values)
  return counted.map((row, index) => {
    const value = values[index]
    const previous = index > 0 ? values[index - 1] : null
    return {
      label: String(row.Stage),
      value,
      share: max > 0 ? value / max : 0,
      fromPrevious: previous ? value / previous : null,
      fromFirst: values[0] > 0 ? value / values[0] : null,
      lost: previous === null ? null : previous - value,
    }
  })
}

/** "−37.5%" for a drop, "+12%" when a stage grew, nothing when flat. */
function Dropoff({ stage }: { stage: Stage }) {
  if (stage.fromPrevious === null || stage.fromPrevious === 1) return null
  const change = stage.fromPrevious - 1
  return (
    <span className={change < 0 ? "text-amber-700 dark:text-amber-400" : "text-emerald-700 dark:text-emerald-400"}>
      {change < 0 ? "−" : "+"}
      {formatRate(Math.abs(change))}
    </span>
  )
}

function FunnelTooltip({ stage, index }: { stage: Stage; index: number }) {
  const rows: [string, string][] = [["Users", stage.value.toLocaleString("en-US")]]
  if (index > 0) {
    if (stage.fromPrevious !== null) rows.push(["From previous", formatRate(stage.fromPrevious)])
    if (stage.fromFirst !== null) rows.push(["From first", formatRate(stage.fromFirst)])
    if (stage.lost !== null) rows.push([stage.lost >= 0 ? "Dropped off" : "Gained", Math.abs(stage.lost).toLocaleString("en-US")])
  }
  return (
    <div aria-hidden className="pointer-events-none absolute top-full left-1/2 z-20 mt-1.5 grid min-w-40 -translate-x-1/2 gap-1 rounded-lg border border-border bg-background px-2.5 py-1.5 text-xs shadow-xl">
      <span className="font-medium text-foreground">{stage.label}</span>
      {rows.map(([label, value]) => (
        <div key={label} className="flex items-center justify-between gap-4">
          <span className="text-muted-foreground">{label}</span>
          <span className="font-mono font-medium text-foreground tabular-nums">{value}</span>
        </div>
      ))}
    </div>
  )
}

/** Top edge at this stage's width, bottom edge at the next stage's, so the
 * rows read as one continuous funnel. Even a zero stage keeps a sliver. */
function trapezoid(share: number, nextShare: number) {
  const top = Math.max(share, 0.02) * 50
  const bottom = Math.max(nextShare, 0.02) * 50
  return `polygon(${50 - top}% 0, ${50 + top}% 0, ${50 + bottom}% 100%, ${50 - bottom}% 100%)`
}

export function Chart() {
  const [active, setActive] = useState<number | null>(null)
  const stages = toStages(data)
  const overall = stages.length > 1 ? stages[stages.length - 1].fromFirst : null

  if (stages.every((stage) => stage.value === 0)) {
    return (
      <div className="flex min-h-[200px] w-full items-center justify-center rounded-lg border border-dashed border-border text-sm text-muted-foreground">
        No stage values to chart yet
      </div>
    )
  }

  return (
    <div className="flex w-full flex-col gap-3 py-2">
      <ol
        aria-label="Users by funnel stage"
        className="grid grid-cols-[fit-content(6rem)_minmax(0,1fr)_auto] gap-x-3 gap-y-1 sm:grid-cols-[fit-content(9rem)_minmax(0,1fr)_auto]"
        onMouseLeave={() => setActive(null)}
      >
        {stages.map((stage, index) => {
          const conversion = index === 0 ? 1 : stage.fromPrevious
          return (
            <li
              key={`${stage.label}-${index}`}
              tabIndex={0}
              aria-label={`${stage.label}: ${stage.value.toLocaleString("en-US")}${conversion === null ? "" : `, ${formatRate(conversion)} of previous stage`}`}
              className={`col-span-3 grid grid-cols-subgrid items-center rounded-md outline-none transition-opacity duration-200 focus-visible:ring-2 focus-visible:ring-ring/50 ${active !== null && active !== index ? "opacity-40" : ""}`}
              onMouseEnter={() => setActive(index)}
              onFocus={() => setActive(index)}
              onBlur={() => setActive(null)}
            >
              <span className={`text-sm leading-tight font-medium break-words transition-colors ${active === index ? "text-foreground" : "text-muted-foreground"}`}>
                {stage.label}
              </span>
              <div className="relative">
                <div
                  className="h-11 w-full motion-safe:animate-in motion-safe:fade-in-0 motion-safe:zoom-in-95"
                  style={{
                    animationDelay: `${index * 70}ms`,
                    animationFillMode: "both",
                    backgroundColor: stageColor(index, stages.length),
                    clipPath: trapezoid(stage.share, stages[index + 1]?.share ?? stage.share),
                  }}
                />
                {active === index && <FunnelTooltip stage={stage} index={index} />}
              </div>
              <div className="flex flex-col items-end leading-tight tabular-nums">
                <span className="text-sm font-medium text-foreground">{stage.value.toLocaleString("en-US")}</span>
                <span className="flex flex-col items-end text-xs text-muted-foreground sm:flex-row sm:gap-1.5">
                  {conversion === null ? "–" : formatRate(conversion)}
                  <Dropoff stage={stage} />
                </span>
              </div>
            </li>
          )
        })}
      </ol>
      <div className="flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground">
        <span>% of previous stage</span>
        {overall !== null && (
          <span>
            Overall conversion <span className="font-medium text-foreground tabular-nums">{formatRate(overall)}</span>
          </span>
        )}
      </div>
    </div>
  )
}

When to use a funnel chart

Funnel charts show where people drop out of a sequence of steps.

  • Signup and onboarding: visit, sign up, activate, pay
  • E-commerce checkout: product view, cart, checkout, purchase
  • Sales pipeline: lead, qualified, proposal, closed won

Data format

  • Use 2 columns: Stage,Value, in order from the first step to the last.
  • Each stage's conversion is its value divided by the previous stage's. The footer shows overall conversion from first to last.
  • Blank rows are skipped. A stage larger than the one before it shows growth (+%) instead of drop-off.
  • Paste CSV, JSON from your API, TSV copied from Excel or Google Sheets, or a Markdown table. The generated component keeps your column names as its data keys.
CSV
Stage,Users
Visited site,12400
Viewed pricing,6800
Started trial,3100
Activated,1900
Paid,860
Same data as JSON
[
  {"stage": "Visited site", "users": 12400},
  {"stage": "Viewed pricing", "users": 6800},
  {"stage": "Started trial", "users": 3100},
  …
]

Options

Shape
A connected trapezoid funnel, or centered rounded bars on a muted track.
Conversion
Show each stage as a % of the previous stage (Step) or of the first stage (Overall).
Drop-off
Show the % lost since the previous stage beside each conversion rate.
Colors
One color faded from top to bottom (Gradient), or a palette color per stage.

How to add it to your project

  1. 1. Add the shadcn/ui chart component (it installs Recharts):
    npx shadcn@latest add chart
  2. 2. Paste your data above, pick the options, and copy the generated chart.tsx.
  3. 3. Save it in your project (e.g. components/funnel-chart.tsx) and render <Chart />, or <Chart data={rows} /> in Data as prop mode.

Funnel Chart FAQ