{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "stage-bars-card",
  "title": "Stage Bars Card",
  "description": "The funnel as a list: a rounded bar per stage with its name, value, and share of the first stage, a range picker, and a tile per stage.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "card",
    "button",
    "dropdown-menu",
    "tooltip"
  ],
  "files": [
    {
      "path": "registry/dashboardcn/blocks/stage-bars-card.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { ChevronDown } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { formatNumber, type NumberFormat } from \"@/registry/dashboardcn/lib/format\"\nimport { Button } from \"@/components/ui/button\"\nimport {\n  Card,\n  CardAction,\n  CardContent,\n  CardDescription,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\"\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuRadioGroup,\n  DropdownMenuRadioItem,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\"\nimport { DeltaBadge } from \"@/registry/dashboardcn/ui/delta-badge\"\nimport { MetricValue } from \"@/registry/dashboardcn/ui/metric-value\"\n\nexport interface StageBarsStage {\n  name: string\n  value: number\n  /** Any CSS color. Defaults to chart-1 through chart-5 in order. */\n  color?: string\n  /** Small icon drawn at the left end of the bar. */\n  icon?: React.ReactNode\n}\n\nexport interface StageBarsRange {\n  value: string\n  label: string\n  /** Top of the funnel first. */\n  stages: StageBarsStage[]\n  /** Change in the first stage vs the previous range, as a fraction. */\n  delta?: number\n}\n\nexport interface StageBarsCardProps\n  extends Omit<React.ComponentProps<typeof Card>, \"children\"> {\n  title: string\n  /** Top of the funnel first. Ignored when `ranges` is set. */\n  stages?: StageBarsStage[]\n  /** Change in the first stage, as a fraction. Ignored when `ranges` is set. */\n  delta?: number\n  /** Context for the delta, e.g. \"vs last week\". */\n  deltaLabel?: string\n  invertDelta?: boolean\n  /** Static text in the header, e.g. \"Last 30 days\". Replaced by the picker when `ranges` is set. */\n  caption?: string\n  /** Ranges for the header picker. Each carries its own stages. */\n  ranges?: StageBarsRange[]\n  range?: string\n  defaultRange?: string\n  onRangeChange?: (value: string) => void\n  format?: NumberFormat\n  currency?: string\n  /** Any CSS color. Paints every bar in it, in place of the per-stage palette. */\n  color?: string\n  /** Show a tile with the name and value for each stage under the bars. */\n  showTiles?: boolean\n  /** Show the icons inside the bars. */\n  showIcons?: boolean\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/** The funnel as a list: a rounded bar per stage with its name, value, and share of the first stage. Hovering a stage swaps the headline. */\nfunction StageBarsCard({\n  title,\n  stages: stagesProp = [],\n  delta: deltaProp,\n  deltaLabel,\n  invertDelta,\n  caption,\n  ranges,\n  range: rangeProp,\n  defaultRange,\n  onRangeChange,\n  format = \"number\",\n  currency,\n  color,\n  showTiles = true,\n  showIcons = true,\n  className,\n  ...props\n}: StageBarsCardProps) {\n  const [rangeState, setRangeState] = React.useState(defaultRange ?? ranges?.[0]?.value)\n  const range = rangeProp ?? rangeState\n  const setRange = (value: string) => {\n    setRangeState(value)\n    onRangeChange?.(value)\n  }\n  const current = ranges?.find((option) => option.value === range) ?? ranges?.[0]\n  const delta = current ? current.delta : deltaProp\n\n  const first = (current?.stages ?? stagesProp)[0]\n  const stages = (current?.stages ?? stagesProp).map((stage, index) => ({\n    ...stage,\n    share: first && first.value > 0 ? Math.min(1, Math.max(0, stage.value / first.value)) : 0,\n    fill: stage.color ?? color ?? defaultColors[index % defaultColors.length],\n    opacity: color && !stage.color ? 1 - index * (0.6 / Math.max(stagesProp.length - 1, 1)) : 1,\n  }))\n\n  const [active, setActive] = React.useState<number | null>(null)\n  const hovered = active !== null ? stages[active] : undefined\n  const headline = hovered ?? stages[0]\n  const percent = (share: number) =>\n    formatNumber(share, { format: \"percent\", maximumFractionDigits: 0 })\n\n  return (\n    <Card\n      data-slot=\"stage-bars-card\"\n      className={cn(\"@container/card gap-4\", className)}\n      {...props}\n    >\n      <CardHeader className=\"@max-sm/card:[grid-template-columns:minmax(0,1fr)]!\">\n        <CardDescription>{title}</CardDescription>\n        <CardTitle className=\"text-3xl font-semibold tabular-nums tracking-tight\">\n          <MetricValue value={headline?.value ?? 0} format={format} currency={currency} />\n        </CardTitle>\n        <div className=\"flex h-5 items-center gap-1.5 text-sm\">\n          {hovered ? (\n            <span className=\"text-muted-foreground truncate\">\n              {hovered.name}\n              {hovered !== stages[0] ? (\n                <>\n                  {\" · \"}\n                  <span className=\"text-foreground font-medium tabular-nums\">\n                    {percent(hovered.share)}\n                  </span>{\" \"}\n                  of {stages[0]?.name}\n                </>\n              ) : null}\n            </span>\n          ) : (\n            <>\n              {delta !== undefined ? (\n                <DeltaBadge delta={delta} invert={invertDelta} variant=\"soft\" />\n              ) : null}\n              {deltaLabel ? <span className=\"text-muted-foreground\">{deltaLabel}</span> : null}\n            </>\n          )}\n        </div>\n        {ranges?.length ? (\n          <CardAction className=\"@max-sm/card:[grid-area:auto]! @max-sm/card:justify-self-start!\">\n            <DropdownMenu>\n              <DropdownMenuTrigger asChild>\n                <Button variant=\"outline\" size=\"sm\">\n                  {current?.label}\n                  <ChevronDown className=\"text-muted-foreground\" />\n                </Button>\n              </DropdownMenuTrigger>\n              <DropdownMenuContent align=\"end\">\n                <DropdownMenuRadioGroup value={range} onValueChange={setRange}>\n                  {ranges.map((option) => (\n                    <DropdownMenuRadioItem key={option.value} value={option.value}>\n                      {option.label}\n                    </DropdownMenuRadioItem>\n                  ))}\n                </DropdownMenuRadioGroup>\n              </DropdownMenuContent>\n            </DropdownMenu>\n          </CardAction>\n        ) : caption ? (\n          <CardAction className=\"@max-sm/card:[grid-area:auto]! @max-sm/card:justify-self-start!\">\n            <span className=\"text-muted-foreground text-sm\">{caption}</span>\n          </CardAction>\n        ) : null}\n      </CardHeader>\n      <CardContent className=\"flex flex-col gap-5\">\n        {/* Subgrid keeps the label column as wide as the longest name across every row. */}\n        <div\n          data-slot=\"stage-bars\"\n          role=\"list\"\n          className=\"grid grid-cols-[auto_minmax(0,1fr)_auto] gap-y-1\"\n          onMouseLeave={() => setActive(null)}\n        >\n          {stages.map((stage, index) => (\n            <div\n              key={stage.name}\n              role=\"listitem\"\n              data-active={active === index || undefined}\n              onMouseEnter={() => setActive(index)}\n              className={cn(\n                \"col-span-3 grid grid-cols-subgrid items-center gap-x-3 rounded-md px-1 py-1 transition-opacity duration-200 -mx-1\",\n                active !== null && active !== index && \"opacity-40\"\n              )}\n            >\n              <span className=\"text-muted-foreground truncate text-right text-sm\">\n                {stage.name}\n              </span>\n              <div className=\"bg-muted relative h-4 overflow-hidden rounded-full\">\n                <div\n                  className=\"relative h-full rounded-full transition-[width] duration-500 ease-out\"\n                  style={{\n                    width: `${Math.round(stage.share * 1000) / 10}%`,\n                    minWidth: stage.icon && showIcons ? 16 : 4,\n                    backgroundColor: stage.fill,\n                    opacity: stage.opacity,\n                  }}\n                >\n                  {stage.icon && showIcons ? (\n                    <span className=\"text-background absolute inset-y-0 left-0 flex w-4 items-center justify-center [&>svg]:size-3\">\n                      {stage.icon}\n                    </span>\n                  ) : null}\n                </div>\n              </div>\n              <span className=\"flex items-baseline justify-end gap-2 text-sm tabular-nums\">\n                <span className=\"font-medium\">\n                  {formatNumber(stage.value, { format, currency })}\n                </span>\n                <span className=\"text-muted-foreground w-9 text-right text-xs\">\n                  {percent(stage.share)}\n                </span>\n              </span>\n            </div>\n          ))}\n        </div>\n        {showTiles ? (\n          <div className=\"grid grid-cols-2 gap-2 @xs/card:grid-cols-3\">\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                  {formatNumber(stage.value, { format, currency })}\n                </span>\n              </div>\n            ))}\n          </div>\n        ) : null}\n      </CardContent>\n    </Card>\n  )\n}\n\nexport { StageBarsCard }\n",
      "type": "registry:component"
    },
    {
      "path": "registry/dashboardcn/ui/delta-badge.tsx",
      "content": "import * as React from \"react\"\nimport { Minus, TrendingDown, TrendingUp } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { formatDelta } from \"@/registry/dashboardcn/lib/format\"\n\nexport type DeltaDirection = \"up\" | \"down\" | \"flat\"\n\nexport function getDeltaDirection(delta: number | undefined): DeltaDirection {\n  if (delta === undefined || delta === 0 || !Number.isFinite(delta)) return \"flat\"\n  return delta > 0 ? \"up\" : \"down\"\n}\n\nconst directionIcon: Record<DeltaDirection, React.ElementType> = {\n  up: TrendingUp,\n  down: TrendingDown,\n  flat: Minus,\n}\n\nexport interface DeltaBadgeProps extends React.ComponentProps<\"span\"> {\n  /** Fractional change, e.g. 0.124 for +12.4%. */\n  delta: number\n  /** Treat a decrease as good and an increase as bad (churn, latency, errors). */\n  invert?: boolean\n  variant?: \"outline\" | \"soft\" | \"text\"\n  showIcon?: boolean\n}\n\nfunction DeltaBadge({\n  delta,\n  invert = false,\n  variant = \"outline\",\n  showIcon = true,\n  className,\n  children,\n  ...props\n}: DeltaBadgeProps) {\n  const direction = getDeltaDirection(delta)\n  const positive = direction === \"flat\" ? null : (direction === \"up\") !== invert\n  const Icon = directionIcon[direction]\n\n  return (\n    <span\n      data-slot=\"delta-badge\"\n      data-direction={direction}\n      data-positive={positive}\n      className={cn(\n        \"inline-flex items-center gap-1 text-xs font-medium tabular-nums\",\n        variant === \"outline\" && \"rounded-md border px-1.5 py-0.5\",\n        variant === \"soft\" && \"rounded-md px-1.5 py-0.5\",\n        variant === \"soft\" && positive === true && \"bg-emerald-500/10\",\n        variant === \"soft\" && positive === false && \"bg-red-500/10\",\n        variant === \"soft\" && positive === null && \"bg-muted\",\n        positive === true && \"text-emerald-600 dark:text-emerald-400\",\n        positive === false && \"text-red-600 dark:text-red-400\",\n        positive === null && \"text-muted-foreground\",\n        className\n      )}\n      {...props}\n    >\n      {showIcon ? <Icon className=\"size-3\" aria-hidden=\"true\" /> : null}\n      {children ?? formatDelta(delta)}\n    </span>\n  )\n}\n\nexport { DeltaBadge }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/dashboardcn/ui/metric-value.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { formatNumber, type NumberFormat } from \"@/registry/dashboardcn/lib/format\"\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\"\n\n/** Values at or above this are abbreviated by default. */\nexport const DEFAULT_COMPACT_FROM = 100_000\n\nexport interface MetricValueProps extends Omit<React.ComponentProps<\"span\">, \"children\"> {\n  /** Strings render as-is. */\n  value: number | string\n  format?: NumberFormat\n  currency?: string\n  maximumFractionDigits?: number\n  /** Values at or above this are abbreviated (e.g. $158K) with the full value in a tooltip. Set to Infinity to always show the full value. */\n  compactFrom?: number\n  /** Text after the number, e.g. a unit. */\n  suffix?: React.ReactNode\n}\n\n/** A formatted number. Large values are abbreviated and reveal the full value on hover. */\nfunction MetricValue({\n  value,\n  format,\n  currency,\n  maximumFractionDigits,\n  compactFrom = DEFAULT_COMPACT_FROM,\n  suffix,\n  className,\n  ...props\n}: MetricValueProps) {\n  const classes = cn(\"tabular-nums\", className)\n\n  if (typeof value !== \"number\") {\n    return (\n      <span data-slot=\"metric-value\" className={classes} {...props}>\n        {value}\n        {suffix}\n      </span>\n    )\n  }\n\n  const full = formatNumber(value, { format, currency, maximumFractionDigits })\n  const abbreviate = format !== \"percent\" && Math.abs(value) >= compactFrom\n  if (!abbreviate) {\n    return (\n      <span data-slot=\"metric-value\" className={classes} {...props}>\n        {full}\n        {suffix}\n      </span>\n    )\n  }\n\n  return (\n    <TooltipProvider>\n      <Tooltip>\n        <TooltipTrigger asChild>\n          <span\n            data-slot=\"metric-value\"\n            data-compact=\"\"\n            className={cn(classes, \"cursor-default\")}\n            {...props}\n          >\n            {formatNumber(value, { format, currency, compact: true })}\n            {suffix}\n          </span>\n        </TooltipTrigger>\n        <TooltipContent className=\"tabular-nums\">\n          {full}\n          {suffix}\n        </TooltipContent>\n      </Tooltip>\n    </TooltipProvider>\n  )\n}\n\nexport { MetricValue }\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"
    }
  ],
  "categories": [
    "dashboard",
    "charts"
  ],
  "type": "registry:block"
}