Funnel
Steps from first visit to the goal, with the share of the start and the conversion between every pair of steps.
One of 7 components in Charts
Details
What Funnel brings along, read from the registry and the source.
- Category
- Charts
- Area
- Elements
- Variants
- 2
- Version
- 1.0.0 · since 1.0.0
- File
- components/ui/funnel.tsx
- Brings along
- utilskitcharticon
- npm packages
- None
- Languages
- English and German, 8 texts of its own
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The FunnelProps interface from components/ui/funnel.tsx, with the comments from the code. Also takes every prop of Omit<ChartA11y, 'labels'>.
stepsrequiredFunnelStep[]The steps in order, the first is the start (100 %)
formatoptionalIntl.NumberFormatOptionsValue format, e.g. { maximumFractionDigits: 0 }
variantoptional'columns' | 'bars'columns: steps side by side with the rates between them. bars: one row per step. Columns fall back to bars below 560 px.
heightoptionalnumbercolumns: height of the bars in px
Built-in texts
Funnel writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 8 texts
| Key | English | German |
|---|---|---|
| ofStart | English of start | German vom Start |
| value | English Value | German Wert |
| conversion | English From the step before | German Vom Schritt davor |
| dropped | English Dropped off | German Abgesprungen |
| share | English Share of start | German Anteil am Start |
| step | English Step | German Schritt |
| hint | English Use the arrow keys to step through the funnel. | German Mit den Pfeiltasten durch den Trichter gehen. |
| chart | English funnel | German Trichter |
Accessibility
Read from the source of the component.
- Needs no client state. The server writes it in full, so it reads without JavaScript too.
Motion
- With reduced motion (prefers-reduced-motion) it stays still. 3 places in the code switch motion off then.
- No animations, it stands still from the start.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 194 lines
'use client';
import { cn, round, useTexts } from '@/lib/kit';
import { ChartRoot, ChartSurface, ChartTable, ChartTooltip, CountUp, barPath, numberFormatter, useActive, useEntrance, useIntlLocale, useShown, useWidth, type ChartA11y } from '@/components/ui/chart';
import { Icon } from '@/components/ui/icon';
export interface FunnelStep {
label: string;
value: number;
}
const TEXTS = {
en: {
ofStart: 'of start',
value: 'Value',
conversion: 'From the step before',
dropped: 'Dropped off',
share: 'Share of start',
step: 'Step',
hint: 'Use the arrow keys to step through the funnel.',
chart: 'funnel',
},
de: {
ofStart: 'vom Start',
value: 'Wert',
conversion: 'Vom Schritt davor',
dropped: 'Abgesprungen',
share: 'Anteil am Start',
step: 'Schritt',
hint: 'Mit den Pfeiltasten durch den Trichter gehen.',
chart: 'Trichter',
},
};
export interface FunnelProps extends Omit<ChartA11y, 'labels'> {
/** The steps in order, the first is the start (100 %) */
steps: FunnelStep[];
/** Value format, e.g. { maximumFractionDigits: 0 } */
format?: Intl.NumberFormatOptions;
/** columns: steps side by side with the rates between them. bars: one row per step. Columns fall back to bars below 560 px. */
variant?: 'columns' | 'bars';
/** columns: height of the bars in px */
height?: number;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
/**
* Steps from first touch to the goal, with the conversion between each pair.
* <Funnel label="Shop funnel" steps={[{ label: 'Visits', value: 21904 }, { label: 'Orders', value: 832 }]} />
*/
export function Funnel({ steps, format, variant = 'columns', height = 200, label, summary, labels, animate = true, className }: FunnelProps) {
const intl = useIntlLocale();
const t = useTexts(TEXTS, labels);
const fmt = numberFormatter(intl, format);
const pct = numberFormatter(intl, { style: 'percent', minimumFractionDigits: 1, maximumFractionDigits: 1 });
const [wrapRef, width] = useWidth<HTMLDivElement>(720);
const [rootRef, anim] = useEntrance<HTMLDivElement>(animate);
const n = steps.length;
const { active, setActive, onKeyDown, onFocus, onBlur } = useActive(n);
const { shown, open, jump } = useShown(active);
// The steps fill one after another, each connector follows its step
const STEP = 150;
const start = steps[0]?.value || 1;
const share = (i: number) => steps[i].value / start;
const conv = (i: number) => (i > 0 && steps[i - 1].value ? steps[i].value / steps[i - 1].value : 1);
const columns = variant === 'columns' && width >= 560;
const live =
active !== null
? [steps[active].label, fmt(steps[active].value), `${pct(share(active))} ${t.ofStart}`, active > 0 ? `${t.conversion} ${pct(conv(active))}` : ''].filter(Boolean).join(', ')
: '';
const auto = steps.map((s, i) => `${s.label} ${fmt(s.value)}${i ? ` (${pct(conv(i))})` : ''}`).join(', ');
const rows = (i: number) => [
{ key: 'v', label: t.value, value: fmt(steps[i].value) },
{ key: 's', label: t.share, value: pct(share(i)) },
...(i > 0
? [
{ key: 'c', label: t.conversion, value: pct(conv(i)) },
{ key: 'd', label: t.dropped, value: fmt(steps[i - 1].value - steps[i].value) },
]
: []),
];
const colW = width / Math.max(1, n);
const barW = round(Math.min(colW * 0.46, 96));
const plotTop = 8;
const bx = (i: number) => round(colW * i + (colW - barW) / 2);
const by = (i: number) => round(plotTop + (1 - share(i)) * (height - plotTop));
return (
<ChartRoot anim={anim} rootRef={rootRef} className={className}>
<div ref={wrapRef}>
<ChartSurface
label={label}
summary={summary ?? auto}
labels={{ hint: t.hint, chart: t.chart }}
live={live}
table={<ChartTable caption={label} head={[t.step, label, t.share, t.conversion]} rows={steps.map((s, i) => [s.label, fmt(s.value), pct(share(i)), i ? pct(conv(i)) : pct(1)])} />}
onKeyDown={onKeyDown}
onFocus={onFocus}
onBlur={onBlur}
onPointerLeave={(e) => e.pointerType === 'mouse' && setActive(null)}
>
{columns ? (
<div>
<div className="grid" style={{ gridTemplateColumns: `repeat(${n}, minmax(0, 1fr))` }}>
{steps.map((s, i) => (
<div key={s.label} className={cn('flex flex-col px-2 text-center transition-opacity duration-200 motion-reduce:transition-none', active !== null && active !== i && 'opacity-50')} onPointerEnter={() => setActive(i)}>
<p className="flex flex-1 items-end justify-center text-balance text-[12px] leading-snug text-muted-foreground">{s.label}</p>
<p className="mt-1 text-[19px] font-semibold tracking-[-0.01em] text-foreground">
<CountUp value={s.value} format={fmt} anim={anim} delay={i * STEP} duration={800} />
</p>
<p className="text-[12px] tabular-nums text-subtle-foreground">{pct(share(i))}</p>
</div>
))}
</div>
<div className="relative mt-5">
<svg width="100%" height={height} viewBox={`0 0 ${width} ${height}`} preserveAspectRatio="xMidYMid meet" aria-hidden className="block">
{steps.map((s, i) => (
<g key={s.label} onPointerEnter={() => setActive(i)} onPointerDown={() => setActive(i)} opacity={active !== null && active !== i ? 0.5 : 1} className="transition-opacity duration-200 motion-reduce:transition-none">
{/* hit area: the whole column */}
<rect x={round(colW * i)} y={0} width={round(colW)} height={height} fill="transparent" />
<path d={barPath(bx(i), plotTop, barW, height - plotTop, 6, 'up')} fill="var(--kitch-empty)" />
{i < n - 1 && (
<path
d={`M${bx(i) + barW + 2} ${by(i)} L${bx(i + 1) - 2} ${by(i + 1)} L${bx(i + 1) - 2} ${height} L${bx(i) + barW + 2} ${height} Z`}
fill="var(--primary)"
fillOpacity="0.09"
className={anim ? 'kitch-clip' : undefined}
style={anim ? ({ '--kitch-d': `${260 + i * STEP}ms` } as React.CSSProperties) : undefined}
/>
)}
<path
d={barPath(bx(i), by(i), barW, height - by(i), 6, 'up')}
fill="var(--kitch-1)"
className={anim ? 'kitch-grow-y' : undefined}
style={anim ? ({ '--kitch-d': `${i * STEP}ms` } as React.CSSProperties) : undefined}
/>
</g>
))}
</svg>
{/* Conversion between two steps, on the connector */}
{steps.slice(1).map((s, k) => {
const i = k + 1;
const x = round(colW * i);
return (
<span
key={s.label}
aria-hidden
className={cn('pointer-events-none absolute flex -translate-x-1/2 items-center gap-0.5 whitespace-nowrap rounded-full border border-border bg-card px-2 py-0.5 text-[11px] font-medium tabular-nums text-foreground shadow-[var(--shadow-sm)]', anim && 'kitch-pop')}
style={{ left: x, top: Math.max(0, Math.min(height - 24, round((by(i - 1) + by(i)) / 2) - 26)), ...(anim ? ({ '--kitch-d': `${520 + k * STEP}ms` } as React.CSSProperties) : {}) }}
>
<Icon name="arrow-right" size={11} strokeWidth={2} className="text-muted-foreground" />
{pct(conv(i))}
</span>
);
})}
{shown !== null && shown < n && <ChartTooltip open={open} jump={jump} x={round(bx(shown) + barW / 2)} y={plotTop} width={width} offset={barW / 2 + 10} title={steps[shown].label} rows={rows(shown)} />}
</div>
</div>
) : (
<ol className="grid gap-1">
{steps.map((s, i) => (
<li key={s.label} onPointerEnter={() => setActive(i)} onPointerDown={() => setActive(i)} className={cn('transition-opacity duration-200 motion-reduce:transition-none', active !== null && active !== i && 'opacity-50')}>
{i > 0 && (
<p className="flex items-center gap-1.5 py-1.5 ps-1 text-[12px] tabular-nums text-muted-foreground">
<Icon name="arrow-down" size={12} strokeWidth={2} />
<span className="font-medium text-foreground">{pct(conv(i))}</span>
<span>· {t.dropped} {fmt(steps[i - 1].value - s.value)}</span>
</p>
)}
<div className="flex items-baseline justify-between gap-4">
<span className="truncate text-[13px] text-foreground">{s.label}</span>
<span className="shrink-0 text-[13px] tabular-nums">
<CountUp value={s.value} format={fmt} anim={anim} delay={i * STEP} duration={800} align="end" className="font-semibold text-foreground" />
<span className="ms-2 inline-block w-14 text-right text-subtle-foreground">{pct(share(i))}</span>
</span>
</div>
<div className="mt-2 h-2.5 overflow-hidden rounded-full" style={{ background: 'var(--kitch-empty)' }}>
<div
className={cn('h-full rounded-full', anim && 'kitch-grow-x')}
style={{ width: `${round(Math.max(share(i) * 100, 0.6), 2)}%`, background: 'var(--kitch-1)', ...(anim ? ({ '--kitch-d': `${i * STEP}ms`, transformBox: 'border-box' } as React.CSSProperties) : {}) }}
/>
</div>
</li>
))}
</ol>
)}
</ChartSurface>
</div>
</ChartRoot>
);
}Frequent questions
How do I install Funnel?
With aiw add @ai-website/funnel. The command puts components/ui/funnel.tsx into your project, the code is yours from then on. It brings along chart and icon. It needs no further npm packages.
Which variants does Funnel have?
2 variants, Horizontal steps and Rows.
Can I translate the texts of Funnel?
Yes. Funnel carries 8 texts of its own in English and German, such as “of start”, “Value” and “From the step before”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Funnel?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Funnel with aiw_components and install it with aiw_add @ai-website/funnel. Name the variant in your prompt, for example variant="bars", and the assistant writes your content straight into the props.