Funnel Chart
Animated conversion funnel card.
Funnel
An animated conversion funnel.
Sign-up funnel
197
+5.2%Link opened
197Started
110Completed
77Converted
38function 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.jsonnpx shadcn@latest add https://boardcn.dev/r/funnel-chart-card.jsonBoardCN dependencies
The CLI installs these for you — you do not need to add them yourself.
Source
The file the CLI copies into your project.
"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`.
| Prop | Type | Default | Description |
|---|---|---|---|
| stagesrequired | readonly FunnelChartStage[] | — | — |
| className | string | — | — |
| defaultRange | string | — | — |
| delta | number | — | — |
| format | (value: number) => string | — | — |
| headline | number | — | — |
| mono | boolean | false | Draw every stage with the semantic single-ink tone. |
| onRangeChange | (rangeId: string) => void | — | — |
| range | string | — | Static period pill. `ranges` takes precedence when both are supplied. |
| ranges | readonly FunnelChartRange[] | — | — |
| shape | "curved" | "sharp" | curved | — |
| title | string | Sign-up funnel | — |