{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"funnel-chart","type":"registry:ui","title":"Funnel Chart","description":"Conversion step by step: each step's value and share, the flow narrowing between them and what dropped. Plain HTML and SVG, no chart library.","dependencies":[],"registryDependencies":["https://dashral.com/r/utils.json","https://dashral.com/r/lib-count-to.json","https://dashral.com/r/lib-locale.json","https://dashral.com/r/lib-motion.json","https://dashral.com/r/text-reveal.json"],"files":[{"path":"registry/dashral/funnel-chart.tsx","type":"registry:file","target":"components/dashral/funnel-chart.tsx","content":"\"use client\"\n\nimport * as React from \"react\"\n\nimport { CountTo } from \"./lib/count-to\"\nimport { useLocale } from \"./lib/locale\"\nimport { enterMotion, riseMotion, stagger, useCountTo } from \"./lib/motion\"\nimport { cn } from \"./lib/utils\"\nimport { TextReveal } from \"./text-reveal\"\n\nexport type FunnelChartDatum = {\n  /** A stable key; the label is used without one. */\n  key?: string\n  label: string\n  value: number\n}\n\ntype FunnelChartProps = Omit<React.ComponentProps<\"div\">, \"children\" | \"color\"> & {\n  /** The steps in order, the first the widest: visits, carts, checkouts, payments. */\n  data: FunnelChartDatum[]\n  /** The bars' height at the first step, in pixels. */\n  height?: number\n  /** Any CSS color for the bars; by default the theme's first chart color. */\n  color?: string\n  /** What the percentage under each value is of: the first step (the default), or the step before. */\n  percent?: \"first\" | \"previous\"\n  /** What dropped since the step before, written in the space above each bar. */\n  showLost?: boolean\n  /** BCP 47 locale for values and percentages. */\n  locale?: string\n  /** How values are written, e.g. `{ notation: \"compact\" }`. */\n  formatOptions?: Intl.NumberFormatOptions\n  /** What the chart shows, e.g. \"Checkout funnel\". */\n  \"aria-label\"?: string\n  classNames?: { step?: string; bar?: string }\n}\n\nconst percentOptions: Intl.NumberFormatOptions = { style: \"percent\", maximumFractionDigits: 1 }\n\n/** A bar takes this share of its column; the rest is the flow to the next step. */\nconst BAR = 0.56\n/** The rounding of a bar's top (or of the \"lost\" space above it), in pixels. */\nconst RADIUS = 6\n\n/** The step's value: it rises into its line with its bar on arrival, then counts to each new value. */\nfunction RevealedCount({ value, format, delay }: { value: number; format: Intl.NumberFormat; delay: number }) {\n  const shown = useCountTo(value)\n  return (\n    <TextReveal as=\"span\" variant=\"rise\" trigger=\"mount\" delay={delay} className=\"block truncate text-base font-semibold tabular-nums\">\n      {format.format(shown)}\n    </TextReveal>\n  )\n}\n\n/**\n * Conversion step by step, in one tag: `<FunnelChart data={steps} />`. Each step is a column with its value and its\n * share written out; between the bars the flow narrows to the next step, and the space above a bar says how many\n * dropped there. Plain HTML and SVG, fills its container's width; the bars rise in one after another, and new data\n * eases them to their heights while the numbers count to theirs.\n */\nfunction FunnelChart({\n  data,\n  height = 200,\n  color = \"var(--chart-1)\",\n  percent: percentOf = \"first\",\n  showLost = true,\n  locale: localeProp,\n  formatOptions,\n  className,\n  classNames,\n  \"aria-label\": ariaLabel,\n  ...props\n}: FunnelChartProps) {\n  const { t, locale: contextLocale, dir } = useLocale()\n  const locale = localeProp ?? contextLocale\n  const format = new Intl.NumberFormat(locale, formatOptions)\n  const percent = new Intl.NumberFormat(locale, { style: \"percent\", maximumFractionDigits: 1 })\n  const values = data.map((d) => Math.max(0, d.value))\n  const full = Math.max(0, ...values)\n  // The top of a step's bar, in pixels from the top of the plot.\n  const top = (i: number) => (full ? height - (values[i]! / full) * height : height)\n  const share = (part: number, whole: number) => percent.format(whole ? part / whole : 0)\n\n  if (data.length === 0) {\n    return (\n      <div data-slot=\"funnel-chart\" className={cn(\"grid place-items-center text-sm text-muted-foreground\", className)} style={{ height }} {...props}>\n        {t(\"noData\")}\n      </div>\n    )\n  }\n\n  return (\n    <div data-slot=\"funnel-chart\" role=\"figure\" aria-label={ariaLabel ?? t(\"funnelChart\")} className={cn(\"w-full\", className)} {...props}>\n      {/* Columns share their rows (subgrid), so the bars stand on one line whatever the labels wrap to. */}\n      <ol className=\"grid grid-rows-[auto_auto] gap-x-0 gap-y-3\" style={{ gridTemplateColumns: `repeat(${data.length}, minmax(0, 1fr))` }}>\n        {data.map((d, i) => {\n          const value = values[i]!\n          const previous = i > 0 ? values[i - 1]! : undefined\n          const lost = previous === undefined ? 0 : Math.max(0, previous - value)\n          const base = percentOf === \"previous\" && previous !== undefined ? previous : values[0]!\n          const barTop = top(i)\n          const lostTop = previous === undefined ? barTop : top(i - 1)\n          const left = ((1 - BAR) / 2) * 100\n          const right = 100 - left\n          // A bar's top is rounded unless a \"lost\" space sits on it. The flow starts and ends at the bars' tops, tucked behind them\n          // from their middles, so a rounded corner sits in it like a square one does.\n          const squared = (k: number) => showLost && k > 0 && values[k - 1]! > values[k]!\n          const corner = (k: number) => (squared(k) ? 0 : Math.min(RADIUS, height - top(k)))\n          const nextTop = i < data.length - 1 ? top(i + 1) : null\n          const end = 100 + left\n          const middle = (right + end) / 2\n          return (\n            <li key={d.key ?? d.label} className={cn(\"row-span-2 grid grid-rows-subgrid\", classNames?.step)}>\n              <div className=\"grid min-w-0 content-start gap-0.5 px-1 text-center\">\n                <span className={cn(enterMotion, \"truncate text-xs text-muted-foreground\")} style={stagger(i, 60)}>\n                  {d.label}\n                </span>\n                <RevealedCount value={d.value} format={format} delay={i * 60} />\n                <span className={cn(enterMotion, \"truncate text-xs text-muted-foreground tabular-nums\")} style={stagger(i + 1, 60)}>\n                  <CountTo value={base ? value / base : 0} locale={locale} formatOptions={percentOptions} />\n                </span>\n                {/* Read out: what's lost here, and how many of the step before carried on. */}\n                {previous !== undefined && (\n                  <span className=\"sr-only\">{t(\"funnelFrom\", { rate: share(value, previous), from: data[i - 1]!.label, lost: format.format(lost) })}</span>\n                )}\n              </div>\n\n              <div aria-hidden className=\"relative\" style={{ height }}>\n                {/* The flow from this bar's top down to the next one's, drawn in one piece across the column edge (columns are\n                    equal, so 100 + left lands on the next bar); mirrored in right-to-left, where the steps run leftwards. */}\n                <svg width=\"100%\" height={height} viewBox={`0 0 100 ${height}`} preserveAspectRatio=\"none\" className={cn(enterMotion, \"absolute inset-0 overflow-visible\", dir === \"rtl\" && \"-scale-x-100\")} style={stagger(i + 1, 60)}>\n                  {nextTop !== null && (\n                    <path\n                      // An S-curve, level where it leaves one bar and where it meets the next.\n                      d={`M50,${barTop}L${right},${barTop}C${middle},${barTop} ${middle},${nextTop} ${end},${nextTop}L150,${nextTop}L150,${height}L50,${height}Z`}\n                      fill={color}\n                      fillOpacity={0.12}\n                      className=\"transition-[d] duration-(--duration-considered) ease-standard motion-reduce:transition-none\"\n                    />\n                  )}\n                </svg>\n\n                {/* What dropped since the step before: the space between that step's height and this bar. */}\n                {showLost && previous !== undefined && lost > 0 && (\n                  <div\n                    className=\"@container absolute grid place-items-center overflow-hidden border border-b-0 border-dashed border-muted-foreground/35 text-[11px] text-muted-foreground tabular-nums transition-[top,height] duration-(--duration-considered) ease-standard motion-reduce:transition-none\"\n                    style={{ left: `${left}%`, right: `${left}%`, top: lostTop, height: barTop - lostTop, borderTopLeftRadius: RADIUS, borderTopRightRadius: RADIUS }}\n                  >\n                    {barTop - lostTop >= 18 && <span className=\"truncate px-0.5 @max-[3.75rem]:px-0 @max-[3.75rem]:text-[10px]\">\n                        −<CountTo value={lost} locale={locale} formatOptions={formatOptions} />\n                      </span>}\n                  </div>\n                )}\n\n                {/* The bar; it eases to its new height when the data changes. Under a \"lost\" space its top stays square. */}\n                <div\n                  className={cn(\"absolute bottom-0 overflow-hidden transition-[top] duration-(--duration-considered) ease-standard motion-reduce:transition-none\")}\n                  style={{ left: `${left}%`, right: `${left}%`, top: barTop, borderTopLeftRadius: corner(i), borderTopRightRadius: corner(i) }}\n                >\n                  <div\n                    className={cn(riseMotion, \"h-full\", classNames?.bar)}\n                    style={{ background: color, ...stagger(i, 60) }}\n                  />\n                </div>\n              </div>\n            </li>\n          )\n        })}\n      </ol>\n    </div>\n  )\n}\n\nexport { FunnelChart, type FunnelChartProps }\n"}],"cssVars":{"theme":{"ease-standard":"var(--ease-standard)","ease-enter":"var(--ease-enter)","ease-exit":"var(--ease-exit)","ease-spring":"var(--ease-spring)"},"light":{"duration-fast":"160ms","duration-standard":"240ms","duration-considered":"480ms","ease-standard":"cubic-bezier(0.2, 0, 0, 1)","ease-enter":"cubic-bezier(0, 0, 0, 1)","ease-exit":"cubic-bezier(0.3, 0, 1, 1)","ease-spring":"linear(0, 0.071 4%, 0.234 8%, 0.433 12%, 0.626 17%, 0.791 21%, 0.917 25%, 1.004 29%, 1.055 33%, 1.079 38%, 1.083 42%, 1.075 46%, 1.06 50%, 1.043 54%, 1.027 58%, 1.015 62%, 1.005 67%, 0.998 71%, 0.995 75%, 0.993 79%, 0.993 83%, 0.994 88%, 0.995 92%, 0.997 96%, 1)"},"dark":{}}}