Charts
Funnel
The steps of a conversion as narrowing bars, with counts, step-to-step rates and the overall rate.
- Built on
CSS gridsubgrid@starting-styleIntl.NumberFormat- Client JavaScript
- None
- Works without React
- Yes, as HTML and CSS
- Source, gzipped
- 1.9 kB
- Dependencies
- React
- Signed up4,820
- Activated2,10543.7% of the step before
- Paid41219.6% of the step before
8.5% overall, Signed up to Paid. Each rate is a share of the step before.
Try its options
Loading its options.
Code
import { Funnel } from "@/components/equip/funnel";
export default function Demo() {
return (
<Funnel
label="Onboarding, last 30 days"
steps={[
{ label: "Signed up", value: 4820 },
{ label: "Activated", value: 2105 },
{ label: "Paid", value: 412 },
]}
style={{ width: 216 }}
/>
);
}Your versionchange its options in Look, This part
HTMLworks in any framework, with the part's CSS
<figure aria-label="Onboarding, last 30 days: Signed up 4,820, Paid 412, 8.5% overall" style="width:216px" class="eq-funnel" data-rates="shown">
<ol class="eq-funnel-list" role="list" aria-label="Onboarding, last 30 days">
<li class="eq-funnel-step" style="--_v:1">
<span class="eq-funnel-label">Signed up</span>
<span class="eq-funnel-track" aria-hidden="true">
<span class="eq-funnel-bar"></span>
</span>
<span class="eq-funnel-value">4,820</span>
<span class="eq-funnel-rate"></span>
</li>
<li class="eq-funnel-step" style="--_v:0.4367219917012448">
<span class="eq-funnel-label">Activated</span>
<span class="eq-funnel-track" aria-hidden="true">
<span class="eq-funnel-bar"></span>
</span>
<span class="eq-funnel-value">2,105</span>
<span class="eq-funnel-rate">43.7%<span class="eq-funnel-sr"> of the step before</span></span>
</li>
<li class="eq-funnel-step" style="--_v:0.08547717842323652">
<span class="eq-funnel-label">Paid</span>
<span class="eq-funnel-track" aria-hidden="true">
<span class="eq-funnel-bar"></span>
</span>
<span class="eq-funnel-value">412</span>
<span class="eq-funnel-rate">19.6%<span class="eq-funnel-sr"> of the step before</span></span>
</li>
</ol>
<p class="eq-funnel-total"><strong>8.5%</strong> overall, Signed up to Paid. Each rate is a share of the step before.</p>
</figure>Examples
Checkout, step by step
The rate on each step is its share of the step before, so the biggest drop stands out even when the bars are close.
- Viewed cart18,420
- Entered shipping11,26061.1% of the step before
- Entered payment9,87087.7% of the step before
- Placed order6,11562.0% of the step before
- Paid5,98297.8% of the step before
32.5% overall, Viewed cart to Paid. Each rate is a share of the step before.
Code
import { Funnel } from "@/components/equip/funnel";
export default function Example() {
return (
<Funnel
label="Checkout, this week"
steps={[
{ label: "Viewed cart", value: 18420 },
{ label: "Entered shipping", value: 11260 },
{ label: "Entered payment", value: 9870 },
{ label: "Placed order", value: 6115 },
{ label: "Paid", value: 5982 },
]}
style={{ maxWidth: 480 }}
/>
);
}HTMLworks in any framework, with the part's CSS
<figure aria-label="Checkout, this week: Viewed cart 18,420, Paid 5,982, 32.5% overall" style="max-width:480px" class="eq-funnel" data-rates="shown">
<ol class="eq-funnel-list" role="list" aria-label="Checkout, this week">
<li class="eq-funnel-step" style="--_v:1">
<span class="eq-funnel-label">Viewed cart</span>
<span class="eq-funnel-track" aria-hidden="true">
<span class="eq-funnel-bar"></span>
</span>
<span class="eq-funnel-value">18,420</span>
<span class="eq-funnel-rate"></span>
</li>
<li class="eq-funnel-step" style="--_v:0.6112920738327905">
<span class="eq-funnel-label">Entered shipping</span>
<span class="eq-funnel-track" aria-hidden="true">
<span class="eq-funnel-bar"></span>
</span>
<span class="eq-funnel-value">11,260</span>
<span class="eq-funnel-rate">61.1%<span class="eq-funnel-sr"> of the step before</span></span>
</li>
<li class="eq-funnel-step" style="--_v:0.5358306188925082">
<span class="eq-funnel-label">Entered payment</span>
<span class="eq-funnel-track" aria-hidden="true">
<span class="eq-funnel-bar"></span>
</span>
<span class="eq-funnel-value">9,870</span>
<span class="eq-funnel-rate">87.7%<span class="eq-funnel-sr"> of the step before</span></span>
</li>
<li class="eq-funnel-step" style="--_v:0.3319761129207383">
<span class="eq-funnel-label">Placed order</span>
<span class="eq-funnel-track" aria-hidden="true">
<span class="eq-funnel-bar"></span>
</span>
<span class="eq-funnel-value">6,115</span>
<span class="eq-funnel-rate">62.0%<span class="eq-funnel-sr"> of the step before</span></span>
</li>
<li class="eq-funnel-step" style="--_v:0.3247557003257329">
<span class="eq-funnel-label">Paid</span>
<span class="eq-funnel-track" aria-hidden="true">
<span class="eq-funnel-bar"></span>
</span>
<span class="eq-funnel-value">5,982</span>
<span class="eq-funnel-rate">97.8%<span class="eq-funnel-sr"> of the step before</span></span>
</li>
</ol>
<p class="eq-funnel-total"><strong>32.5%</strong> overall, Viewed cart to Paid. Each rate is a share of the step before.</p>
</figure>Without rates, and empty
With showRates off each rate is still read; a funnel whose first step is zero says there is no data.
- Started trial2,140
- Connected data1,32261.8% of the step before
- Paid38829.3% of the step before
18.1% overall, Started trial to Paid.
No data
Code
import { Funnel } from "@/components/equip/funnel";
export default function Example() {
return (
<div style={{ display: "grid", gap: 24, width: "100%", maxWidth: 360 }}>
<Funnel
label="Trial to paid, Q3"
showRates={false}
steps={[
{ label: "Started trial", value: 2140 },
{ label: "Connected data", value: 1322 },
{ label: "Paid", value: 388 },
]}
/>
<Funnel
label="Trial to paid, ap-south-2"
steps={[
{ label: "Started trial", value: 0 },
{ label: "Paid", value: 0 },
]}
/>
</div>
);
}HTMLworks in any framework, with the part's CSS
<div style="display:grid;gap:24px;width:100%;max-width:360px">
<figure aria-label="Trial to paid, Q3: Started trial 2,140, Paid 388, 18.1% overall" class="eq-funnel" data-rates="hidden">
<ol class="eq-funnel-list" role="list" aria-label="Trial to paid, Q3">
<li class="eq-funnel-step" style="--_v:1">
<span class="eq-funnel-label">Started trial</span>
<span class="eq-funnel-track" aria-hidden="true">
<span class="eq-funnel-bar"></span>
</span>
<span class="eq-funnel-value">2,140</span>
<span class="eq-funnel-rate"></span>
</li>
<li class="eq-funnel-step" style="--_v:0.6177570093457944">
<span class="eq-funnel-label">Connected data</span>
<span class="eq-funnel-track" aria-hidden="true">
<span class="eq-funnel-bar"></span>
</span>
<span class="eq-funnel-value">1,322</span>
<span class="eq-funnel-rate">61.8%<span class="eq-funnel-sr"> of the step before</span></span>
</li>
<li class="eq-funnel-step" style="--_v:0.18130841121495328">
<span class="eq-funnel-label">Paid</span>
<span class="eq-funnel-track" aria-hidden="true">
<span class="eq-funnel-bar"></span>
</span>
<span class="eq-funnel-value">388</span>
<span class="eq-funnel-rate">29.3%<span class="eq-funnel-sr"> of the step before</span></span>
</li>
</ol>
<p class="eq-funnel-total"><strong>18.1%</strong> overall, Started trial to Paid.</p>
</figure>
<figure aria-label="Trial to paid, ap-south-2: no data" class="eq-funnel" data-rates="shown">
<p class="eq-funnel-empty">No data</p>
</figure>
</div>Props
| Prop | Type | Default | What it does |
|---|---|---|---|
steps | { label: string; value: number }[] | none | The steps, first to last. Negative values count as zero. |
label | string | none | Names the figure. The first and last steps and the overall rate are added to it. |
showRates | boolean | true | Shows each step's share of the step before. When off it is still read. |
format | Intl.NumberFormatOptions | none | How counts are written. |
locale | string | "en-US" | Passed to Intl.NumberFormat. |
...rest | ComponentProps<"figure"> | none | Every native figure attribute. |
Anatomy
| root | .eq-funnel | A figure named with the summary; data-rates shows or hides the rates. |
|---|---|---|
| list | .eq-funnel-list | An ordered list, one item per step. |
| step | .eq-funnel-step | |
| label | .eq-funnel-label | |
| track | .eq-funnel-track | |
| bar | .eq-funnel-bar | Centred in its track, as a share of the first step. |
| value | .eq-funnel-value | |
| rate | .eq-funnel-rate | The share of the step before. |
| screen reader text | .eq-funnel-sr | Says what the rate is a share of. |
| total | .eq-funnel-total | The overall rate from the first step to the last. |
| empty | .eq-funnel-empty | Shown when the first step is zero. |
Notes
- It is an ordered list of real text, so every count and rate is read in order without the bars.
- Rates are of the step before, and the line under the list says so, so the numbers are never ambiguous.
- Steps should only narrow; a later step larger than the first still draws, capped at the full track.
- Renders on the server with no client JavaScript.
- The rows line up across steps with subgrid; where it is missing each row still lines up within itself.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/funnel.tsx72 lines
import type { CSSProperties, ComponentProps } from "react";
import "./funnel.css";
export type FunnelStep = { label: string; value: number };
export type FunnelProps = ComponentProps<"figure"> & {
/** The steps of the conversion, first to last. */
steps: FunnelStep[];
/** Names the funnel, such as "Onboarding, last 30 days". The overall conversion is added to it. */
label: string;
/** Shows each step's share of the step before it. */
showRates?: boolean;
format?: Intl.NumberFormatOptions;
locale?: string;
};
export function Funnel({ steps, label, showRates = true, format, locale = "en-US", className, ...rest }: FunnelProps) {
const number = new Intl.NumberFormat(locale, format);
const percent = new Intl.NumberFormat(locale, { style: "percent", minimumFractionDigits: 1, maximumFractionDigits: 1 });
const rows = steps.map((s) => ({ label: s.label, value: Number.isFinite(s.value) && s.value > 0 ? s.value : 0 }));
const first = rows[0];
const last = rows[rows.length - 1];
const empty = !first || !last || first.value === 0;
const share = (a: number, b: number) => (b > 0 ? a / b : 0);
const overall = first && last ? share(last.value, first.value) : 0;
const summary = empty
? `${label}: no data`
: `${label}: ${first.label} ${number.format(first.value)}, ${last.label} ${number.format(last.value)}, ${percent.format(overall)} overall`;
return (
<figure aria-label={summary} {...rest} className={["eq-funnel", className].filter(Boolean).join(" ")} data-rates={showRates ? "shown" : "hidden"}>
{empty ? (
<p className="eq-funnel-empty">No data</p>
) : (
<>
<ol className="eq-funnel-list" role="list" aria-label={label}>
{rows.map((r, i) => {
const prev = rows[i - 1];
return (
<li
key={`${r.label}-${i}`}
className="eq-funnel-step"
style={{ "--_v": Math.min(1, share(r.value, first.value)) } as CSSProperties}
>
<span className="eq-funnel-label">{r.label}</span>
<span className="eq-funnel-track" aria-hidden="true">
<span className="eq-funnel-bar" />
</span>
<span className="eq-funnel-value">{number.format(r.value)}</span>
<span className="eq-funnel-rate">
{prev ? (
<>
{percent.format(share(r.value, prev.value))}
<span className="eq-funnel-sr"> of the step before</span>
</>
) : null}
</span>
</li>
);
})}
</ol>
<p className="eq-funnel-total">
<strong>{percent.format(overall)}</strong>
{` overall, ${first.label} to ${last.label}.`}
{showRates ? " Each rate is a share of the step before." : null}
</p>
</>
)}
</figure>
);
}components/equip/funnel.css126 lines
@layer equip {
.eq-funnel {
display: grid;
gap: var(--eq-space-2);
width: 100%;
min-width: 0;
margin: 0;
font: var(--eq-weight) var(--eq-text-xs) / 1.3 var(--eq-font);
color: var(--eq-ink-2);
}
.eq-funnel-list {
display: grid;
grid-template-columns: fit-content(40%) minmax(48px, 1fr) max-content max-content;
gap: var(--eq-space-1) var(--eq-space-2);
margin: 0;
padding: 0;
list-style: none;
}
.eq-funnel[data-rates="hidden"] .eq-funnel-list {
grid-template-columns: fit-content(40%) minmax(48px, 1fr) max-content;
}
.eq-funnel-step {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
align-items: center;
min-height: 24px;
}
.eq-funnel-label {
min-width: 0;
overflow: hidden;
color: var(--eq-ink);
text-overflow: ellipsis;
white-space: nowrap;
}
.eq-funnel-track {
position: relative;
display: block;
height: 18px;
border-radius: calc(var(--eq-radius-s) / 2);
background: var(--eq-tint);
}
/* Centred in its track, so the steps narrow like a funnel. It opens out on the spring. */
.eq-funnel-bar {
position: absolute;
inset-block: 0;
left: 50%;
width: max(2px, calc(var(--_v) * 100%));
border-radius: inherit;
background: color-mix(in srgb, var(--eq-ink) 72%, transparent);
translate: -50% 0;
transition:
scale var(--eq-dur-move) var(--eq-spring),
background-color var(--eq-dur) var(--eq-ease);
}
@starting-style {
.eq-funnel-bar {
scale: 0 1;
}
}
@media (hover: hover) {
.eq-funnel-step:hover .eq-funnel-bar {
background: var(--eq-ink);
}
}
.eq-funnel-value {
color: var(--eq-ink);
font-weight: var(--eq-weight-strong);
font-variant-numeric: tabular-nums;
text-align: end;
white-space: nowrap;
}
.eq-funnel-rate {
min-width: 4ch;
color: var(--eq-ink-2);
font-variant-numeric: tabular-nums;
text-align: end;
white-space: nowrap;
}
.eq-funnel[data-rates="hidden"] .eq-funnel-rate,
.eq-funnel-sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
border: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.eq-funnel-total {
margin: 0;
padding-block-start: var(--eq-space-2);
border-block-start: 1px solid var(--eq-line);
color: var(--eq-ink-2);
}
.eq-funnel-total strong {
color: var(--eq-ink);
font-weight: var(--eq-weight-strong);
font-variant-numeric: tabular-nums;
}
.eq-funnel-empty {
display: grid;
place-items: center;
min-height: calc(var(--eq-space-6) * 2);
margin: 0;
border: 1px dashed var(--eq-line-strong);
border-radius: var(--eq-radius-m);
color: var(--eq-ink-3);
}
}