Skip to content
Star on GitHub
Charts and tablesFunnel chart
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

Funnel 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.

$npx designme add funnel-chart
Signup to paid
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add funnel-chart

React

$npx designme add funnel-chart --react

The 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.

02

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.
03

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>
  );
}
04

Props

Everything FunnelChart takes.

PropTypeWhat it does
titlerequiredstringLike "Signup to paid"
stagesrequiredstring[]The stages in order, like "Signed up" then "Opened a file"
viewsrequiredFunnelChartView[]One set of numbers per view. With more than one, a switch picks between them
viewstringKey of the view shown. Leave it out to let the switch change it
defaultViewstringDefaults to the first view
onViewChange(key: string) => void
shareLabelstringEnds a stage's tip, like "of signups" in "51.8% of signups"
startLabelstringThe first stage's tip
format(n: number) => stringWrites a count. Defaults to whole numbers like 12,400
classNamestring
05

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' });
});
06

Questions

What people ask about it.

Does the component write the insight text?
No. You pass it as insight on 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", and startLabel for 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 an insight.
07

More in Charts and tables

Lightweight charts and tables for dashboards and reports, drawn in plain SVG and HTML.

Line chartTwo series over time with a hover readout and toggles.
Bar chartWeekly agent runs, split by outcome, with a range switch.
Donut chartWhere the agent's time went, by kind of task.
Top pagesMost visited pages with share of traffic and a quick sort.
Health scoreA design system score made of a few checks you can open.
Activity heatmapWhen the agent and the team were busy, by day and hour.
Stat cardsFour headline numbers with sparklines and a period switch.
Account tableCustomer accounts with health, plan and an agent note per row.