Data Storytelling

Funnel Chart

An SVG conversion funnel React component: trapezoid stages grow in one after another, stage-to-stage conversion labels sit between layers, hovering highlights a stage with a tooltip, in vertical and horizontal modes with a screen reader data table.

The chart behind every sign-up, checkout and trial-to-paid flow. Each stage is drawn as a symmetric trapezoid whose top width matches the bottom width of the stage above, with widths proportional to the values; very small stages keep at least 8% width so they never disappear. On entry the stages grow in one by one, 120ms apart, while their values fade in. Small labels between layers show the conversion relative to the previous stage, and hovering or keyboard-focusing a stage brightens it and shows a tooltip (stage name, value, share of the first stage); clicking selects it and fires onStageClick. When a label does not fit inside its trapezoid it moves to the side with a leader line.

Bring this into your project

Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.

npx webberui@latest add funnel-chart.en

New project or installation trouble? Start here →

Playground

Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.

6
320
Active edited usageDefault props
<FunnelChart/>

The complete example includes the preview layout and data. Install the component first, then paste this file into your project.

Installation

npx webberui@latest add https://webberui.com/r/funnel-chart.en.json

Or, once registries are configured in components.json, install it as @webberui/funnel-chart.

Usage

import { FunnelChart } from "@/components/ui/funnel-chart";

<FunnelChart
  stages={[
    { id: "visit", label: "Visited home", value: 12480 },
    { id: "start", label: "Started sign-up", value: 6240 },
    { id: "form", label: "Filled details", value: 4180 },
    { id: "verify", label: "Verified email", value: 3050 },
    { id: "setup", label: "Finished setup", value: 2120 },
  ]}
  orientation="vertical"
  totalLabel="Overall conversion"
  valueFormatter={(v) => `${v.toLocaleString()} users`}
  onStageClick={(stage) => console.log(stage.label)}
/>

Props

PropTypeDefaultDescription
stagesFunnelStage[]—Stage data, top to bottom (vertical) or left to right (horizontal); usually decreasing, but non-decreasing data is drawn as-is
orientation"vertical" | "horizontal""vertical"Funnel direction
showConversionbooleantrueShow the conversion label (relative to the previous stage) between layers
showValuesbooleantrueShow the value under each label
valueFormatter(value: number) => stringthousands-separated integerValue formatting function
totalLabelstring—When provided, shows the overall conversion (last ÷ first) above the chart with this string as its title
heightnumber320Chart height (px); width is always 100% of the container
gapnumber6Spacing between layers (px)
selectedIdstring | null—Controlled selected stage id; when omitted the component manages it internally (uncontrolled)
defaultSelectedIdstring | nullnullInitial selected stage in uncontrolled mode
onSelectedChange(id: string | null) => void—Callback when the selection changes; clicking the selected stage again returns null
onStageClick(stage: FunnelStage, index: number) => void—Callback when a stage is clicked or activated from the keyboard
ariaLabelstring"轉換漏斗"Accessible name of the chart, used for the summary and data table caption (default reads "conversion funnel")
classNamestring—Forwarded to the outermost container

FunnelStage

FieldTypeDefaultDescription
idstring—Unique id, used for keys and selection
labelstring—Stage name
valuenumber—Stage value; negatives are treated as 0
colorstringbuilt-in color cycleOverride this stage's color (any valid CSS color)

formatFunnelPercent(ratio) is also a named export: integers at 10% and above, one decimal below that, and "—" when the denominator is 0, so summary text outside the chart can reuse the same format.

How it works

  • Trapezoid geometry: stage i's top width maps to stages[i].value and its bottom width to the next stage's value, so adjacent edges join seamlessly; the last stage has no next stage, so its bottom narrows to 82% of its top.
  • Color: by default a 6-color cycle from deep to light in the primary hue, overridable per stage with stage.color; text inside each layer switches between dark and white based on the fill's brightness.
  • Conversion labels: drawn left of the layer boundary in vertical mode, and above it with a thin connector line in horizontal mode; increases are green with ↑, drops below half are red, everything else is neutral.
  • Label placement: it first tries to place every label inside its trapezoid; only if some stage does not fit (too narrow or too short) does it reserve side space (right in vertical mode, bottom in horizontal mode) and move those labels out with a dashed leader line.
  • Width follows the container: a ResizeObserver measures the real pixel width and recomputes the viewBox, so text keeps its font size instead of scaling with the container; switching orientation replays the grow-in animation.

Accessibility

  • The SVG is role="img" with an aria-label summary listing every stage's name, value and relative conversion, plus aria-describedby pointing at an sr-only data table; each layer also carries a <title>
  • Each layer is covered by a native button clipped to the trapezoid with clip-path: Tab focuses it, arrow keys move between stages, Home / End jump to the ends, and Enter or Space selects; aria-pressed reflects the selection
  • Keyboard focus is shown with a 3px sky-blue outline on the layer, together with the same tooltip as hover
  • When the user has reduce motion enabled at the system level, stages appear fully drawn without the grow and fade animations, and the tooltip switches instantly