FunnelChart
A typed funnel chart for stage-by-stage progression and drop-off.
Usage
import { FunnelChart } from '@acronis-platform/ui-react';
import type { ChartConfig } from '@acronis-platform/ui-react';FunnelChart is a typed composition over the shared Chart
primitives. Give it data (one row per stage), a per-stage config, the value
field (dataKey), and the label field (nameKey) — it renders a themed recharts
funnel with a tooltip and a two-column legend beside it, so you don't
hand-compose recharts children.
Nothing else is required. A funnel with only the data contract set draws four
stages painted from the sequential blue ramp, no text on the plot
(showLabels is false), the hover tooltip on (showTooltip), and the legend
beside the funnel (showLegend is true). Stage labels stay off the plot
because the legend already names every stage; turn on showLabels when you want
them on the funnel as well.
Layout
The root is a flex row: a 120×120 square plot, a 16px gutter, then the legend
column, which takes the width that's left. PieChart
and RadialBarChart are composed the same way.
FunnelChart brings no width and no height of its own. It fills whatever its
parent gives it: the plot stays a 120px square and the legend column absorbs the
surplus, so a wider widget gets a wider legend rather than a taller wedge. Size
the parent, not the chart — in a dashboard cell, pass className="size-full" and
let the grid decide.
The legend sits on the inline end (the right in LTR), never below the plot.
FunnelChart is card-less. The card, its header, the ⋯ actions menu and the
metric row all belong to ChartWidget, so the chart
itself depends on no Card and reads just as well in a table cell or a popover:
<ChartWidget header={{ title: 'Conversion', actions: <WidgetMenu /> }}>
<FunnelChart
config={config}
data={data}
dataKey="value"
nameKey="stage"
className="size-full"
/>
</ChartWidget>Examples
A conversion funnel with the default chrome — legend on the right, tooltip on hover, no text on the plot:
const data = [
{ stage: 'Visits', value: 5000 },
{ stage: 'Signups', value: 2600 },
{ stage: 'Trials', value: 1400 },
{ stage: 'Purchases', value: 620 },
];
const config = {
Visits: { label: 'Visits' },
Signups: { label: 'Signups' },
Trials: { label: 'Trials' },
Purchases: { label: 'Purchases' },
} satisfies ChartConfig;
<FunnelChart config={config} data={data} dataKey="value" nameKey="stage" />;End the funnel flat instead of at a point with lastShape="rectangle":
<FunnelChart
config={config}
data={data}
dataKey="value"
nameKey="stage"
lastShape="rectangle"
/>Flip the direction with reversed so the funnel widens toward the bottom, or
drop the tooltip with showTooltip={false}:
<FunnelChart
config={config}
data={data}
dataKey="value"
nameKey="stage"
reversed
showTooltip={false}
/>Stage geometry
Each stage keeps 2px of surface between itself and the stage above it, and
its corners are rounded by 2px. With the default lastShape="triangle" the
final stage narrows to a point; lastShape="rectangle" ends it flat, as a stack
of trapezoids. reversed stands the whole stack on its head, and the stage that
ends up at the top of the plot is the one that takes no gap — the funnel's own
outer edge is never a seam.
stroke / strokeWidth add a border to every stage, funnelWidth narrows the
shape (px, or a percentage of the plot area), and showActiveShape outlines the
hovered stage rather than changing its fill, so it keeps the colour that ties it
to its legend entry.
strokeWidth on its own is enough: the renderer's own default stage border is a
hardcoded white that the chart container neutralizes, so a bare strokeWidth
would otherwise widen an invisible line. Given alone it pairs with
--ui-border-on-surface-border.
<FunnelChart
config={config}
data={data}
dataKey="value"
nameKey="stage"
stroke="var(--ui-border-on-surface-border)"
strokeWidth={2}
funnelWidth="65%"
showActiveShape
/>Coloring
palette is the only source of a stage's colour, apart from a per-stage
stageSettings.color. config carries each stage's label and an optional
tone, not its colour — see Palettes.
FunnelChart resolves the palette into the --color-<name> custom properties
its stages fill from, and hands the legend markers the same resolved colours.
The default is the sequential blue ramp, exported as
FUNNEL_CHART_DEFAULT_PALETTE. Every other chart keeps the shared categorical
default; a funnel doesn't, because its stages are an ordered series — one
quantity dropping from stage to stage — and a ramp reads that order, where four
unrelated categorical hues would not.
import { FUNNEL_CHART_DEFAULT_PALETTE } from '@acronis-platform/ui-react';
// The default, spelled out.
<FunnelChart
config={config}
data={data}
dataKey="value"
nameKey="stage"
palette={FUNNEL_CHART_DEFAULT_PALETTE}
/>;
// Another ramp, when blue is taken by a neighbouring widget.
<FunnelChart
config={config}
data={data}
dataKey="value"
nameKey="stage"
palette={{ type: 'sequential', ramp: 'teal' }}
/>;stageSettings overrides one stage at a time, keyed by its nameKey value:
color wins over the palette, and hidden drops the stage from the funnel, its
labels, the legend, and the conversion percentages.
<FunnelChart
config={config}
data={data}
dataKey="value"
nameKey="stage"
stageSettings={{
Trials: { color: 'var(--ui-background-status-strong-info)' },
Purchases: { hidden: true },
}}
/>Use the strong status tokens (--ui-background-status-strong-info, …) for a
stageSettings colour, not the plain --ui-background-status-* ones — those are
the light tints meant for banners and they wash out as a chart fill.
Legend
showLegend is on by default and renders one entry per visible stage as two
columns: the coloured dot and the config label on the inline start, the stage's
dataKey value on the inline end. Label and value both use the primary
on-surface text token (--ui-text-on-surface-primary); the value is semibold.
legendValueFormatter formats it:
<FunnelChart
config={config}
data={data}
dataKey="value"
nameKey="stage"
legendValueFormatter={(value) => Number(value).toLocaleString()}
/>Turn it off with showLegend={false} and the plot centres in the space it has —
turn on showLabels too, or nothing names the stages any more.
<FunnelChart
config={config}
data={data}
dataKey="value"
nameKey="stage"
showLegend={false}
showLabels
/>Labels
showLabels is off by default. When you turn it on, labelFormat decides what
each stage's label says: its name (the default), its value, its percent, or
a pair — name-value, name-percent, value-percent.
A percentage is the stage's share of the widest stage — its conversion from
the top of the funnel. Unlike a pie's slices, a funnel's stages are nested subsets
of each other rather than parts of a whole, so a share of the sum wouldn't mean
anything. The base is the largest value, not the first row, so an unsorted funnel
still tops out at 100.0%.
<FunnelChart
config={config}
data={data}
dataKey="value"
nameKey="stage"
showLabels
labelFormat="name-percent"
/>labelPosition puts the label beside the stage (right, the default, or left)
or on it (inside). The label colour follows: beside the funnel it sits on the
card surface, on a stage it switches to the on-fill token so it keeps its contrast
over a saturated stage colour. Override it with labelFill only when neither
works on your surface.
inside only works while every stage is wide enough to hold its text. A
funnel narrows by definition, so its last stages usually aren't — the text
runs past the stage onto the surface, where the on-fill colour (white in both
themes) has nothing to sit on and disappears in light mode. Pair inside with
a short format (percent, value) and let the legend carry the names, or
keep the labels beside the funnel.
showValueLabels adds a second label carrying the value. valuePosition places
it, and defaults to the side opposite labelPosition — so it follows the names
rather than pinning itself to one edge, and the two lists never stack:
<FunnelChart
config={config}
data={data}
dataKey="value"
nameKey="stage"
showLabels
showValueLabels
/>labelFormatter formats only the numeric part of a label, so a label and its
tooltip can share one formatter. percentFormatter formats the conversion share
separately — it receives a fraction (0.52) and defaults to "52.0%":
import { createTickFormatter } from '@acronis-platform/ui-react';
<FunnelChart
config={config}
data={data}
dataKey="value"
nameKey="stage"
showLabels
labelFormat="name-percent"
percentFormatter={createTickFormatter(
{ style: 'percent', minimumFractionDigits: 1 },
'de-DE'
)}
/>;You don't normally need margin or funnelWidth: a composite labelFormat
beside the funnel narrows the funnel on its own to leave the label room to wrap
into.
Label room comes from funnelWidth, not margin. A label word-wraps against
the gap between its own stage and the plot area's edge — and a margin
moves that edge inward together with the funnel, so widening margin.right
leaves a long label with less room, not more. Narrowing the funnel is what
frees real space, because the plot area stays put. There is no equivalent
lever for labelPosition="left": the widest stage always sits flush against
the plot area's left edge, so a long left-hand label wraps regardless.
margin is merged over the defaults per side, so margin={{ right: 160 }} keeps
the default top, bottom and left. Reach for it when a label can't wrap at all (one
long word) and you need more room before the SVG edge.
Custom tooltip
Replace the tooltip with a configured ChartTooltipContent — imported from the
same library, so you never compose recharts yourself. Its formatter renders
each row and labelFormatter the header, giving you custom formatting, per-series
content, and extra fields:
import { FunnelChart, ChartTooltipContent } from '@acronis-platform/ui-react';
<FunnelChart
config={config}
data={data}
dataKey="value"
nameKey="stage"
tooltipContent={
<ChartTooltipContent
nameKey="stage"
hideLabel
formatter={(value, name) => `${name}: ${value.toLocaleString()}`}
/>
}
/>;Animation
Charts render statically by default. Opt in to an entrance animation with
animate, and tune it with animationDuration / animationBegin /
animationEasing:
<FunnelChart
config={config}
data={data}
dataKey="value"
nameKey="stage"
animate
animationDuration={800}
animationEasing="ease-out"
/>animate honors prefers-reduced-motion: for a visitor who has asked their
system to reduce motion, the series render at their final geometry with no
animation (the same applies when rendering on the server).
Accessibility
FunnelChart sets no role and no aria-* of its own, and the SVG the funnel is
drawn into has no accessible name. That gap belongs to this component rather than
recharts: recharts takes title, desc and role as first-class chart props and
writes them straight into the plot's <svg>, but FunnelChart passes none of them
down — it spreads the props you give it onto its own root <div> and hands the
recharts chart only the data it computes itself, so the <title> / <desc> pair
recharts always emits stays empty. recharts' accessibility layer is on by default
and is not switched off here, so the plot is also a tab stop carrying
role="application": a focus stop that announces neither a name nor any data. The
component exposes no way to turn that off.
So put the name on the root, which spreads the props you pass. Choose the role
before copying the snippet: role="img" names the chart but also makes its subtree
presentational, and it does not remove the focusable plot inside it — wrapping the
chart in a <figure> with a <figcaption> is often the better fit here.
aria-labelledby works the same way as aria-label.
<FunnelChart
role="img"
aria-label="Conversion funnel: 5,000 visits, 2,600 signups, 1,400 trials, 620 purchases"
config={config}
data={data}
dataKey="value"
nameKey="stage"
/>Either way, a name is not a text alternative. A funnel is a visual encoding of
numbers, so the numbers themselves have to be reachable as text — a caption, a
summary sentence, or a table beside the chart. The default funnel puts nothing on
the plot at all, so the legend carries the whole reading: it is ordinary HTML laid
out beside the SVG, naming every stage and its value in the document. Turn on
showLabels / showValueLabels and those are real <text> rather than pixels,
but they sit in the SVG with no structural role, and a stage's name and its value
end up as two separate label lists on opposite sides with nothing tying them
together.
Direction (RTL)
Under dir="rtl" the chrome around the plot — the legend, the tooltip, and any
readouts you compose alongside them — mirrors on its own, because it is ordinary
flow layout built from logical CSS utilities. The plot area deliberately stays
left-to-right: recharts places every mark in physical SVG coordinates, so mirroring
it would flip the reading order of the data without flipping the geometry that
encodes it. ChartContainer holds that in place by pinning .recharts-surface to
direction: ltr.
For a funnel the pin is what fixes the labels, not just the stages. A funnel's
labels are inside the plot, and recharts anchors a left- or right-positioned
label with the direction-relative SVG keywords text-anchor: start / end —
under an inherited dir="rtl" those would mirror each label back across the
stage it names, while the stage itself stayed put. With the pin they don't,
which is why labelPosition and valuePosition name physical sides: right is
the right of the funnel in both directions, and the plot-area inset reserved for
a label follows the same side. labelPosition="inside" is anchored middle and is
direction-immune either way. The pin stops at the surface, so the legend and the
tooltip — HTML outside it — still mirror with the page.
API Reference
Prop
Type