# Funnel Chart

> Conversion funnel with tapering stages, percent of top, drop-off between stages and animated entry.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/funnel-chart
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/funnel-chart.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

No extra dependencies.

## Usage

```tsx title="components/funnel-chart-demo.tsx"
"use client"

import * as React from "react"

import { FunnelChart, type FunnelStage } from "@/components/funnel-chart"
import { cn } from "@/lib/utils"

const stages: FunnelStage[] = [
  { label: "Visitors", value: 48200 },
  { label: "Signed up", value: 14860 },
  { label: "Activated", value: 8940 },
  { label: "Subscribed", value: 3120 },
  { label: "Retained", value: 2210 },
]

const options = [
  { value: "vertical", label: "Vertical" },
  { value: "horizontal", label: "Horizontal" },
] as const

export default function FunnelChartDemo() {
  const [orientation, setOrientation] = React.useState<"vertical" | "horizontal">("vertical")

  return (
    <div className="w-full max-w-2xl min-w-0">
      <div className="min-w-0 rounded-2xl border bg-card p-5 shadow-sm">
        <div className="mb-5 flex flex-wrap items-start justify-between gap-3">
          <div>
            <p className="text-sm text-muted-foreground">Signup to retention, last 30 days</p>
            <p className="text-2xl font-semibold tabular-nums">4.6% <span className="text-sm font-medium text-muted-foreground">overall conversion</span></p>
          </div>
          <div role="group" aria-label="Funnel orientation" className="inline-flex gap-0.5 rounded-lg bg-muted p-0.5">
            {options.map((o) => (
              <button
                key={o.value}
                type="button"
                aria-pressed={orientation === o.value}
                onClick={() => setOrientation(o.value)}
                className={cn(
                  "h-7 rounded-md px-2.5 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/40",
                  orientation === o.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
                )}
              >
                {o.label}
              </button>
            ))}
          </div>
        </div>
        <FunnelChart key={orientation} stages={stages} orientation={orientation} title="Conversion funnel" />
      </div>
    </div>
  )
}
```

## Source

```tsx title="components/funnel-chart.tsx"
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"

export interface FunnelStage {
  label: string
  value: number
}

export type FunnelTone = "primary" | "emerald" | "sky" | "violet" | "amber" | "rose"

export interface FunnelChartProps extends Omit<React.ComponentProps<"div">, "children"> {
  stages: FunnelStage[]
  /** "vertical" stacks stages top to bottom; "horizontal" runs left to right */
  orientation?: "vertical" | "horizontal"
  tone?: FunnelTone
  /** Accessible title used in the summary */
  title?: string
}

const toneBg: Record<FunnelTone, string> = {
  primary: "bg-primary",
  emerald: "bg-emerald-500 dark:bg-emerald-400",
  sky: "bg-sky-500 dark:bg-sky-400",
  violet: "bg-violet-500 dark:bg-violet-400",
  amber: "bg-amber-500 dark:bg-amber-400",
  rose: "bg-rose-500 dark:bg-rose-400",
}

const number = new Intl.NumberFormat("en-US")
const pct = (n: number) => `${Math.round(n * 10) / 10}%`
const MIN_SIZE = 8

const keyframes = `@keyframes atelier-funnel-x{from{transform:scaleX(0);opacity:0}}
@keyframes atelier-funnel-y{from{transform:scaleY(0);opacity:0}}
@media (prefers-reduced-motion:reduce){.atelier-funnel-bar{animation:none!important;transition:none!important}}`

export function FunnelChart({
  stages,
  orientation = "vertical",
  tone = "primary",
  title = "Conversion funnel",
  className,
  ...props
}: FunnelChartProps) {
  const [hovered, setHovered] = React.useState<number | null>(null)
  const vertical = orientation === "vertical"
  const top = stages[0]?.value || 1

  const rows = stages.map((s, i) => {
    const ofTop = (s.value / top) * 100
    const prev = i > 0 ? stages[i - 1].value : null
    const drop = prev ? (1 - s.value / prev) * 100 : null
    const size = Math.max(ofTop, MIN_SIZE)
    const nextSize = i < stages.length - 1 ? Math.max((stages[i + 1].value / top) * 100, MIN_SIZE) : size * 0.8
    return { ...s, ofTop, drop, size, nextSize }
  })

  const summary = `${title}: ${rows
    .map((r) => `${r.label} ${number.format(r.value)}, ${pct(r.ofTop)} of ${rows[0]?.label ?? "top"}${r.drop !== null ? `, ${pct(r.drop)} drop-off` : ""}`)
    .join("; ")}`

  const hoverProps = (i: number) => ({
    onPointerEnter: () => setHovered(i),
    onPointerLeave: () => setHovered((cur) => (cur === i ? null : cur)),
  })

  function shape(r: (typeof rows)[number], i: number) {
    const a = (100 - r.size) / 2
    const b = (100 - r.nextSize) / 2
    const clip = vertical
      ? `polygon(${a}% 0, ${100 - a}% 0, ${100 - b}% 100%, ${b}% 100%)`
      : `polygon(0 ${a}%, 100% ${b}%, 100% ${100 - b}%, 0 ${100 - a}%)`
    const base = Math.max(1 - i * 0.14, 0.4)
    return (
      <div
        className={cn("atelier-funnel-bar absolute transition-opacity duration-200", vertical ? "inset-x-0 inset-y-px" : "inset-y-0 inset-x-px", toneBg[tone])}
        style={{
          clipPath: clip,
          opacity: hovered === null || hovered === i ? (hovered === i ? 1 : base) : 0.3,
          animation: `${vertical ? "atelier-funnel-x" : "atelier-funnel-y"} 650ms cubic-bezier(0.2, 0.8, 0.2, 1) ${i * 90}ms both`,
        }}
      />
    )
  }

  const stat = (r: (typeof rows)[number], align: "left" | "center") => (
    <>
      <span className="block max-w-full truncate text-sm font-medium">{r.label}</span>
      <span className="block text-xs text-muted-foreground tabular-nums">
        <span className="font-semibold text-foreground">{number.format(r.value)}</span> · {pct(r.ofTop)}
      </span>
      <span className={cn("block text-[11px] tabular-nums", r.drop === null ? "text-muted-foreground" : "text-rose-600 dark:text-rose-400", align === "center" && "truncate")}>
        {r.drop === null ? "Top of funnel" : `−${pct(r.drop)} drop-off`}
      </span>
    </>
  )

  return (
    <div className={cn("w-full min-w-0", className)} {...props}>
      <style>{keyframes}</style>
      {vertical ? (
        <div role="img" aria-label={summary} className="flex min-w-0 items-stretch gap-2 sm:gap-4">
          <div className="min-w-0 flex-1" aria-hidden>
            {rows.map((r, i) => (
              <div key={r.label} className="relative h-16" {...hoverProps(i)}>
                {shape(r, i)}
              </div>
            ))}
          </div>
          <div className="w-36 shrink-0 sm:w-52" aria-hidden>
            {rows.map((r, i) => (
              <div
                key={r.label}
                className={cn("flex h-16 flex-col justify-center rounded-lg px-2 transition-colors", hovered === i && "bg-muted")}
                {...hoverProps(i)}
              >
                {stat(r, "left")}
              </div>
            ))}
          </div>
        </div>
      ) : (
        <div className="min-w-0 overflow-x-auto">
          <div role="img" aria-label={summary} className="flex min-w-[34rem] items-stretch gap-1">
            {rows.map((r, i) => (
              <div key={r.label} className="min-w-0 flex-1" {...hoverProps(i)}>
                <div className="relative h-44" aria-hidden>
                  {shape(r, i)}
                </div>
                <div className={cn("mt-2 rounded-lg px-1.5 py-1.5 text-center transition-colors", hovered === i && "bg-muted")} aria-hidden>
                  {stat(r, "center")}
                </div>
              </div>
            ))}
          </div>
        </div>
      )}
      <div className="sr-only">
      <table>
        <caption>{title}</caption>
        <thead>
          <tr>
            <th scope="col">Stage</th>
            <th scope="col">Value</th>
            <th scope="col">% of top</th>
            <th scope="col">Drop-off from previous</th>
          </tr>
        </thead>
        <tbody>
          {rows.map((r) => (
            <tr key={r.label}>
              <th scope="row">{r.label}</th>
              <td>{number.format(r.value)}</td>
              <td>{pct(r.ofTop)}</td>
              <td>{r.drop === null ? "n/a" : pct(r.drop)}</td>
            </tr>
          ))}
        </tbody>
      </table>
      </div>
    </div>
  )
}
```
