Skip to content
Product block

Conversion Funnel

A funnel that leads with the step-to-step rate and the people who fell out of it, not the tidy share-of-top percentages nobody can act on. Biggest drop named in words, and a footnote saying which definition of funnel this is.

195 lineslucide-reactAdded 27 Aug 2026Updated 10 Sept 2026
  • funnel
  • conversion
  • analytics
  • chart
  • cohort

What's included

  • components/funnel-conversion-panel.tsx
  • Needs lucide-react

Works with

  • React
  • Next.js
  • Tailwind CSS
  • TypeScript

npx hoverlab add funnel-conversion-panel

Or over MCP, from your editor's agent — no account needed.

License

Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.

Was this useful?

Start a page with this section — add more, order them, and leave with the page source.

Preview

Signup funnel

Last 30 days

End to end 5.5%

  1. Visited pricing18,420Top of the funnel

    100.0% of the funnel

  2. Started signup6,21533.7%from previous step

    12,205 did not continue · 33.7% of everyone who started

  3. Verified email4,98880.3%from previous step

    1,227 did not continue · 27.1% of everyone who started · Link expires after 24 hours

  4. Reached checkout1,10222.1%from previous step

    3,886 did not continue · 6.0% of everyone who started

    Biggest drop in the funnel — 77.9% of the previous step stops here

  5. Paid1,01492.0%from previous step

    88 did not continue · 5.5% of everyone who started

Counted as a cohort: every person here entered at the first step inside the window and is followed through in order. Someone who signed up last month and paid this month is not in these numbers, so this reads lower than a per-step total over the same period — and it is the one that answers “what happens to the people we get”.

Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.

Source

components/funnel-conversion-panel.tsx
/**
 * <FunnelConversionPanel> — the step chart, with the number that matters.
 *
 * Charts & Metrics had sparklines, a bar chart, usage meters, a line
 * chart, a donut and a heatmap: six ways to show a quantity over time or
 * across a category. A funnel is neither. It is the only chart in a
 * product analytics screen that answers "where do people stop", and it
 * was missing.
 *
 * THE ONE THING THIS GETS RIGHT THAT MOST DO NOT
 *
 * Almost every funnel widget labels each bar with its share of the *top*
 * of the funnel — 100%, 61%, 44%, 41% — which is the number that makes a
 * slide look tidy and the number nobody can act on. What decides where
 * the next week of work goes is the step-to-step rate and the absolute
 * count that fell out of it: "1,102 people got to checkout and 88 of them
 * did not finish" is a task. Both are shown, with the step-to-step rate
 * given the larger type, and the drop stated as people rather than as a
 * percentage of a percentage.
 *
 * THE WORST STEP IS NAMED
 *
 * A funnel where every bar looks similar hides its own point. The largest
 * relative drop is marked, once, in words — not by colouring one bar red
 * and hoping the eye lands there.
 *
 * IT SAYS WHAT KIND OF FUNNEL IT IS
 *
 * A funnel over "everyone who did each step in the window" and a funnel
 * over "one cohort followed through in order" produce different numbers
 * from the same data, and the difference routinely gets lost between the
 * query and the dashboard. The footnote states which one this is, because
 * a reader cannot tell by looking and will assume the flattering one.
 *
 * NO CANVAS, NO CHART LIBRARY: the bars are divs with a width, so the
 * whole thing prints, survives text zoom, and adds nothing to the bundle.
 *
 * ACCESSIBILITY: an ordered list, because a funnel is a sequence; each
 * step's numbers are real text rather than a title attribute, so the
 * chart is fully readable without ever resolving the visual widths. No
 * `'use client'` — nothing here has state.
 */

import { TrendingDown } from 'lucide-react'

export interface FunnelStep {
  label: string
  /** People who reached this step. */
  count: number
  /** Optional note about the step itself, not its number. */
  note?: string
}

export interface FunnelConversionPanelProps {
  title?: string
  window?: string
  steps?: FunnelStep[]
  className?: string
}

const DEFAULT_STEPS: FunnelStep[] = [
  { label: 'Visited pricing', count: 18420 },
  { label: 'Started signup', count: 6215 },
  { label: 'Verified email', count: 4988, note: 'Link expires after 24 hours' },
  { label: 'Reached checkout', count: 1102 },
  { label: 'Paid', count: 1014 },
]

/** `0.61` → `"61.0%"`. Fixed to one place: funnels invite false precision. */
function pct(value: number): string {
  return `${(value * 100).toFixed(1)}%`
}

export function FunnelConversionPanel({
  title = 'Signup funnel',
  window: period = 'Last 30 days',
  steps = DEFAULT_STEPS,
  className = '',
}: FunnelConversionPanelProps) {
  const top = steps[0]?.count ?? 0
  if (top <= 0) return null

  /* Both rates, computed once. `fromPrevious` is the actionable one. */
  const rows = steps.map((step, index) => {
    const previous = index === 0 ? step.count : steps[index - 1]!.count
    return {
      ...step,
      index,
      fromTop: step.count / top,
      fromPrevious: previous === 0 ? 0 : step.count / previous,
      dropped: previous - step.count,
    }
  })

  /* The single worst step-to-step rate, ignoring the first (which has no
     previous step and is always 100%). */
  const worst = rows
    .slice(1)
    .reduce((lowest, row) => (row.fromPrevious < lowest.fromPrevious ? row : lowest), rows[1]!)

  return (
    <section className={`mx-auto w-full max-w-3xl px-4 py-16 sm:px-6 ${className}`}>
      <div className="rounded-2xl border border-border bg-card p-5 sm:p-6">
        <header className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
          <h2 className="text-base font-semibold text-foreground">{title}</h2>
          <p className="text-xs text-muted-foreground">{period}</p>
          <p className="ms-auto text-xs text-muted-foreground">
            End to end{' '}
            <strong className="font-semibold tabular-nums text-foreground">
              {pct((steps[steps.length - 1]?.count ?? 0) / top)}
            </strong>
          </p>
        </header>

        {/* Ordered, because the sequence is the meaning. */}
        <ol className="mt-5 space-y-4">
          {rows.map((row) => (
            <li key={row.label}>
              <div className="flex flex-wrap items-baseline gap-x-2">
                <span className="text-sm font-medium text-foreground">{row.label}</span>
                <span className="text-sm tabular-nums text-muted-foreground">
                  {row.count.toLocaleString('en-US')}
                </span>
                {row.index > 0 ? (
                  <span className="ms-auto text-sm font-semibold tabular-nums text-foreground">
                    {pct(row.fromPrevious)}
                    <span className="ms-1 text-xs font-normal text-muted-foreground">
                      from previous step
                    </span>
                  </span>
                ) : (
                  <span className="ms-auto text-xs text-muted-foreground">
                    Top of the funnel
                  </span>
                )}
              </div>

              {/*
                Two widths on one track: the pale bar is the share of the
                top, the solid bar is this step. The track is decorative —
                every number it encodes is in the text above it.
              */}
              <div
                aria-hidden
                className="mt-1.5 h-2.5 w-full overflow-hidden rounded-full border border-transparent bg-muted"
              >
                <div
                  className="h-full rounded-full bg-primary transition-[width] duration-500 border border-transparent"
                  style={{ width: `${Math.max(row.fromTop * 100, 1)}%` }}
                />
              </div>

              <p className="mt-1 text-xs text-muted-foreground">
                {row.index === 0 ? (
                  <>{pct(1)} of the funnel</>
                ) : (
                  <>
                    {/* People, not a percentage of a percentage. */}
                    <strong className="font-medium text-foreground tabular-nums">
                      {row.dropped.toLocaleString('en-US')}
                    </strong>{' '}
                    did not continue · {pct(row.fromTop)} of everyone who started
                  </>
                )}
                {row.note ? <> · {row.note}</> : null}
              </p>

              {/* Named once, in words. */}
              {row.index === worst.index ? (
                <p className="mt-1.5 inline-flex items-center gap-1.5 rounded-md bg-amber-500/10 px-2 py-0.5 text-xs text-amber-700 dark:text-amber-400">
                  <TrendingDown aria-hidden className="h-3 w-3" />
                  Biggest drop in the funnel — {pct(1 - worst.fromPrevious)} of the
                  previous step stops here
                </p>
              ) : null}
            </li>
          ))}
        </ol>

        {/*
          Which funnel this is. A reader cannot tell by looking, and will
          assume whichever definition flatters the number.
        */}
        <p className="mt-5 border-t border-border pt-3 text-xs leading-relaxed text-muted-foreground">
          Counted as a cohort: every person here entered at the first step inside the
          window and is followed through in order. Someone who signed up last month
          and paid this month is not in these numbers, so this reads lower than a
          per-step total over the same period — and it is the one that answers
          &ldquo;what happens to the people we get&rdquo;.
        </p>
      </div>
    </section>
  )
}

Before you paste

  • Styling is Tailwind utility classes on semantic tokens (bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.
  • Install: npm i lucide-react
  • Every prop has a default, so it renders standalone before you wire it up.

Where it goes

Drop it at components/funnel-conversion-panel.tsx and import it where you need the section:

import { FunnelConversionPanel } from '@/components/funnel-conversion-panel'

Customize

1 of this block’s props are simple enough to drive from here. Change them and the block below re-renders — it is the same component whose source is above, not a mock of it. Everything else it accepts is in the table underneath.

Props

Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.

PropTypeDefault
titlestring'Signup funnel'
windowstring—
stepsFunnelStep[]DEFAULT_STEPS
classNamestring''

Not using React?

The same block rendered once to markup, wrapped as a file your framework compiles. Tailwind classes are framework-agnostic, so the design transfers intact — the behaviour does not.

This block is interactive. The markup below is its initial state with the event handlers stripped — you will need to re-wire the behaviour in your framework.

funnel-conversion-panel.html
<!--
  Conversion Funnel — markup from the Hoverlab catalog.

  This is the block rendered once to HTML and wrapped as a component
  file. It is not a port of the React source: the Tailwind classes carry
  the design, which is the part that took the work, and they are the same
  in every framework.

  This block is interactive in React and the handlers are NOT here.
  Buttons, toggles and menus render in their initial state and do
  nothing until you wire them up.
-->
<section class="mx-auto w-full max-w-3xl px-4 py-16 sm:px-6 ">
  <div class="rounded-2xl border border-border bg-card p-5 sm:p-6">
    <header class="flex flex-wrap items-baseline gap-x-3 gap-y-1">
      <h2 class="text-base font-semibold text-foreground">Signup funnel</h2>
      <p class="text-xs text-muted-foreground">Last 30 days</p>
      <p class="ms-auto text-xs text-muted-foreground">End to end <strong class="font-semibold tabular-nums text-foreground">5.5%</strong></p>
    </header>
    <ol class="mt-5 space-y-4">
      <li>
        <div class="flex flex-wrap items-baseline gap-x-2">
          <span class="text-sm font-medium text-foreground">Visited pricing</span>
          <span class="text-sm tabular-nums text-muted-foreground">18,420</span>
          <span class="ms-auto text-xs text-muted-foreground">Top of the funnel</span>
        </div>
        <div aria-hidden="true" class="mt-1.5 h-2.5 w-full overflow-hidden rounded-full border border-transparent bg-muted">
          <div class="h-full rounded-full bg-primary transition-[width] duration-500 border border-transparent" style="width:100%"></div>
        </div>
        <p class="mt-1 text-xs text-muted-foreground">100.0% of the funnel</p>
      </li>
      <li>
        <div class="flex flex-wrap items-baseline gap-x-2">
          <span class="text-sm font-medium text-foreground">Started signup</span>
          <span class="text-sm tabular-nums text-muted-foreground">6,215</span>
          <span class="ms-auto text-sm font-semibold tabular-nums text-foreground">33.7%<span class="ms-1 text-xs font-normal text-muted-foreground">from previous step</span></span>
        </div>
        <div aria-hidden="true" class="mt-1.5 h-2.5 w-full overflow-hidden rounded-full border border-transparent bg-muted">
          <div class="h-full rounded-full bg-primary transition-[width] duration-500 border border-transparent" style="width:33.74049945711183%"></div>
        </div>
        <p class="mt-1 text-xs text-muted-foreground"><strong class="font-medium text-foreground tabular-nums">12,205</strong> did not continue · 33.7% of everyone who started</p>
      </li>
      <li>
        <div class="flex flex-wrap items-baseline gap-x-2">
          <span class="text-sm font-medium text-foreground">Verified email</span>
          <span class="text-sm tabular-nums text-muted-foreground">4,988</span>
          <span class="ms-auto text-sm font-semibold tabular-nums text-foreground">80.3%<span class="ms-1 text-xs font-normal text-muted-foreground">from previous step</span></span>
        </div>
        <div aria-hidden="true" class="mt-1.5 h-2.5 w-full overflow-hidden rounded-full border border-transparent bg-muted">
          <div class="h-full rounded-full bg-primary transition-[width] duration-500 border border-transparent" style="width:27.07926167209555%"></div>
        </div>
        <p class="mt-1 text-xs text-muted-foreground"><strong class="font-medium text-foreground tabular-nums">1,227</strong> did not continue · 27.1% of everyone who started · Link expires after 24 hours</p>
      </li>
      <li>
        <div class="flex flex-wrap items-baseline gap-x-2">
          <span class="text-sm font-medium text-foreground">Reached checkout</span>
          <span class="text-sm tabular-nums text-muted-foreground">1,102</span>
          <span class="ms-auto text-sm font-semibold tabular-nums text-foreground">22.1%<span class="ms-1 text-xs font-normal text-muted-foreground">from previous step</span></span>
        </div>
        <div aria-hidden="true" class="mt-1.5 h-2.5 w-full overflow-hidden rounded-full border border-transparent bg-muted">
          <div class="h-full rounded-full bg-primary transition-[width] duration-500 border border-transparent" style="width:5.982627578718784%"></div>
        </div>
        <p class="mt-1 text-xs text-muted-foreground"><strong class="font-medium text-foreground tabular-nums">3,886</strong> did not continue · 6.0% of everyone who started</p>
        <p class="mt-1.5 inline-flex items-center gap-1.5 rounded-md bg-amber-500/10 px-2 py-0.5 text-xs text-amber-700 dark:text-amber-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trending-down h-3 w-3" aria-hidden="true"><path d="M16 17h6v-6"></path><path d="m22 17-8.5-8.5-5 5L2 7"></path></svg>Biggest drop in the funnel — 77.9% of the previous step stops here</p>
      </li>
      <li>
        <div class="flex flex-wrap items-baseline gap-x-2">
          <span class="text-sm font-medium text-foreground">Paid</span>
          <span class="text-sm tabular-nums text-muted-foreground">1,014</span>
          <span class="ms-auto text-sm font-semibold tabular-nums text-foreground">92.0%<span class="ms-1 text-xs font-normal text-muted-foreground">from previous step</span></span>
        </div>
        <div aria-hidden="true" class="mt-1.5 h-2.5 w-full overflow-hidden rounded-full border border-transparent bg-muted">
          <div class="h-full rounded-full bg-primary transition-[width] duration-500 border border-transparent" style="width:5.504885993485342%"></div>
        </div>
        <p class="mt-1 text-xs text-muted-foreground"><strong class="font-medium text-foreground tabular-nums">88</strong> did not continue · 5.5% of everyone who started</p>
      </li>
    </ol>
    <p class="mt-5 border-t border-border pt-3 text-xs leading-relaxed text-muted-foreground">Counted as a cohort: every person here entered at the first step inside the window and is followed through in order. Someone who signed up last month and paid this month is not in these numbers, so this reads lower than a per-step total over the same period — and it is the one that answers “what happens to the people we get”.</p>
  </div>
</section>
  • This is rendered HTML, not a translation of the React source. The Tailwind classes carry the design and work in any framework.
  • It is one frame: the component in its initial state, with no props applied beyond the defaults.
  • This block is interactive in React — toggles, menus or form state. None of that survives here; the markup is the closed/default state and the handlers are gone. Re-wire them in your own framework.
  • Requires Tailwind, and the design tokens the classes reference (bg-card, text-muted-foreground, and so on). The template ZIPs ship a globals.css that defines them.

What each framework gets across the whole catalog — effects convert properly; this rung is markup.

For AI

The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.

See the prompt

Used in these pages

Want the whole screen instead of this one section? Open a page and copy it entire.

9 more blocks in Charts & Metrics

All of them free to read, copy and install — no account, no locked tiles, no watermarked preview. The whole catalog is open, and so are the API and the CLI.

Browse Charts & Metrics

Shipping one commercially

Copying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.

  • A commercial licence for everything in the catalog
  • Unlimited bundle exports, in Vue, Svelte and Tailwind
  • One payment — no subscription, nothing to renew
Pro — $79 once

More Charts & Metrics blocks

View category
Open the full page for this block

Sparkline Metric Cards

KPI cards with a hand-built SVG trend line — no charting dependency for a path this file computes in twelve lines.

Charts & Metrics142 linesNo deps
Open the full page for this block

CSS Bar Chart Panel

Categorical bars in plain CSS with gridlines, hover values and a screen-reader table carrying the real numbers.

Charts & Metrics139 linesNo deps
Open the full page for this block

Quota & Usage Meters

Consumption against plan limits, with warning thresholds, honest overage display and unmetered quotas that do not fake a bar.

Charts & Metrics172 lines1 dep
Open the full page for this block

Dual-Series Line Chart

A year-over-year trend in inline SVG with a printed axis, no charting dependency and the same data repeated as a screen-reader table.

Charts & Metrics277 linesNo deps
Open the full page for this block

Donut Breakdown

Where a total went, drawn as one conic-gradient ring with the total in the hole and every number repeated in the legend.

Charts & Metrics205 linesNo deps
Open the full page for this block

Daily Activity Heatmap

A year of daily activity as one square per day, bucketed against a quantile so a single outlier cannot flatten the scale.

Charts & Metrics231 linesNo deps
Open the full page for this block

Metric Alert List

Alert thresholds with their current distance from firing, so a quiet alert is visibly armed rather than possibly broken.

Charts & Metrics141 linesNo deps
Open the full page for this block

KPI Summary Band

The top-of-dashboard figures, each carrying the comparison period that makes a delta mean anything.

Charts & Metrics118 linesNo deps
Open the full page for this block

Market Price Ticker

The moving price rail that sits under an exchange’s navbar — direction carried by an arrow and a sign as well as by colour, and the duplicate half dropped rather than read twice.

Charts & Metrics180 lines1 dep