Charts and tablesFunnel chart
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callFunnel chart
Signup to paid, with the biggest drop called out by the agent. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add funnel-chartReact
npx designme add funnel-chart --reactThe CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.
About
What it is for and what it does.
A funnel that shows how many people reach each stage of a flow, like signup to paid, with the stage that loses the largest share marked. Under the funnel sits a short note, written by your agent, explaining what the numbers mean. A switch moves between segments such as all users, teams and solo users.
- When you want to show conversion through a signup, onboarding or checkout flow.
- When an agent analyses the funnel and its finding should sit right under the chart.
- When the same stages need comparing across a few user segments.
- One horizontal bar per entry in
stages, its width relative to the first stage, with the count on or beside the bar. - Each row ends with its drop from the stage before as a percentage, and the first row reads 100%.
- The stage with the largest drop compared with the one before is marked automatically.
- Hovering a bar shows its share of the first stage, ended by
shareLabel, orstartLabelon the first stage. - With more than one entry in
views, a segment switch redraws the bars top to bottom and swaps in that view'sinsighttext.
- The segment switch is a role="group" labelled "Segment", and its buttons use aria-pressed.
- Each bar's share tooltip has role="tooltip".
Usage
Import it and drive it from your own state.
Import
import { FunnelChart } from '@/components/designme/funnel-chart/FunnelChart';Example
The demo above as a React component, 18 lines you can copy.
'use client';
// The preview from designme.dev, rebuilt from the React component: signup to paid, for everyone, teams or solo users.
import { FunnelChart } from './FunnelChart';
export default function FunnelChartDemo() {
return (
<div style={{ width: '100%' }}>
<FunnelChart title="Signup to paid" shareLabel="of signups"
stages={['Signed up', 'Opened a file', 'Ran the agent', 'Invited a teammate', 'Paid']}
views={[
{ key: 'all', label: 'All', values: [12400, 9180, 6420, 2310, 1490], insight: 'The biggest drop is after the first agent run. 64% never invite a teammate.' },
{ key: 'team', label: 'Teams', values: [4100, 3570, 2980, 1890, 1210], insight: 'Teams convert well. Most drop off at payment, so the plan page matters most here.' },
{ key: 'solo', label: 'Solo', values: [8300, 5610, 3440, 420, 280], insight: 'Solo users rarely invite anyone. Try a paid plan that does not need a team.' }
]} />
</div>
);
}
Props
Everything FunnelChart takes.
| Prop | Type | What it does |
|---|---|---|
titlerequired | string | Like "Signup to paid" |
stagesrequired | string[] | The stages in order, like "Signed up" then "Opened a file" |
viewsrequired | FunnelChartView[] | One set of numbers per view. With more than one, a switch picks between them |
view | string | Key of the view shown. Leave it out to let the switch change it |
defaultView | string | Defaults to the first view |
onViewChange | (key: string) => void | |
shareLabel | string | Ends a stage's tip, like "of signups" in "51.8% of signups" |
startLabel | string | The first stage's tip |
format | (n: number) => string | Writes a count. Defaults to whole numbers like 12,400 |
className | string |
Code
The full source, as the CLI installs it.
FunnelChart.tsxReact · 89 lines
'use client';
import { formatCount, useStagger } from '../lib/chart';
import { cx } from '../lib/cx';
import { useControllable } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { Seg, Swap, Tip } from '../lib/motion';
import '../designme.css';
import './funnel-chart.css';
export interface FunnelChartView {
key: string;
/** On the switch, like "Teams" */
label: string;
/** How many reached each stage, in order */
values: number[];
/** The agent's note under the funnel for this view */
insight?: string;
}
export interface FunnelChartProps {
/** Like "Signup to paid" */
title: string;
/** The stages in order, like "Signed up" then "Opened a file" */
stages: string[];
/** One set of numbers per view. With more than one, a switch picks between them */
views: FunnelChartView[];
/** Key of the view shown. Leave it out to let the switch change it */
view?: string;
/** Defaults to the first view */
defaultView?: string;
onViewChange?: (key: string) => void;
/** Ends a stage's tip, like "of signups" in "51.8% of signups" */
shareLabel?: string;
/** The first stage's tip */
startLabel?: string;
/** Writes a count. Defaults to whole numbers like 12,400 */
format?: (n: number) => string;
className?: string;
}
/** How many people reach each stage, with the biggest drop marked and a note from the agent. */
export function FunnelChart({ title, stages, views, view, defaultView, onViewChange, shareLabel = 'of the first stage', startLabel = 'Starting point', format = formatCount, className }: FunnelChartProps) {
const [cur, setCur] = useControllable(view, defaultView ?? views[0]?.key ?? '', onViewChange);
// the bars draw a moment after the funnel appears, then redraw top to bottom on every switch
const at = useStagger(cur, stages.length, 70, 150);
const byKey = (k: string | null) => views.find(v => v.key === k);
const shown = byKey(at[0]);
const d = shown?.values ?? [];
// the stage that loses the largest share of the one before it
let worst = -1, ratio = 1;
d.forEach((n, i) => { if (i && n / d[i - 1] < ratio) { ratio = n / d[i - 1]; worst = i; } });
const insight = views.some(v => v.insight);
return (
<div data-dm="" className={cx('dm-fnx dm-panel', className)}>
<div className="dm-fnx-h">
<b>{title}</b>
{views.length > 1 && (
<Seg className="dm-fnx-sg" label="Segment" value={cur} onChange={setCur} options={views.map(v => ({ value: v.key, label: v.label }))} />
)}
</div>
<div className="dm-fnx-rows">
{stages.map((stage, i) => {
const own = byKey(at[i])?.values, w = own ? Math.max(6, own[i] / own[0] * 100) : 0, out = !!own && w < 24;
const track = (
<div className="dm-tr">
<div className="dm-bar" style={{ width: w + '%' }} />
<span className={cx('dm-n dm-num', out && 'dm-out')} style={own ? { left: out ? `calc(${w}% + 8px)` : 10 } : undefined}>{shown && format(d[i])}</span>
</div>
);
return (
<div key={i} className={cx('dm-fnx-r', i === worst && 'dm-drop')}>
<span className="dm-k">{stage}</span>
{shown ? <Tip label={i ? `${Math.round(d[i] / d[0] * 1000) / 10}% ${shareLabel}` : startLabel}>{track}</Tip> : track}
<span className="dm-v dm-num">{shown && (i ? '−' + Math.round((1 - d[i] / d[i - 1]) * 100) + '%' : '100%')}</span>
</div>
);
})}
</div>
{insight && (
<div className="dm-fnx-ins">
<span className="dm-fnx-ic"><Icon name="spark" size="xs" /></span>
<Swap>{shown?.insight ?? ''}</Swap>
</div>
)}
</div>
);
}
funnel-chart.cssReact · 20 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Funnel ---------- */
.dm-fnx{padding:var(--dm-s-4);display:flex;flex-direction:column;gap:var(--dm-s-3)}
.dm-fnx-h{display:flex;align-items:center;justify-content:space-between;gap:10px;font-size:13.5px;flex-wrap:wrap}
.dm-fnx-h b{font-weight:500}
.dm-fnx-sg button{font-size:12px;height:26px;padding:0 9px}
.dm-fnx-rows{display:flex;flex-direction:column;gap:6px}
.dm-fnx-r{display:grid;grid-template-columns:118px 1fr 48px;align-items:center;gap:10px;font-size:12.5px;padding:2px 0;border-radius:var(--dm-r-sm)}
.dm-fnx-r .dm-k{color:var(--dm-fg2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dm-fnx-r .dm-tr{height:26px;border-radius:var(--dm-r-sm);background:var(--dm-chip);position:relative;overflow:hidden}
/* the bar is an accent fill, so its count reads in --on-accent; its edge in the mark keeps a light accent visible on the track */
.dm-fnx-r .dm-bar{position:absolute;inset:0 auto 0 0;border-radius:var(--dm-r-sm);background:var(--dm-accent);box-shadow:inset 0 0 0 1px var(--dm-accent-mark,var(--dm-accent));transition:width .9s var(--dm-ease),opacity .3s}
.dm-fnx-r .dm-n{position:absolute;left:10px;top:0;bottom:0;display:flex;align-items:center;color:var(--dm-on-accent);font-size:11.5px;font-weight:500;transition:left .9s var(--dm-ease),color .3s}
.dm-fnx-r .dm-n.dm-out{color:var(--dm-fg2)}
.dm-fnx-r .dm-v{text-align:right;color:var(--dm-muted);font-size:12px}
.dm-fnx-r.dm-drop .dm-v{color:var(--dm-bad);font-weight:500}
@media (hover:hover) and (pointer:fine){.dm-fnx-rows:hover .dm-fnx-r:not(:hover) .dm-bar{opacity:.35}}
.dm-fnx-ins{display:flex;gap:var(--dm-s-2);font-size:12.5px;line-height:1.5;color:var(--dm-fg2);padding:10px var(--dm-s-3);border-radius:var(--dm-r-in);background:var(--dm-accent-bg)}
.dm-fnx-ic{color:var(--dm-accent-fg,var(--dm-accent));display:grid;margin-top:2px}
funnel-chart.htmlHTML · 10 lines
<div class="card" data-c="funnel">
<div class="stage-in">
<div class="fnx panel">
<div class="fnx-h"><b>Signup to paid</b><div class="seg2 fnx-sg" role="group" aria-label="Segment"><button aria-pressed="true" data-v="all">All</button><button aria-pressed="false" data-v="team">Teams</button><button aria-pressed="false" data-v="solo">Solo</button></div></div>
<div class="fnx-rows"></div>
<div class="fnx-ins"><span class="fnx-ic"><svg class="i xs"><use href="#i-spark"/></svg></span><span class="fnx-t"></span></div>
</div>
</div>
</div>
funnel-chart.cssHTML · 19 lines
/* ---------- Funnel ---------- */
.fnx{padding:var(--s-4);display:flex;flex-direction:column;gap:var(--s-3)}
.fnx-h{display:flex;align-items:center;justify-content:space-between;gap:10px;font-size:13.5px;flex-wrap:wrap}
.fnx-h b{font-weight:500}
.fnx-sg button{font-size:12px;height:26px;padding:0 9px}
.fnx-rows{display:flex;flex-direction:column;gap:6px}
.fnx-r{display:grid;grid-template-columns:118px 1fr 48px;align-items:center;gap:10px;font-size:12.5px;padding:2px 0;border-radius:var(--r-sm)}
.fnx-r .k{color:var(--fg2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.fnx-r .tr{height:26px;border-radius:var(--r-sm);background:var(--chip);position:relative;overflow:hidden}
/* the bar is an accent fill, so its count reads in --on-accent; its edge in the mark keeps a light accent visible on the track */
.fnx-r .bar{position:absolute;inset:0 auto 0 0;border-radius:var(--r-sm);background:var(--accent);box-shadow:inset 0 0 0 1px var(--accent-mark,var(--accent));transition:width .9s var(--ease),opacity .3s}
.fnx-r .n{position:absolute;left:10px;top:0;bottom:0;display:flex;align-items:center;color:var(--on-accent);font-size:11.5px;font-weight:500;transition:left .9s var(--ease),color .3s}
.fnx-r .n.out{color:var(--fg2)}
.fnx-r .v{text-align:right;color:var(--muted);font-size:12px}
.fnx-r.drop .v{color:var(--bad);font-weight:500}
@media (hover:hover) and (pointer:fine){.fnx-rows:hover .fnx-r:not(:hover) .bar{opacity:.35}}
.fnx-ins{display:flex;gap:var(--s-2);font-size:12.5px;line-height:1.5;color:var(--fg2);padding:10px var(--s-3);border-radius:var(--r-in);background:var(--accent-bg)}
.fnx-ic{color:var(--accent-fg,var(--accent));display:grid;margin-top:2px}
funnel-chart.jsHTML · 39 lines
DM.def('funnel', (r, c) => {
r.innerHTML = r.innerHTML.replace('{spark}', I('spark','xs'));
const K = ['Signed up','Opened a file','Ran the agent','Invited a teammate','Paid'];
const D = {
all:{ n:[12400,9180,6420,2310,1490], t:'The biggest drop is after the first agent run. 64% never invite a teammate.' },
team:{ n:[4100,3570,2980,1890,1210], t:'Teams convert well. Most drop off at payment, so the plan page matters most here.' },
solo:{ n:[8300,5610,3440,420,280], t:'Solo users rarely invite anyone. Try a paid plan that does not need a team.' }
};
const rows = $('.fnx-rows', r), txt = $('.fnx-t', r);
// the bars and counts move with the animations below rather than CSS transitions, so only the hover dim and the
// count's colour keep a transition here
rows.innerHTML = K.map(k => `<div class="fnx-r"><span class="k">${k}</span><div class="tr"><div class="bar" style="width:0;transition:opacity .3s"></div><span class="n num" style="transition:color .3s"></span></div><span class="v num"></span></div>`).join('');
const R = $$('.fnx-r', rows);
/* every draw writes the finished chart at once and the motion only plays over it: each bar and count glides from where
it is to where it ends, on the page's clock. So the settled chart shows whatever happens on the way: a replay half
way, a stop, or the card scrolling far off screen (where the homepage stops drawing it) and back */
const glide = (el, prop, to, delay) => {
const from = getComputedStyle(el)[prop]; if (el._g) el._g.cancel();
el.style[prop] = to; el._g = null;
if (REDUCE || from === getComputedStyle(el)[prop]) return;
el._g = el.animate([{ [prop]:from }, { [prop]:to }], { duration:900, delay, easing:getComputedStyle(r).getPropertyValue('--ease').trim() || 'ease', fill:'backwards' });
};
// the note crossfades on a switch; the newest note is the one that lands, however quickly the segments change
let note = 0;
const say = t => { const id = ++note; if (txt.textContent === t && !txt.classList.contains('out')) return; txt.classList.add('sw', 'out'); setTimeout(() => { if (id !== note) return; txt.textContent = t; txt.classList.remove('out'); }, REDUCE ? 0 : 170); };
function draw(s, wait = 0){
const d = D[s].n; let worst = 1, wv = 1;
d.forEach((n, i) => { if (i && n / d[i - 1] < wv){ wv = n / d[i - 1]; worst = i; } });
R.forEach((row, i) => { const w = Math.max(6, d[i] / d[0] * 100), nn = $('.n', row);
glide($('.bar', row), 'width', w + '%', wait + i * 70); nn.classList.toggle('out', w < 24); glide(nn, 'left', w < 24 ? `calc(${w}% + 8px)` : '10px', wait + i * 70);
nn.textContent = d[i].toLocaleString('en-US');
$('.v', row).textContent = i ? '−' + Math.round((1 - d[i] / d[i - 1]) * 100) + '%' : '100%'; row.classList.toggle('drop', i === worst); row.title = ''; $('.tr', row).dataset.tip = i ? Math.round(d[i] / d[0] * 1000) / 10 + '% of signups' : 'Starting point'; });
}
seg($('.fnx-sg', r), s => { draw(s); say(D[s].t); });
// the first draw starts a moment after the funnel appears, the way it always has, and its note fades in after it
draw('all', 280); txt.textContent = D.all.t; txt.classList.add('sw');
if (!REDUCE) txt.animate([{ opacity:0, filter:'blur(3px)', transform:`translateY(${getComputedStyle(r).getPropertyValue('--motion').trim() === 'bp' ? -3 : 3}px)` }, { opacity:1, filter:'blur(0)', transform:'none' }], { duration:250, delay:450, easing:getComputedStyle(r).getPropertyValue('--ease').trim() || 'ease', fill:'backwards' });
});
Questions
What people ask about it.
- Does the component write the insight text?
- No. You pass it as
insighton each view, for example a summary your agent produced. The component only picks out and marks the biggest drop. - How is the biggest drop chosen?
- It is the stage whose count is the smallest fraction of the stage before it, which is not always the largest absolute loss.
- How do I word the hover tip for my data?
- Set
shareLabel, like "of signups", to read "51.8% of signups", andstartLabelfor the first stage's tip. - Can I show a single funnel without the switch?
- Yes. Pass one entry in
views. The switch only appears when there is more than one, and the note only shows if a view has aninsight.
More in Charts and tables
Lightweight charts and tables for dashboards and reports, drawn in plain SVG and HTML.