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
  1. Signed up4,820
  2. Activated2,10543.7% of the step before
  3. 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.

  1. Viewed cart18,420
  2. Entered shipping11,26061.1% of the step before
  3. Entered payment9,87087.7% of the step before
  4. Placed order6,11562.0% of the step before
  5. 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.

  1. Started trial2,140
  2. Connected data1,32261.8% of the step before
  3. 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

PropTypeDefaultWhat it does
steps{ label: string; value: number }[]noneThe steps, first to last. Negative values count as zero.
labelstringnoneNames the figure. The first and last steps and the overall rate are added to it.
showRatesbooleantrueShows each step's share of the step before. When off it is still read.
formatIntl.NumberFormatOptionsnoneHow counts are written.
localestring"en-US"Passed to Intl.NumberFormat.
...restComponentProps<"figure">noneEvery native figure attribute.

Anatomy

root.eq-funnelA figure named with the summary; data-rates shows or hides the rates.
list.eq-funnel-listAn ordered list, one item per step.
step.eq-funnel-step
label.eq-funnel-label
track.eq-funnel-track
bar.eq-funnel-barCentred in its track, as a share of the first step.
value.eq-funnel-value
rate.eq-funnel-rateThe share of the step before.
screen reader text.eq-funnel-srSays what the rate is a share of.
total.eq-funnel-totalThe overall rate from the first step to the last.
empty.eq-funnel-emptyShown 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);
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index