{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "funnel-chart",
  "title": "Funnel Chart",
  "description": "Step-by-step conversion, as stacked bars with drop-off or as a flow of tapering stages with a tile per step.",
  "files": [
    {
      "path": "registry/dashboardcn/ui/funnel-chart.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { formatNumber } from \"@/registry/dashboardcn/lib/format\"\n\nexport interface FunnelStep {\n  name: string\n  value: number\n  /** Any CSS color for this step in the flow variant. Defaults to chart-1 through chart-5 in order. */\n  color?: string\n}\n\nexport interface FunnelChartProps extends React.ComponentProps<\"div\"> {\n  steps: FunnelStep[]\n  valueFormatter?: (value: number) => string\n  /**\n   * Any CSS color. \"bars\" fades it step by step. \"flow\" paints every stage in\n   * it, in place of the per-stage palette.\n   */\n  color?: string\n  /** Show the drop-off between consecutive steps. Bars only. */\n  showDropoff?: boolean\n  /** \"bars\" stacks one bar per step; \"flow\" runs the steps left to right, each tapering into the next. */\n  variant?: \"bars\" | \"flow\"\n  /** Flow only. \"eased\" curves each neck; \"sharp\" draws straight trapezoids. */\n  shape?: \"eased\" | \"sharp\"\n  /** Flow only. Height of the stages in pixels. */\n  height?: number\n  /** Flow only. Width of the neck between stages in pixels. */\n  neckWidth?: number\n  /** Flow only. Show the share of the first step as a pill on each stage. */\n  showPercentages?: boolean\n  /** Flow only. Show a tile with the name and value under each stage. */\n  showLabels?: boolean\n  /** Flow only. Stage drawn at full strength while the rest dim. */\n  activeIndex?: number | null\n  onActiveIndexChange?: (index: number | null) => void\n}\n\nconst defaultColors = [\n  \"var(--chart-1)\",\n  \"var(--chart-2)\",\n  \"var(--chart-3)\",\n  \"var(--chart-4)\",\n  \"var(--chart-5)\",\n]\n\n// Round so server and client render identical path strings.\nconst round = (n: number) => Math.round(n * 1000) / 1000\n\n/** Path for the neck between two stages, in a 1x1 box stretched to fit. */\nfunction neckPath(from: number, to: number, shape: \"eased\" | \"sharp\") {\n  const t0 = round((1 - from) / 2)\n  const b0 = round(1 - t0)\n  const t1 = round((1 - to) / 2)\n  const b1 = round(1 - t1)\n  if (shape === \"sharp\") return `M0,${t0} L1,${t1} L1,${b1} L0,${b0} Z`\n  return `M0,${t0} C0.5,${t0} 0.5,${t1} 1,${t1} L1,${b1} C0.5,${b1} 0.5,${b0} 0,${b0} Z`\n}\n\nfunction FunnelBars({\n  steps,\n  valueFormatter,\n  color,\n  showDropoff,\n}: {\n  steps: FunnelStep[]\n  valueFormatter: (value: number) => string\n  color: string\n  showDropoff: boolean\n}) {\n  const first = steps[0]?.value ?? 0\n  const last = steps[steps.length - 1]?.value ?? 0\n  const overall = first > 0 ? last / first : 0\n\n  return (\n    <>\n      {steps.map((step, index) => {\n        const previous = index > 0 ? steps[index - 1]!.value : step.value\n        const ofFirst = first > 0 ? step.value / first : 0\n        const ofPrevious = previous > 0 ? step.value / previous : 0\n        const dropoff = 1 - ofPrevious\n\n        return (\n          <React.Fragment key={step.name}>\n            {showDropoff && index > 0 ? (\n              <div className=\"text-muted-foreground flex items-center gap-2 py-1 pl-3 text-xs\">\n                <span aria-hidden=\"true\" className=\"bg-border h-3 w-px\" />\n                <span className=\"tabular-nums\">\n                  {formatNumber(dropoff, { format: \"percent\" })} drop-off\n                </span>\n              </div>\n            ) : null}\n            <div data-slot=\"funnel-step\" className=\"flex flex-col gap-1.5\">\n              <div className=\"flex items-baseline justify-between gap-4 text-sm\">\n                <span className=\"flex items-center gap-2 truncate\">\n                  <span className=\"text-muted-foreground w-4 shrink-0 text-xs tabular-nums\">\n                    {index + 1}\n                  </span>\n                  <span className=\"truncate font-medium\">{step.name}</span>\n                </span>\n                <span className=\"flex shrink-0 items-baseline gap-2 tabular-nums\">\n                  <span className=\"font-medium\">{valueFormatter(step.value)}</span>\n                  <span className=\"text-muted-foreground w-12 text-right text-xs\">\n                    {formatNumber(ofFirst, { format: \"percent\" })}\n                  </span>\n                </span>\n              </div>\n              <div className=\"bg-muted h-6 w-full overflow-hidden rounded-md\">\n                <div\n                  className=\"h-full rounded-md transition-[width] duration-300\"\n                  style={{\n                    width: `${ofFirst * 100}%`,\n                    backgroundColor: color,\n                    opacity: 1 - index * (0.5 / Math.max(steps.length - 1, 1)),\n                  }}\n                />\n              </div>\n            </div>\n          </React.Fragment>\n        )\n      })}\n      {steps.length > 1 ? (\n        <div className=\"mt-2 flex items-center justify-between border-t pt-3 text-sm\">\n          <span className=\"text-muted-foreground\">Overall conversion</span>\n          <span className=\"font-semibold tabular-nums\">\n            {formatNumber(overall, { format: \"percent\" })}\n          </span>\n        </div>\n      ) : null}\n    </>\n  )\n}\n\nfunction FunnelFlow({\n  steps,\n  valueFormatter,\n  color,\n  shape,\n  height,\n  neckWidth,\n  showPercentages,\n  showLabels,\n  activeIndex,\n  onActiveIndexChange,\n}: {\n  steps: FunnelStep[]\n  valueFormatter: (value: number) => string\n  color?: string\n  shape: \"eased\" | \"sharp\"\n  height: number\n  neckWidth: number\n  showPercentages: boolean\n  showLabels: boolean\n  activeIndex?: number | null\n  onActiveIndexChange?: (index: number | null) => void\n}) {\n  const id = React.useId()\n  const [activeState, setActiveState] = React.useState<number | null>(null)\n  const active = activeIndex === undefined ? activeState : activeIndex\n  const setActive = (index: number | null) => {\n    setActiveState(index)\n    onActiveIndexChange?.(index)\n  }\n\n  const first = steps[0]?.value ?? 0\n  const stages = steps.map((step, index) => ({\n    ...step,\n    share: first > 0 ? Math.min(1, Math.max(0, step.value / first)) : 0,\n    fill: step.color ?? color ?? defaultColors[index % defaultColors.length],\n    // A single color fades toward the end of the funnel so stages stay distinct.\n    opacity: color && !step.color ? 1 - index * (0.6 / Math.max(steps.length - 1, 1)) : 1,\n  }))\n\n  return (\n    <>\n      <div\n        data-slot=\"funnel-flow\"\n        className=\"flex w-full items-stretch\"\n        style={{ height }}\n        onMouseLeave={() => setActive(null)}\n      >\n        {stages.map((stage, index) => {\n          const next = stages[index + 1]\n          const dimmed = active !== null && active !== index\n          const neckDimmed = active !== null && active !== index && active !== index + 1\n          const gradient = `${id}-${index}`\n          return (\n            <React.Fragment key={stage.name}>\n              <div\n                data-slot=\"funnel-stage\"\n                data-active={active === index || undefined}\n                onMouseEnter={() => setActive(index)}\n                className=\"relative flex min-w-0 flex-1 items-center transition-opacity duration-200\"\n                style={{ opacity: dimmed ? 0.35 : 1 }}\n              >\n                <div\n                  className={cn(\n                    \"w-full transition-[height] duration-500 ease-out\",\n                    index === 0 && \"rounded-l-lg\",\n                    index === stages.length - 1 && \"rounded-r-lg\"\n                  )}\n                  style={{\n                    height: `${round(stage.share * 100)}%`,\n                    minHeight: 4,\n                    backgroundColor: stage.fill,\n                    opacity: stage.opacity,\n                  }}\n                />\n                {showPercentages ? (\n                  <span className=\"bg-background/90 text-foreground ring-foreground/10 pointer-events-none absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full px-2 py-0.5 text-[11px] font-medium tabular-nums shadow-xs ring-1 backdrop-blur-sm\">\n                    {formatNumber(stage.share, { format: \"percent\", maximumFractionDigits: 0 })}\n                  </span>\n                ) : null}\n              </div>\n              {next ? (\n                <svg\n                  aria-hidden=\"true\"\n                  data-slot=\"funnel-neck\"\n                  viewBox=\"0 0 1 1\"\n                  preserveAspectRatio=\"none\"\n                  className=\"h-full shrink-0 transition-opacity duration-200\"\n                  style={{ width: neckWidth, opacity: neckDimmed ? 0.35 : 1 }}\n                >\n                  <defs>\n                    <linearGradient id={gradient} x1=\"0\" y1=\"0\" x2=\"1\" y2=\"0\">\n                      <stop offset=\"0\" stopColor={stage.fill} stopOpacity={stage.opacity} />\n                      <stop offset=\"1\" stopColor={next.fill} stopOpacity={next.opacity} />\n                    </linearGradient>\n                  </defs>\n                  <path\n                    d={neckPath(stage.share, next.share, shape)}\n                    fill={`url(#${gradient})`}\n                    className=\"transition-[d] duration-500 ease-out\"\n                  />\n                </svg>\n              ) : null}\n            </React.Fragment>\n          )\n        })}\n      </div>\n      {showLabels ? (\n        <div\n          data-slot=\"funnel-labels\"\n          className=\"grid gap-2\"\n          style={{ gridTemplateColumns: `repeat(${stages.length}, minmax(0, 1fr))` }}\n        >\n          {stages.map((stage, index) => (\n            <div\n              key={stage.name}\n              data-active={active === index || undefined}\n              onMouseEnter={() => setActive(index)}\n              onMouseLeave={() => setActive(null)}\n              className={cn(\n                \"flex min-w-0 flex-col gap-0.5 rounded-lg border px-2.5 py-2 transition-[opacity,border-color] duration-200\",\n                active !== null && active !== index && \"opacity-50\",\n                active === index && \"border-foreground/20\"\n              )}\n            >\n              <span className=\"text-muted-foreground flex items-center gap-1.5 text-xs\">\n                <span\n                  aria-hidden=\"true\"\n                  className=\"size-2 shrink-0 rounded-full\"\n                  style={{ backgroundColor: stage.fill, opacity: stage.opacity }}\n                />\n                <span className=\"truncate\">{stage.name}</span>\n              </span>\n              <span className=\"truncate text-sm font-semibold tabular-nums\">\n                {valueFormatter(stage.value)}\n              </span>\n            </div>\n          ))}\n        </div>\n      ) : null}\n    </>\n  )\n}\n\n/** Step-by-step conversion, as stacked bars with drop-off or as a flow of tapering stages. */\nfunction FunnelChart({\n  steps,\n  valueFormatter = (value) => formatNumber(value),\n  color,\n  showDropoff = true,\n  variant = \"bars\",\n  shape = \"eased\",\n  height = 160,\n  neckWidth = 24,\n  showPercentages = true,\n  showLabels = true,\n  activeIndex,\n  onActiveIndexChange,\n  className,\n  ...props\n}: FunnelChartProps) {\n  return (\n    <div\n      data-slot=\"funnel-chart\"\n      data-variant={variant}\n      className={cn(\"flex flex-col\", variant === \"flow\" ? \"gap-3\" : \"gap-1\", className)}\n      {...props}\n    >\n      {variant === \"flow\" ? (\n        <FunnelFlow\n          steps={steps}\n          valueFormatter={valueFormatter}\n          color={color}\n          shape={shape}\n          height={height}\n          neckWidth={neckWidth}\n          showPercentages={showPercentages}\n          showLabels={showLabels}\n          activeIndex={activeIndex}\n          onActiveIndexChange={onActiveIndexChange}\n        />\n      ) : (\n        <FunnelBars\n          steps={steps}\n          valueFormatter={valueFormatter}\n          color={color ?? \"var(--chart-1)\"}\n          showDropoff={showDropoff}\n        />\n      )}\n    </div>\n  )\n}\n\nexport { FunnelChart }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/dashboardcn/lib/format.ts",
      "content": "export type NumberFormat = \"number\" | \"compact\" | \"currency\" | \"percent\"\n\nexport interface FormatNumberOptions {\n  format?: NumberFormat\n  /** ISO 4217 code, used when format is \"currency\". Defaults to USD. */\n  currency?: string\n  locale?: string\n  maximumFractionDigits?: number\n  /** Abbreviate large values, e.g. \"$158K\" instead of \"$158,143\". */\n  compact?: boolean\n}\n\nconst formatterCache = new Map<string, Intl.NumberFormat>()\n\nfunction getFormatter(locale: string, options: Intl.NumberFormatOptions) {\n  const key = locale + JSON.stringify(options)\n  let formatter = formatterCache.get(key)\n  if (!formatter) {\n    formatter = new Intl.NumberFormat(locale, options)\n    formatterCache.set(key, formatter)\n  }\n  return formatter\n}\n\n/**\n * Format a metric value for display.\n *\n * formatNumber(1234567)                          -> \"1,234,567\"\n * formatNumber(1234567, { format: \"compact\" })   -> \"1.2M\"\n * formatNumber(48.2, { format: \"currency\" })     -> \"$48.20\"\n * formatNumber(158143, { format: \"currency\", compact: true }) -> \"$158K\"\n * formatNumber(0.124, { format: \"percent\" })     -> \"12.4%\"\n */\nexport function formatNumber(\n  value: number,\n  {\n    format = \"number\",\n    currency = \"USD\",\n    locale = \"en-US\",\n    maximumFractionDigits,\n    compact = false,\n  }: FormatNumberOptions = {}\n): string {\n  if (!Number.isFinite(value)) return \"—\"\n\n  // Compact values keep three significant digits: $158K, $1.23M, 41.2K.\n  const compactOptions: Intl.NumberFormatOptions = compact\n    ? maximumFractionDigits === undefined\n      ? { notation: \"compact\", maximumSignificantDigits: 3 }\n      : { notation: \"compact\", maximumFractionDigits }\n    : {}\n  switch (format) {\n    case \"compact\":\n      return getFormatter(locale, {\n        notation: \"compact\",\n        maximumFractionDigits: maximumFractionDigits ?? 1,\n      }).format(value)\n    case \"currency\":\n      return getFormatter(locale, {\n        style: \"currency\",\n        currency,\n        maximumFractionDigits: maximumFractionDigits ?? 2,\n        ...compactOptions,\n      }).format(value)\n    case \"percent\":\n      return getFormatter(locale, {\n        style: \"percent\",\n        maximumFractionDigits: maximumFractionDigits ?? 1,\n      }).format(value)\n    default:\n      return getFormatter(locale, {\n        maximumFractionDigits: maximumFractionDigits ?? 0,\n        ...compactOptions,\n      }).format(value)\n  }\n}\n\n/**\n * Format a fractional change as a signed percentage.\n *\n * formatDelta(0.124)  -> \"+12.4%\"\n * formatDelta(-0.03)  -> \"-3.0%\"\n * formatDelta(0)      -> \"0.0%\"\n */\nexport function formatDelta(delta: number, locale = \"en-US\"): string {\n  if (!Number.isFinite(delta)) return \"—\"\n  return getFormatter(locale, {\n    style: \"percent\",\n    signDisplay: \"exceptZero\",\n    maximumFractionDigits: 1,\n    minimumFractionDigits: 1,\n  }).format(delta)\n}\n\n/**\n * Fractional change between two values. Returns 0 when there is no baseline.\n */\nexport function computeDelta(current: number, previous: number): number {\n  if (!previous) return 0\n  return (current - previous) / Math.abs(previous)\n}\n",
      "type": "registry:lib"
    }
  ],
  "type": "registry:ui"
}