# Funnel Chart

A conversion funnel: each stage narrows in proportion to its value, with the step-to-step conversion rate and the drop-off called out between stages.

**Category:** Charts  
**Exports:** `FunnelChart`  
**npm dependencies:** motion (+ clsx, tailwind-merge for `cn`)

## Install

```bash
npx shadcn@latest add https://charcoalui.com/r/funnel-chart.json
```
The file lands at `components/ui/funnel-chart.tsx`; import from `@/components/ui/funnel-chart`.

## API — Funnel Chart
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `stages` | `{ label: string; value: number }[]` | — |  |
| `format` | `(v: number) => string` | — |  |

## Example — Funnel Chart

```tsx
import { FunnelChart } from "@/components/ui/funnel-chart";

export default function FunnelChartDemo() {
  return (
    <FunnelChart
      stages={[
        { label: "Visited", value: 48200 },
        { label: "Signed up", value: 9640 },
        { label: "Activated", value: 5210 },
        { label: "Trialled Pro", value: 1890 },
        { label: "Paid", value: 742 },
      ]}
    />
  );
}
```

## Source — components/ui/funnel-chart.tsx

```tsx
"use client";

import * as React from "react";
import { motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export type FunnelStage = { label: string; value: number };

export type FunnelChartProps = {
  stages: FunnelStage[];
  format?: (v: number) => string;
  className?: string;
};

/** A conversion funnel: stages narrow in proportion to their value, with the step-to-step conversion and the drop-off called out between them. */
export function FunnelChart({ stages, format = (v) => v.toLocaleString("en-US"), className }: FunnelChartProps) {
  const reduce = useReducedMotion();
  const [hover, setHover] = React.useState<number | null>(null);
  const top = stages[0]?.value || 1;

  return (
    <figure className={cn("w-full max-w-xl", className)} aria-label={`Funnel: ${stages.map((s) => `${s.label} ${format(s.value)}`).join(", ")}`}>
      <ol className="grid gap-1.5">
        {stages.map((s, i) => {
          const pct = (s.value / top) * 100;
          const prev = stages[i - 1];
          const conv = prev ? (s.value / prev.value) * 100 : 100;
          return (
            <li key={s.label} onPointerEnter={() => setHover(i)} onPointerLeave={() => setHover(null)}>
              {prev && (
                <p className="flex items-center justify-center gap-2 py-0.5 font-mono text-[11px] text-muted-foreground">
                  <span className="text-foreground">{conv.toFixed(1)}%</span> converted
                  <span className="text-destructive">−{format(prev.value - s.value)}</span>
                </p>
              )}
              <div className="flex items-center gap-3">
                <span className="w-24 shrink-0 truncate text-right text-sm">{s.label}</span>
                <div className="relative flex h-10 flex-1 justify-center">
                  <motion.div
                    className="relative h-full rounded-lg bg-ember"
                    initial={reduce ? false : { width: 0 }}
                    animate={{ width: `${Math.max(pct, 2)}%`, opacity: hover === null || hover === i ? 0.35 + 0.65 * (pct / 100) + 0.1 : 0.25 }}
                    transition={{ width: { duration: 0.8, delay: i * 0.12, ease: [0.22, 1, 0.36, 1] }, opacity: { duration: 0.2 } }}
                  />
                  <span className="pointer-events-none absolute inset-0 grid place-items-center font-mono text-sm font-semibold tabular-nums">{format(s.value)}</span>
                </div>
                <span className="w-14 shrink-0 font-mono text-xs tabular-nums text-muted-foreground">{pct.toFixed(0)}%</span>
              </div>
            </li>
          );
        })}
      </ol>
    </figure>
  );
}
```