Sparkline Metric Cards
KPI cards with a hand-built SVG trend line — no charting dependency for a path this file computes in twelve lines.
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.
components/funnel-conversion-panel.tsxnpx hoverlab add funnel-conversion-panel
Or over MCP, from your editor's agent — no account needed.
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.
Start a page with this section — add more, order them, and leave with the page source.
Last 30 days
End to end 5.5%
100.0% of the funnel
12,205 did not continue · 33.7% of everyone who started
1,227 did not continue · 27.1% of everyone who started · Link expires after 24 hours
3,886 did not continue · 6.0% of everyone who started
Biggest drop in the funnel — 77.9% of the previous step stops here
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.
/**
* <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
“what happens to the people we get”.
</p>
</div>
</section>
)
}
bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.Drop it at components/funnel-conversion-panel.tsx and import it where you need the section:
import { FunnelConversionPanel } from '@/components/funnel-conversion-panel'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.
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.
| Prop | Type | Default |
|---|---|---|
title | string | 'Signup funnel' |
window | string | — |
steps | FunnelStep[] | DEFAULT_STEPS |
className | string | '' |
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.
<!--
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>
What each framework gets across the whole catalog — effects convert properly; this rung is markup.
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.
Want the whole screen instead of this one section? Open a page and copy it entire.
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 & MetricsCopying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.
KPI cards with a hand-built SVG trend line — no charting dependency for a path this file computes in twelve lines.
Categorical bars in plain CSS with gridlines, hover values and a screen-reader table carrying the real numbers.
Consumption against plan limits, with warning thresholds, honest overage display and unmetered quotas that do not fake a bar.
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.
Where a total went, drawn as one conic-gradient ring with the total in the hole and every number repeated in the legend.
A year of daily activity as one square per day, bucketed against a quantile so a single outlier cannot flatten the scale.
Alert thresholds with their current distance from firing, so a quiet alert is visibly armed rather than possibly broken.
The top-of-dashboard figures, each carrying the comparison period that makes a delta mean anything.
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.