For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to documentation URLs. Homepage markdown is available at /llms.md/content.md. An agent skill is available at /.well-known/agent-skills/site-skill.md.

Funnel Chart Card 01

A horizontal funnel with selectable ranges and stage details.

Installation

Set up the @cobalt registry first, as described in Installation, then add the block:

$ pnpm dlx shadcn@latest add @cobalt/funnel-chart-card-01

Pass stages as an ordered list of labels and values. Percentages are relative to the first stage. Use shape="sharp" for straight edges, mono for a single color, and ranges for a period selector. Each range can supply its own stages, headline, and fractional delta. Set popupClassName when a scoped theme must also apply to the menu.

Source

"use client";

import { RiArrowDownSLine, RiCalendarLine } from "@remixicon/react";
import { cn } from "cn";
import * as React from "react";

import { useCountUp } from "@/components/hooks/use-count-up";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
  Card,
  CardContent,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Reveal } from "@/components/ui/reveal";

export interface FunnelStage {
  label: string;
  value: number;
  color?: string;
}
export interface FunnelRange {
  label: string;
  stages: FunnelStage[];
  delta?: number;
  headline?: number;
}

const colors = [
  "var(--funnel-chart-1, var(--revenue-chart-fill, var(--chart-1)))",
  "var(--funnel-chart-2, var(--primary))",
  "var(--funnel-chart-3, var(--chart-5))",
  "var(--funnel-chart-4, var(--chart-3))",
  "var(--funnel-chart-5, var(--chart-4))",
];

// Slices squash open from the middle (450ms), each 40ms after the previous
// one, so the funnel reads as drawn rather than switched on.
const segmentIn = "funnel-chart-card-01-segment-in";

export function FunnelChartCard({
  title,
  stages,
  ranges,
  delta,
  headline,
  shape = "curved",
  mono = false,
  className,
  popupClassName,
}: {
  title: string;
  stages: FunnelStage[];
  ranges?: FunnelRange[];
  delta?: number;
  headline?: number;
  shape?: "curved" | "sharp";
  mono?: boolean;
  className?: string;
  popupClassName?: string;
}) {
  const [rangeIndex, setRangeIndex] = React.useState(0);
  const [active, setActive] = React.useState<number | null>(null);
  const range = ranges?.[rangeIndex];
  const data = range?.stages ?? stages;
  const first = Math.max(0, data[0]?.value ?? 0);
  const selected = active === null ? undefined : data[active];
  const total = selected?.value ?? range?.headline ?? headline ?? first;
  const change = range?.delta ?? delta;
  const totalRef = useCountUp<HTMLSpanElement>(total);
  const height = (value: number) =>
    first > 0 ? Math.max(0, Math.min(100, (value / first) * 100)) : 0;

  return (
    <Reveal
      variant="card"
      render={
        <Card
          radius={16}
          variant="muted"
          role="region"
          aria-label={title}
          // oxlint-disable-next-line shadcn/no-restyle -- reference: 20px between header, funnel and legend
          className={cn("@container/funnel min-w-0 gap-5", className)}
        />
      }
    >
      <CardHeader className="min-w-0">
        <div className="flex items-start justify-between gap-3">
          <div className="flex min-w-0 flex-col">
            <CardTitle variant="secondary" className="min-w-0">
              <span className="block truncate">{selected?.label ?? title}</span>
            </CardTitle>
            <div className="flex min-w-0 items-center gap-2">
              <span
                ref={totalRef}
                className="text-2xl leading-8 font-medium tabular-nums"
              >
                {total.toLocaleString("en-US")}
              </span>
              {active === null && change !== undefined && (
                <Badge variant="label" tone={change < 0 ? "rose" : "lime"}>
                  {change > 0 ? "+" : ""}
                  {(change * 100).toFixed(1)}%
                </Badge>
              )}
            </div>
          </div>
          {ranges && ranges.length > 0 && (
            <DropdownMenu>
              <DropdownMenuTrigger
                aria-label={`Range: ${range?.label ?? ""}`}
                render={<Button variant="outline" size="sm" />}
              >
                <RiCalendarLine data-icon="inline-start" />
                {/* In a narrow card the label is dropped instead of squeezed to
                  one letter; the menu trigger keeps its aria-label. */}
                <span className="hidden @min-[220px]/card-header:inline">
                  {range?.label}
                </span>
                <RiArrowDownSLine
                  data-icon="inline-end"
                  className="transition-transform duration-150 ease-out group-aria-expanded/button:rotate-180 motion-reduce:transition-none"
                />
              </DropdownMenuTrigger>
              <DropdownMenuContent
                align="end"
                className={cn("w-40", popupClassName)}
              >
                <DropdownMenuGroup>
                  <DropdownMenuRadioGroup
                    value={String(rangeIndex)}
                    onValueChange={(value) => {
                      setRangeIndex(Number(value));
                      setActive(null);
                    }}
                  >
                    {ranges.map((item, index) => (
                      <DropdownMenuRadioItem
                        key={item.label}
                        value={String(index)}
                        closeOnClick
                      >
                        {item.label}
                      </DropdownMenuRadioItem>
                    ))}
                  </DropdownMenuRadioGroup>
                </DropdownMenuGroup>
              </DropdownMenuContent>
            </DropdownMenu>
          )}
        </div>
      </CardHeader>
      {data.length > 0 && (
        <CardContent
          className="flex h-[148px] min-w-0 flex-row gap-0"
          onMouseLeave={() => setActive(null)}
        >
          <div key={rangeIndex} className="flex h-full w-full min-w-0">
            {data.map((stage, index) => {
              const h = height(stage.value);
              const next = height(data[index + 1]?.value ?? stage.value);
              const top = (100 - h) / 2;
              const bottom = 100 - top;
              const nextTop = (100 - next) / 2;
              const nextBottom = 100 - nextTop;
              const path =
                shape === "sharp"
                  ? `M 0 ${top} L 100 ${nextTop} L 100 ${nextBottom} L 0 ${bottom} Z`
                  : `M 0 ${top} C 55 ${top} 55 ${nextTop} 100 ${nextTop} L 100 ${nextBottom} C 55 ${nextBottom} 55 ${bottom} 0 ${bottom} Z`;
              const color = mono
                ? "var(--funnel-chart-mono, var(--muted-foreground))"
                : (stage.color ?? colors[index % colors.length]);
              const faded = active !== null && active !== index;
              return (
                <Button
                  key={`${stage.label}-${index}`}
                  type="button"
                  variant="unstyled"
                  focusRing="none"
                  onMouseDown={(event) => event.preventDefault()}
                  aria-label={`${stage.label}: ${stage.value.toLocaleString()}, ${Math.round(h)} percent of first stage`}
                  onMouseEnter={() => setActive(index)}
                  onFocus={() => setActive(index)}
                  onBlur={() => setActive(null)}
                  className="relative h-full flex-1 cursor-default"
                >
                  <span
                    className={cn(
                      "pointer-events-none absolute inset-0 flex items-center justify-center transition-opacity duration-150 ease-out motion-reduce:transition-none",
                      faded && "opacity-50"
                    )}
                  >
                    <svg
                      aria-hidden="true"
                      viewBox="0 0 100 100"
                      preserveAspectRatio="none"
                      style={
                        { "--delay": `${index * 40}ms` } as React.CSSProperties
                      }
                      className={cn(
                        "absolute inset-0 size-full origin-center overflow-visible",
                        segmentIn
                      )}
                    >
                      <path
                        d={path}
                        fill="none"
                        opacity={shape === "curved" ? 0.1 : 0}
                        stroke={color}
                        strokeWidth="12"
                        vectorEffect="non-scaling-stroke"
                      />
                      <path
                        d={path}
                        fill="none"
                        opacity={shape === "curved" ? 0.2 : 0}
                        stroke={color}
                        strokeWidth="6"
                        vectorEffect="non-scaling-stroke"
                      />
                      <path
                        d={path}
                        fill={color}
                        vectorEffect="non-scaling-stroke"
                        className="group-focus-visible/button:stroke-ring group-focus-visible/button:stroke-2"
                      />
                    </svg>
                    <span className="relative rounded-full bg-background px-2 py-1 text-xs leading-none font-medium text-foreground tabular-nums">
                      {Math.round(h)}%
                    </span>
                  </span>
                </Button>
              );
            })}
          </div>
        </CardContent>
      )}
      <CardFooter className="min-w-0">
        <dl className="grid min-w-0 flex-1 grid-cols-2 gap-1 @min-[340px]/funnel:grid-cols-4">
          {data.map((stage, index) => (
            <div
              key={`${stage.label}-${index}`}
              className="min-w-0 rounded-[8px] bg-(--stat-footer-surface,var(--background)) px-2 py-2"
            >
              {/* oxlint-disable-next-line shadcn/no-arbitrary-values -- reference: legend tile label is 13px */}
              <dt className="flex min-w-0 items-center gap-1.5 text-[13px] text-muted-foreground">
                {!mono && (
                  <span
                    aria-hidden
                    className="size-2 shrink-0 rounded-[2px] bg-(--swatch)"
                    style={
                      {
                        "--swatch":
                          stage.color ?? colors[index % colors.length],
                      } as React.CSSProperties
                    }
                  />
                )}
                <span className="truncate">{stage.label}</span>
              </dt>
              <dd className="mt-0.5 text-sm font-medium tabular-nums">
                {stage.value.toLocaleString()}
              </dd>
            </div>
          ))}
        </dl>
      </CardFooter>
    </Reveal>
  );
}