Funnel Chart

Animated conversion funnel card.

Funnel

An animated conversion funnel.

Sign-up funnel

197

+5.2%
Link opened
197
Started
110
Completed
77
Converted
38
function FunnelChartDemo() {
  return <FunnelChartCard stages={FUNNEL_STAGES} ranges={FUNNEL_RANGES} />;
}
function FunnelChartDemo() {
  return <FunnelChartCard stages={FUNNEL_STAGES} ranges={FUNNEL_RANGES} />;
}

Installation

npx shadcn@latest add https://boardcn.dev/r/funnel-chart-card.json
npx shadcn@latest add https://boardcn.dev/r/funnel-chart-card.json

BoardCN dependencies

The CLI installs these for you — you do not need to add them yourself.

Source

The file the CLI copies into your project.

components/charts/funnel-chart-card.tsx
"use client";

import {
  useEffect,
  useRef,
  useState,
  type CSSProperties,
  type FocusEvent,
  type KeyboardEvent,
} from "react";
import { Chip } from "@/components/base/badges/chip";
import { ChartRangeControl } from "@/components/charts/chart-range-control";
import { useCountUp } from "@/hooks/use-count-up";
import { cx } from "@/utils/cx";

export type FunnelChartShape = "curved" | "sharp";

export interface FunnelChartStage {
  label: string;
  value: number;
  /** Overrides the stage's automatically assigned chart-palette colour. */
  color?: string;
  /** Optional stronger colour used while the stage is active. */
  activeColor?: string;
}

export interface FunnelChartRange {
  id: string;
  label: string;
  stages: readonly FunnelChartStage[];
  /** Decimal change, for example `0.052` renders as `+5.2%`. */
  delta?: number;
  /** Resting headline for this range. Defaults to its first stage's value. */
  headline?: number;
}

/** Concise aliases retained for consumers that model the funnel data directly. */
export type FunnelStage = FunnelChartStage;
export type FunnelRange = FunnelChartRange;

export interface FunnelChartCardProps {
  shape?: FunnelChartShape;
  /** Draw every stage with the semantic single-ink tone. */
  mono?: boolean;
  title?: string;
  stages: readonly FunnelChartStage[];
  headline?: number;
  delta?: number;
  /** Static period pill. `ranges` takes precedence when both are supplied. */
  range?: string;
  ranges?: readonly FunnelChartRange[];
  defaultRange?: string;
  onRangeChange?: (rangeId: string) => void;
  format?: (value: number) => string;
  className?: string;
}

type Tone = {
  color: string;
  activeColor: string;
};

const PALETTE_ORDER = [2, 6, 5, 3, 8, 7, 4, 1] as const;
const PALETTE: readonly Tone[] = PALETTE_ORDER.map((index) => ({
  color: `var(--color-chart-${index})`,
  activeColor: `var(--color-chart-${index}-active)`,
}));

const MONO_TONE: Tone = {
  color:
    "light-dark(var(--color-neutral-500), color-mix(in srgb, var(--color-neutral-50) 84%, transparent))",
  activeColor: "light-dark(var(--color-neutral-600), var(--color-neutral-50))",
};

const EDGE_LAYERS = [
  { pad: 12, opacity: 0.1 },
  { pad: 6, opacity: 0.22 },
] as const;

const OPACITY_TRANSITION =
  "transition-opacity duration-200 ease-out motion-reduce:transition-none";

export function formatFunnelNumber(value: number): string {
  return value.toLocaleString("en-US");
}

function resolveTone(stage: FunnelChartStage, index: number, mono: boolean): Tone {
  if (mono) return MONO_TONE;
  if (stage.color) {
    return {
      color: stage.color,
      activeColor:
        stage.activeColor ??
        `color-mix(in srgb, ${stage.color} 82%, black)`,
    };
  }
  return PALETTE[index % PALETTE.length];
}

function describeDelta(delta: number): {
  label: string;
  color: "neutral" | "lime" | "rose";
} {
  const percentage = Math.round(Math.abs(delta) * 1000) / 10;
  if (percentage === 0) return { label: "0.0%", color: "neutral" };
  return {
    label: `${delta > 0 ? "+" : "-"}${percentage}%`,
    color: delta > 0 ? "lime" : "rose",
  };
}

function useAnimatedNumber(value: number): number {
  const precision = Number.isInteger(value)
    ? 0
    : Math.abs(10 * value - Math.round(10 * value)) < 0.000001
      ? 1
      : 2;
  const multiplier = 10 ** precision;
  return useCountUp(Math.round(value * multiplier)) / multiplier;
}

function useElementSize<T extends HTMLElement>() {
  const ref = useRef<T>(null);
  const [size, setSize] = useState({ width: 0, height: 0 });

  useEffect(() => {
    const element = ref.current;
    if (!element) return;

    const measure = () =>
      setSize({ width: element.clientWidth, height: element.clientHeight });

    measure();
    if (typeof ResizeObserver === "undefined") {
      window.addEventListener("resize", measure);
      return () => window.removeEventListener("resize", measure);
    }

    const observer = new ResizeObserver(measure);
    observer.observe(element);
    return () => observer.disconnect();
  }, []);

  return { ref, ...size };
}

function funnelPath(
  x0: number,
  x1: number,
  centerY: number,
  startHeight: number,
  endHeight: number,
  shape: FunnelChartShape,
): string {
  const startTop = centerY - startHeight / 2;
  const startBottom = centerY + startHeight / 2;
  const endTop = centerY - endHeight / 2;
  const endBottom = centerY + endHeight / 2;

  if (shape === "sharp") {
    return `M${x0},${startTop} L${x1},${endTop} L${x1},${endBottom} L${x0},${startBottom} Z`;
  }

  const straightEnd = x0 + (x1 - x0) * 0.42;
  const curveControl = (straightEnd + x1) / 2;
  return `M${x0},${startTop} L${straightEnd},${startTop} C${curveControl},${startTop} ${curveControl},${endTop} ${x1},${endTop} L${x1},${endBottom} C${curveControl},${endBottom} ${curveControl},${startBottom} ${straightEnd},${startBottom} L${x0},${startBottom} Z`;
}

/**
 * Horizontal funnel card matching BoardCN's curved/sharp, palette/mono, range,
 * hover and responsive behaviours. Geometry is derived entirely from `stages`.
 */
export function FunnelChartCard({
  shape = "curved",
  mono = false,
  title = "Sign-up funnel",
  stages,
  headline,
  delta,
  range,
  ranges,
  defaultRange,
  onRangeChange,
  format = formatFunnelNumber,
  className,
}: FunnelChartCardProps) {
  const [activeIndex, setActiveIndex] = useState<number | null>(null);
  const [selectedRangeId, setSelectedRangeId] = useState(defaultRange);
  const { ref, width, height } = useElementSize<HTMLDivElement>();

  const availableRanges = ranges;
  const selectedRange =
    availableRanges?.find((item) => item.id === selectedRangeId) ??
    availableRanges?.[0];
  const resolvedStages = selectedRange?.stages ?? stages;
  const resolvedHeadline = selectedRange?.headline ?? headline;
  const resolvedDelta = selectedRange?.delta ?? delta;
  const tones = resolvedStages.map((stage, index) => resolveTone(stage, index, mono));
  const topValue = Math.max(1, resolvedStages[0]?.value ?? 1);
  const hasActiveStage = activeIndex !== null && activeIndex < resolvedStages.length;
  const headerLabel = hasActiveStage ? resolvedStages[activeIndex].label : title;
  const headerValue = hasActiveStage
    ? resolvedStages[activeIndex].value
    : (resolvedHeadline ?? topValue);
  const animatedHeadline = useAnimatedNumber(headerValue);

  const stageCount = Math.max(1, resolvedStages.length);
  const isCurved = shape === "curved";
  const outerPad = isCurved ? EDGE_LAYERS[0].pad : 0;
  const stageWidth = Math.max(0, (width - 3 * (stageCount - 1)) / stageCount);
  const availableHeight = Math.max(0, height - 2 * outerPad);
  const centerY = height / 2;
  const scaledHeight = (value: number) => Math.max(2, (value / topValue) * availableHeight);
  const xBounds = (index: number) => {
    const x0 = index * (stageWidth + 3);
    return { x0, x1: x0 + stageWidth };
  };
  const stageOpacity = (index: number) =>
    hasActiveStage && activeIndex !== index ? 0.3 : 1;

  const selectRange = (rangeId: string) => {
    setActiveIndex(null);
    setSelectedRangeId(rangeId);
    onRangeChange?.(rangeId);
  };

  const activateFromKeyboard = (
    event: KeyboardEvent<SVGGElement>,
    index: number,
  ) => {
    if (event.key === "Enter" || event.key === " ") {
      event.preventDefault();
      setActiveIndex(index);
    } else if (event.key === "Escape") {
      setActiveIndex(null);
      event.currentTarget.blur();
    }
  };

  const clearOnBlur = (event: FocusEvent<SVGGElement>) => {
    if (!event.currentTarget.ownerSVGElement?.contains(event.relatedTarget)) {
      setActiveIndex(null);
    }
  };

  return (
    <section
      className={cx(
        "flex h-[329px] w-full min-w-0 flex-col gap-4 rounded-2xl bg-background-secondary-default px-4 pt-4 pb-3",
        className,
      )}
      aria-label={`${title} funnel chart`}
    >
      <div className="flex w-full items-start justify-between gap-3">
        <div className="flex min-w-0 flex-1 flex-col gap-0.5">
          <p className="w-full truncate text-body-medium text-text-secondary">{headerLabel}</p>
          <div className="flex w-full items-center gap-2">
            <p
              key={`${selectedRange?.id ?? ""}:${activeIndex ?? "rest"}`}
              className="animate-number-fade text-title-1-medium whitespace-nowrap text-text-primary tabular-nums"
            >
              {format(animatedHeadline)}
            </p>
            {resolvedDelta !== undefined && (
              <Chip
                variant="bold"
                color={describeDelta(resolvedDelta).color}
                className={hasActiveStage ? "invisible" : undefined}
              >
                {describeDelta(resolvedDelta).label}
              </Chip>
            )}
          </div>
        </div>

        {availableRanges && availableRanges.length > 0 ? (
          <ChartRangeControl
            ranges={availableRanges}
            value={selectedRange?.id}
            onValueChange={selectRange}
          />
        ) : range ? (
          <ChartRangeControl label={range} />
        ) : null}
      </div>

      <div ref={ref} className="relative min-h-0 w-full flex-1">
        {width > 0 && height > 0 && (
          <svg
            width={width}
            height={height}
            viewBox={`0 0 ${width} ${height}`}
            className="absolute inset-0 overflow-visible"
            role="group"
            aria-label={`${title}: ${resolvedStages
              .map((stage) => `${stage.label} ${format(stage.value)}`)
              .join(", ")}`}
            onMouseLeave={() => setActiveIndex(null)}
            onPointerLeave={() => setActiveIndex(null)}
          >
            <title>{`${title} funnel stages`}</title>
            {!isCurved &&
              resolvedStages.map((stage, index) => {
                const { x0, x1 } = xBounds(index);
                return (
                  <rect
                    key={`backing-${stage.label}`}
                    x={x0}
                    y={centerY - 18}
                    width={x1 - x0}
                    height={36}
                    fill={tones[index].color}
                    fillOpacity={0.14}
                    opacity={stageOpacity(index)}
                    className={cx("cursor-default", OPACITY_TRANSITION)}
                    onMouseEnter={() => setActiveIndex(index)}
                    onPointerEnter={() => setActiveIndex(index)}
                  />
                );
              })}

            {isCurved &&
              EDGE_LAYERS.map((layer) =>
                resolvedStages.map((stage, index) => {
                  const { x0, x1 } = xBounds(index);
                  const startHeight = scaledHeight(stage.value);
                  const endHeight =
                    index < stageCount - 1
                      ? scaledHeight(resolvedStages[index + 1].value)
                      : startHeight;
                  return (
                    <path
                      key={`layer-${layer.pad}-${stage.label}`}
                      d={funnelPath(
                        x0,
                        x1,
                        centerY,
                        startHeight + 2 * layer.pad,
                        endHeight + 2 * layer.pad,
                        shape,
                      )}
                      fill={tones[index].color}
                      fillOpacity={layer.opacity}
                      opacity={stageOpacity(index)}
                      className={cx("pointer-events-none", OPACITY_TRANSITION)}
                    />
                  );
                }),
              )}

            {resolvedStages.map((stage, index) => {
              const { x0, x1 } = xBounds(index);
              const startHeight = scaledHeight(stage.value);
              const endHeight =
                index < stageCount - 1
                  ? scaledHeight(resolvedStages[index + 1].value)
                  : startHeight;
              const percentage = `${Math.round((stage.value / topValue) * 100)}%`;
              const pillWidth = 7.2 * percentage.length + 16;
              const pillCenterX = (x0 + x1) / 2;
              const showPill = pillWidth <= stageWidth + 3;
              const isActive = activeIndex === index;

              return (
                <g
                  key={stage.label}
                  role="button"
                  tabIndex={0}
                  aria-label={`${stage.label}: ${format(stage.value)}, ${percentage} of funnel entry`}
                  className={cx(
                    "cursor-default outline-none focus-visible:[&>path]:stroke-text-primary focus-visible:[&>path]:stroke-[2px]",
                    OPACITY_TRANSITION,
                  )}
                  onMouseEnter={() => setActiveIndex(index)}
                  onPointerEnter={() => setActiveIndex(index)}
                  onPointerDown={() => setActiveIndex(index)}
                  onFocus={() => setActiveIndex(index)}
                  onBlur={clearOnBlur}
                  onKeyDown={(event) => activateFromKeyboard(event, index)}
                  opacity={stageOpacity(index)}
                >
                  <path
                    d={funnelPath(x0, x1, centerY, startHeight, endHeight, shape)}
                    fill={isActive ? tones[index].activeColor : tones[index].color}
                    className="transition-[fill,opacity,stroke] duration-200 ease-out motion-reduce:transition-none"
                  />
                  {showPill && (
                    <>
                      <rect
                        x={pillCenterX - pillWidth / 2}
                        y={centerY - 10}
                        width={pillWidth}
                        height={20}
                        rx={10}
                        fill="var(--color-background-secondary-default)"
                        className="pointer-events-none"
                      />
                      <text
                        x={pillCenterX}
                        y={centerY}
                        dy={4}
                        textAnchor="middle"
                        fontSize={12}
                        fontWeight={500}
                        fill="var(--color-text-primary)"
                        className="pointer-events-none tabular-nums"
                      >
                        {percentage}
                      </text>
                    </>
                  )}
                </g>
              );
            })}
          </svg>
        )}
      </div>

      <div
        className="-mx-2 -mb-1 grid grid-cols-2 gap-2 sm:[grid-template-columns:repeat(var(--funnel-tiles),minmax(0,1fr))]"
        style={{ "--funnel-tiles": stageCount } as CSSProperties}
      >
        {resolvedStages.map((stage, index) => {
          const isActive = activeIndex === index;
          const isDimmed = hasActiveStage && !isActive;
          return (
            <div
              key={stage.label}
              className={cx(
                "flex min-w-0 flex-col items-start gap-px rounded-2lg bg-background-inner-default px-2.5 py-2",
                OPACITY_TRANSITION,
              )}
              style={{ opacity: isDimmed ? 0.5 : 1 }}
              onMouseEnter={() => setActiveIndex(index)}
              onPointerEnter={() => setActiveIndex(index)}
              onMouseLeave={() => setActiveIndex(null)}
              onPointerLeave={() => setActiveIndex(null)}
            >
              <div className="flex min-w-0 max-w-full items-center gap-1.5">
                {!mono && (
                  <span
                    className="size-3 shrink-0 rounded-[4px] transition-colors duration-150 ease-out motion-reduce:transition-none"
                    style={{
                      backgroundColor: isActive
                        ? tones[index].activeColor
                        : tones[index].color,
                    }}
                    aria-hidden="true"
                  />
                )}
                <span className="truncate text-body-regular text-text-secondary">
                  {stage.label}
                </span>
              </div>
              <span className="text-body-medium whitespace-nowrap text-text-primary tabular-nums">
                {format(stage.value)}
              </span>
            </div>
          );
        })}
      </div>
    </section>
  );
}

export default FunnelChartCard;
"use client";

import {
  useEffect,
  useRef,
  useState,
  type CSSProperties,
  type FocusEvent,
  type KeyboardEvent,
} from "react";
import { Chip } from "@/components/base/badges/chip";
import { ChartRangeControl } from "@/components/charts/chart-range-control";
import { useCountUp } from "@/hooks/use-count-up";
import { cx } from "@/utils/cx";

export type FunnelChartShape = "curved" | "sharp";

export interface FunnelChartStage {
  label: string;
  value: number;
  /** Overrides the stage's automatically assigned chart-palette colour. */
  color?: string;
  /** Optional stronger colour used while the stage is active. */
  activeColor?: string;
}

export interface FunnelChartRange {
  id: string;
  label: string;
  stages: readonly FunnelChartStage[];
  /** Decimal change, for example `0.052` renders as `+5.2%`. */
  delta?: number;
  /** Resting headline for this range. Defaults to its first stage's value. */
  headline?: number;
}

/** Concise aliases retained for consumers that model the funnel data directly. */
export type FunnelStage = FunnelChartStage;
export type FunnelRange = FunnelChartRange;

export interface FunnelChartCardProps {
  shape?: FunnelChartShape;
  /** Draw every stage with the semantic single-ink tone. */
  mono?: boolean;
  title?: string;
  stages: readonly FunnelChartStage[];
  headline?: number;
  delta?: number;
  /** Static period pill. `ranges` takes precedence when both are supplied. */
  range?: string;
  ranges?: readonly FunnelChartRange[];
  defaultRange?: string;
  onRangeChange?: (rangeId: string) => void;
  format?: (value: number) => string;
  className?: string;
}

type Tone = {
  color: string;
  activeColor: string;
};

const PALETTE_ORDER = [2, 6, 5, 3, 8, 7, 4, 1] as const;
const PALETTE: readonly Tone[] = PALETTE_ORDER.map((index) => ({
  color: `var(--color-chart-${index})`,
  activeColor: `var(--color-chart-${index}-active)`,
}));

const MONO_TONE: Tone = {
  color:
    "light-dark(var(--color-neutral-500), color-mix(in srgb, var(--color-neutral-50) 84%, transparent))",
  activeColor: "light-dark(var(--color-neutral-600), var(--color-neutral-50))",
};

const EDGE_LAYERS = [
  { pad: 12, opacity: 0.1 },
  { pad: 6, opacity: 0.22 },
] as const;

const OPACITY_TRANSITION =
  "transition-opacity duration-200 ease-out motion-reduce:transition-none";

export function formatFunnelNumber(value: number): string {
  return value.toLocaleString("en-US");
}

function resolveTone(stage: FunnelChartStage, index: number, mono: boolean): Tone {
  if (mono) return MONO_TONE;
  if (stage.color) {
    return {
      color: stage.color,
      activeColor:
        stage.activeColor ??
        `color-mix(in srgb, ${stage.color} 82%, black)`,
    };
  }
  return PALETTE[index % PALETTE.length];
}

function describeDelta(delta: number): {
  label: string;
  color: "neutral" | "lime" | "rose";
} {
  const percentage = Math.round(Math.abs(delta) * 1000) / 10;
  if (percentage === 0) return { label: "0.0%", color: "neutral" };
  return {
    label: `${delta > 0 ? "+" : "-"}${percentage}%`,
    color: delta > 0 ? "lime" : "rose",
  };
}

function useAnimatedNumber(value: number): number {
  const precision = Number.isInteger(value)
    ? 0
    : Math.abs(10 * value - Math.round(10 * value)) < 0.000001
      ? 1
      : 2;
  const multiplier = 10 ** precision;
  return useCountUp(Math.round(value * multiplier)) / multiplier;
}

function useElementSize<T extends HTMLElement>() {
  const ref = useRef<T>(null);
  const [size, setSize] = useState({ width: 0, height: 0 });

  useEffect(() => {
    const element = ref.current;
    if (!element) return;

    const measure = () =>
      setSize({ width: element.clientWidth, height: element.clientHeight });

    measure();
    if (typeof ResizeObserver === "undefined") {
      window.addEventListener("resize", measure);
      return () => window.removeEventListener("resize", measure);
    }

    const observer = new ResizeObserver(measure);
    observer.observe(element);
    return () => observer.disconnect();
  }, []);

  return { ref, ...size };
}

function funnelPath(
  x0: number,
  x1: number,
  centerY: number,
  startHeight: number,
  endHeight: number,
  shape: FunnelChartShape,
): string {
  const startTop = centerY - startHeight / 2;
  const startBottom = centerY + startHeight / 2;
  const endTop = centerY - endHeight / 2;
  const endBottom = centerY + endHeight / 2;

  if (shape === "sharp") {
    return `M${x0},${startTop} L${x1},${endTop} L${x1},${endBottom} L${x0},${startBottom} Z`;
  }

  const straightEnd = x0 + (x1 - x0) * 0.42;
  const curveControl = (straightEnd + x1) / 2;
  return `M${x0},${startTop} L${straightEnd},${startTop} C${curveControl},${startTop} ${curveControl},${endTop} ${x1},${endTop} L${x1},${endBottom} C${curveControl},${endBottom} ${curveControl},${startBottom} ${straightEnd},${startBottom} L${x0},${startBottom} Z`;
}

/**
 * Horizontal funnel card matching BoardCN's curved/sharp, palette/mono, range,
 * hover and responsive behaviours. Geometry is derived entirely from `stages`.
 */
export function FunnelChartCard({
  shape = "curved",
  mono = false,
  title = "Sign-up funnel",
  stages,
  headline,
  delta,
  range,
  ranges,
  defaultRange,
  onRangeChange,
  format = formatFunnelNumber,
  className,
}: FunnelChartCardProps) {
  const [activeIndex, setActiveIndex] = useState<number | null>(null);
  const [selectedRangeId, setSelectedRangeId] = useState(defaultRange);
  const { ref, width, height } = useElementSize<HTMLDivElement>();

  const availableRanges = ranges;
  const selectedRange =
    availableRanges?.find((item) => item.id === selectedRangeId) ??
    availableRanges?.[0];
  const resolvedStages = selectedRange?.stages ?? stages;
  const resolvedHeadline = selectedRange?.headline ?? headline;
  const resolvedDelta = selectedRange?.delta ?? delta;
  const tones = resolvedStages.map((stage, index) => resolveTone(stage, index, mono));
  const topValue = Math.max(1, resolvedStages[0]?.value ?? 1);
  const hasActiveStage = activeIndex !== null && activeIndex < resolvedStages.length;
  const headerLabel = hasActiveStage ? resolvedStages[activeIndex].label : title;
  const headerValue = hasActiveStage
    ? resolvedStages[activeIndex].value
    : (resolvedHeadline ?? topValue);
  const animatedHeadline = useAnimatedNumber(headerValue);

  const stageCount = Math.max(1, resolvedStages.length);
  const isCurved = shape === "curved";
  const outerPad = isCurved ? EDGE_LAYERS[0].pad : 0;
  const stageWidth = Math.max(0, (width - 3 * (stageCount - 1)) / stageCount);
  const availableHeight = Math.max(0, height - 2 * outerPad);
  const centerY = height / 2;
  const scaledHeight = (value: number) => Math.max(2, (value / topValue) * availableHeight);
  const xBounds = (index: number) => {
    const x0 = index * (stageWidth + 3);
    return { x0, x1: x0 + stageWidth };
  };
  const stageOpacity = (index: number) =>
    hasActiveStage && activeIndex !== index ? 0.3 : 1;

  const selectRange = (rangeId: string) => {
    setActiveIndex(null);
    setSelectedRangeId(rangeId);
    onRangeChange?.(rangeId);
  };

  const activateFromKeyboard = (
    event: KeyboardEvent<SVGGElement>,
    index: number,
  ) => {
    if (event.key === "Enter" || event.key === " ") {
      event.preventDefault();
      setActiveIndex(index);
    } else if (event.key === "Escape") {
      setActiveIndex(null);
      event.currentTarget.blur();
    }
  };

  const clearOnBlur = (event: FocusEvent<SVGGElement>) => {
    if (!event.currentTarget.ownerSVGElement?.contains(event.relatedTarget)) {
      setActiveIndex(null);
    }
  };

  return (
    <section
      className={cx(
        "flex h-[329px] w-full min-w-0 flex-col gap-4 rounded-2xl bg-background-secondary-default px-4 pt-4 pb-3",
        className,
      )}
      aria-label={`${title} funnel chart`}
    >
      <div className="flex w-full items-start justify-between gap-3">
        <div className="flex min-w-0 flex-1 flex-col gap-0.5">
          <p className="w-full truncate text-body-medium text-text-secondary">{headerLabel}</p>
          <div className="flex w-full items-center gap-2">
            <p
              key={`${selectedRange?.id ?? ""}:${activeIndex ?? "rest"}`}
              className="animate-number-fade text-title-1-medium whitespace-nowrap text-text-primary tabular-nums"
            >
              {format(animatedHeadline)}
            </p>
            {resolvedDelta !== undefined && (
              <Chip
                variant="bold"
                color={describeDelta(resolvedDelta).color}
                className={hasActiveStage ? "invisible" : undefined}
              >
                {describeDelta(resolvedDelta).label}
              </Chip>
            )}
          </div>
        </div>

        {availableRanges && availableRanges.length > 0 ? (
          <ChartRangeControl
            ranges={availableRanges}
            value={selectedRange?.id}
            onValueChange={selectRange}
          />
        ) : range ? (
          <ChartRangeControl label={range} />
        ) : null}
      </div>

      <div ref={ref} className="relative min-h-0 w-full flex-1">
        {width > 0 && height > 0 && (
          <svg
            width={width}
            height={height}
            viewBox={`0 0 ${width} ${height}`}
            className="absolute inset-0 overflow-visible"
            role="group"
            aria-label={`${title}: ${resolvedStages
              .map((stage) => `${stage.label} ${format(stage.value)}`)
              .join(", ")}`}
            onMouseLeave={() => setActiveIndex(null)}
            onPointerLeave={() => setActiveIndex(null)}
          >
            <title>{`${title} funnel stages`}</title>
            {!isCurved &&
              resolvedStages.map((stage, index) => {
                const { x0, x1 } = xBounds(index);
                return (
                  <rect
                    key={`backing-${stage.label}`}
                    x={x0}
                    y={centerY - 18}
                    width={x1 - x0}
                    height={36}
                    fill={tones[index].color}
                    fillOpacity={0.14}
                    opacity={stageOpacity(index)}
                    className={cx("cursor-default", OPACITY_TRANSITION)}
                    onMouseEnter={() => setActiveIndex(index)}
                    onPointerEnter={() => setActiveIndex(index)}
                  />
                );
              })}

            {isCurved &&
              EDGE_LAYERS.map((layer) =>
                resolvedStages.map((stage, index) => {
                  const { x0, x1 } = xBounds(index);
                  const startHeight = scaledHeight(stage.value);
                  const endHeight =
                    index < stageCount - 1
                      ? scaledHeight(resolvedStages[index + 1].value)
                      : startHeight;
                  return (
                    <path
                      key={`layer-${layer.pad}-${stage.label}`}
                      d={funnelPath(
                        x0,
                        x1,
                        centerY,
                        startHeight + 2 * layer.pad,
                        endHeight + 2 * layer.pad,
                        shape,
                      )}
                      fill={tones[index].color}
                      fillOpacity={layer.opacity}
                      opacity={stageOpacity(index)}
                      className={cx("pointer-events-none", OPACITY_TRANSITION)}
                    />
                  );
                }),
              )}

            {resolvedStages.map((stage, index) => {
              const { x0, x1 } = xBounds(index);
              const startHeight = scaledHeight(stage.value);
              const endHeight =
                index < stageCount - 1
                  ? scaledHeight(resolvedStages[index + 1].value)
                  : startHeight;
              const percentage = `${Math.round((stage.value / topValue) * 100)}%`;
              const pillWidth = 7.2 * percentage.length + 16;
              const pillCenterX = (x0 + x1) / 2;
              const showPill = pillWidth <= stageWidth + 3;
              const isActive = activeIndex === index;

              return (
                <g
                  key={stage.label}
                  role="button"
                  tabIndex={0}
                  aria-label={`${stage.label}: ${format(stage.value)}, ${percentage} of funnel entry`}
                  className={cx(
                    "cursor-default outline-none focus-visible:[&>path]:stroke-text-primary focus-visible:[&>path]:stroke-[2px]",
                    OPACITY_TRANSITION,
                  )}
                  onMouseEnter={() => setActiveIndex(index)}
                  onPointerEnter={() => setActiveIndex(index)}
                  onPointerDown={() => setActiveIndex(index)}
                  onFocus={() => setActiveIndex(index)}
                  onBlur={clearOnBlur}
                  onKeyDown={(event) => activateFromKeyboard(event, index)}
                  opacity={stageOpacity(index)}
                >
                  <path
                    d={funnelPath(x0, x1, centerY, startHeight, endHeight, shape)}
                    fill={isActive ? tones[index].activeColor : tones[index].color}
                    className="transition-[fill,opacity,stroke] duration-200 ease-out motion-reduce:transition-none"
                  />
                  {showPill && (
                    <>
                      <rect
                        x={pillCenterX - pillWidth / 2}
                        y={centerY - 10}
                        width={pillWidth}
                        height={20}
                        rx={10}
                        fill="var(--color-background-secondary-default)"
                        className="pointer-events-none"
                      />
                      <text
                        x={pillCenterX}
                        y={centerY}
                        dy={4}
                        textAnchor="middle"
                        fontSize={12}
                        fontWeight={500}
                        fill="var(--color-text-primary)"
                        className="pointer-events-none tabular-nums"
                      >
                        {percentage}
                      </text>
                    </>
                  )}
                </g>
              );
            })}
          </svg>
        )}
      </div>

      <div
        className="-mx-2 -mb-1 grid grid-cols-2 gap-2 sm:[grid-template-columns:repeat(var(--funnel-tiles),minmax(0,1fr))]"
        style={{ "--funnel-tiles": stageCount } as CSSProperties}
      >
        {resolvedStages.map((stage, index) => {
          const isActive = activeIndex === index;
          const isDimmed = hasActiveStage && !isActive;
          return (
            <div
              key={stage.label}
              className={cx(
                "flex min-w-0 flex-col items-start gap-px rounded-2lg bg-background-inner-default px-2.5 py-2",
                OPACITY_TRANSITION,
              )}
              style={{ opacity: isDimmed ? 0.5 : 1 }}
              onMouseEnter={() => setActiveIndex(index)}
              onPointerEnter={() => setActiveIndex(index)}
              onMouseLeave={() => setActiveIndex(null)}
              onPointerLeave={() => setActiveIndex(null)}
            >
              <div className="flex min-w-0 max-w-full items-center gap-1.5">
                {!mono && (
                  <span
                    className="size-3 shrink-0 rounded-[4px] transition-colors duration-150 ease-out motion-reduce:transition-none"
                    style={{
                      backgroundColor: isActive
                        ? tones[index].activeColor
                        : tones[index].color,
                    }}
                    aria-hidden="true"
                  />
                )}
                <span className="truncate text-body-regular text-text-secondary">
                  {stage.label}
                </span>
              </div>
              <span className="text-body-medium whitespace-nowrap text-text-primary tabular-nums">
                {format(stage.value)}
              </span>
            </div>
          );
        })}
      </div>
    </section>
  );
}

export default FunnelChartCard;

Props

Generated from the component's TypeScript types. Standard DOM and React Aria props are omitted.

FunnelChartCard

Horizontal funnel card matching BoardCN's curved/sharp, palette/mono, range, hover and responsive behaviours. Geometry is derived entirely from `stages`.

PropTypeDefaultDescription
stagesrequiredreadonly FunnelChartStage[]
classNamestring
defaultRangestring
deltanumber
format(value: number) => string
headlinenumber
monobooleanfalseDraw every stage with the semantic single-ink tone.
onRangeChange(rangeId: string) => void
rangestringStatic period pill. `ranges` takes precedence when both are supplied.
rangesreadonly FunnelChartRange[]
shape"curved" | "sharp"curved
titlestringSign-up funnel