Hairline Stats Band
A four-up metrics strip with 1px hairline dividers, built from a single gap-px grid so the rules stay crisp on any display.
The buyer’s current situation in one column and yours in the next, as a real table with scoped headers — the comparison they are actually making, which a delta against your own past cannot express.
components/stats-comparison.tsxnpx hoverlab add stats-comparison
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.
Before and after
| Metric | Shared inbox and a rota | On the platform | Change |
|---|---|---|---|
| Median first response | 3.2 hrs | 11 min | Improved: 94% faster |
| Tickets escalated to engineering | 1 in 6 | 1 in 12 | Improved: 48% fewer |
| Resolved without a second reply | 41% | 73% | Improved: +32 pts |
| Cost per resolved ticket | $4.90 | $3.38 | Improved: 31% lower |
| Hours a week spent on triage | 22 | 6 | Improved: 16 hrs back |
Medians across 412 accounts over twelve months. “Before” figures are the ones those teams reported at the start of their pilot.
Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.
/**
* <StatsComparison> — the same measurements, before and after.
*
* <StatsCards> shows a figure and a delta, which answers "is this getting
* better". It cannot answer "better than what we were doing instead",
* because a delta compares a metric to its own past and a buyer is
* comparing it to their present. Someone reading a results section already
* has a status quo — a spreadsheet, an incumbent, four people doing it by
* hand — and the number they need is the one next to theirs.
*
* So this is a real two-column comparison, and the left column is deliberately
* unflattering: it is the reader's current situation described in their own
* terms. A comparison where the "before" is a strawman is one the reader
* spots immediately and stops trusting, so `before` values should be
* defensible enough to survive being quoted back.
*
* A <table>, not a <dl>. There are two dimensions here — metric by scenario
* — and that is precisely what a table is for. Every other stats block in
* this catalog is a definition list because it has one dimension; using a
* table there would be as wrong as using a <dl> here. The column headers are
* real <th scope="col">, so a screen reader reading a cell says which
* scenario it belongs to rather than reading twelve loose numbers.
*
* The improvement column is derived from nothing — it is authored per row.
* Computing "58% faster" from two strings would mean parsing "3.2 hrs" and
* "11 min", getting units wrong somewhere, and shipping a rounding bug into
* a section whose entire job is being believable.
*
* Direction is never carried by colour alone: each improvement pairs its
* tint with an arrow and a `sr-only` word, matching <StatsCards>.
*/
import * as React from 'react'
import { ArrowDown, ArrowUp } from 'lucide-react'
export interface ComparisonRow {
metric: string
before: string
after: string
/** Authored, not computed. See the note above. */
improvement?: string
/** Which way is good. 'up' for throughput, 'down' for latency and cost. */
direction?: 'up' | 'down'
}
export interface StatsComparisonProps {
eyebrow?: string
heading?: string
beforeLabel?: string
afterLabel?: string
rows?: ComparisonRow[]
footnote?: string
className?: string
}
const DEFAULT_ROWS: ComparisonRow[] = [
{
metric: 'Median first response',
before: '3.2 hrs',
after: '11 min',
improvement: '94% faster',
direction: 'down',
},
{
metric: 'Tickets escalated to engineering',
before: '1 in 6',
after: '1 in 12',
improvement: '48% fewer',
direction: 'down',
},
{
metric: 'Resolved without a second reply',
before: '41%',
after: '73%',
improvement: '+32 pts',
direction: 'up',
},
{
metric: 'Cost per resolved ticket',
before: '$4.90',
after: '$3.38',
improvement: '31% lower',
direction: 'down',
},
{
metric: 'Hours a week spent on triage',
before: '22',
after: '6',
improvement: '16 hrs back',
direction: 'down',
},
]
/*
A stable id suffix derived from this instance's own heading.
`useId` is the right answer and is not available here: this is a server
component and hooks are not. The heading is what differs when a section is
used twice on one page, so hashing it gives each copy its own
`aria-labelledby` target without a hook, a prop or a counter — and it
stays stable across server and client renders, which a counter would not.
*/
function instanceId(...parts: (string | undefined)[]): string {
const text = parts.filter(Boolean).join('|')
let hash = 0
for (let i = 0; i < text.length; i++) hash = (Math.imul(hash, 31) + text.charCodeAt(i)) | 0
return (hash >>> 0).toString(36).slice(0, 6)
}
export function StatsComparison({
eyebrow = 'Before and after',
heading = 'Measured against what you are doing now, not against last quarter',
beforeLabel = 'Shared inbox and a rota',
afterLabel = 'On the platform',
rows = DEFAULT_ROWS,
footnote = 'Medians across 412 accounts over twelve months. “Before” figures are the ones those teams reported at the start of their pilot.',
className = '',
}: StatsComparisonProps) {
const headingId = `stats-comparison-heading-${instanceId(heading, eyebrow)}`
return (
<section
aria-labelledby={headingId}
className={`mx-auto w-full max-w-5xl px-4 py-16 sm:px-6 lg:px-8 ${className}`}
>
<div className="mx-auto max-w-2xl text-center">
<p className="text-sm font-medium uppercase tracking-wider text-primary">{eyebrow}</p>
<h2
id={headingId}
className="mt-3 text-balance text-3xl font-bold tracking-tight text-foreground sm:text-4xl"
>
{heading}
</h2>
</div>
{/* Scrolls rather than compressing: five columns of numbers squeezed
into 320px is unreadable, and a horizontal scroll on a table is a
pattern phone users already understand. */}
{/*
`relative` is load-bearing. Tailwind's `sr-only` is `position:
absolute`, and an overflow box only clips an absolutely positioned
descendant when it is also that descendant's containing block.
Without it the "Improved:" labels in the right-hand cells laid out at their static position,
escaped this scroller and dragged the page sideways on a phone,
pointing at nothing anyone could see. Same fix as /compare.
*/}
<div className="relative mt-12 overflow-x-auto">
<table className="w-full min-w-[36rem] border-collapse text-start">
<caption className="sr-only">
Support metrics {beforeLabel} compared with {afterLabel}
</caption>
<thead>
<tr className="border-b border-border">
<th scope="col" className="py-4 pe-6 text-sm font-semibold text-foreground">
Metric
</th>
<th scope="col" className="px-6 py-4 text-sm font-medium text-muted-foreground">
{beforeLabel}
</th>
<th scope="col" className="px-6 py-4 text-sm font-semibold text-foreground">
{afterLabel}
</th>
<th scope="col" className="py-4 ps-6 text-end text-sm font-medium text-muted-foreground">
Change
</th>
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{rows.map((row) => (
<tr key={row.metric}>
<th
scope="row"
className="py-5 pe-6 text-sm font-medium leading-snug text-foreground"
>
{row.metric}
</th>
<td className="px-6 py-5 text-lg tabular-nums text-muted-foreground line-through decoration-muted-foreground/40">
{row.before}
</td>
<td className="px-6 py-5 text-lg font-bold tabular-nums text-foreground">
{row.after}
</td>
<td className="py-5 ps-6 text-end">
{row.improvement ? (
<span className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2.5 py-1 text-xs font-semibold text-primary">
{row.direction === 'up' ? (
<ArrowUp aria-hidden className="h-3.5 w-3.5" />
) : (
<ArrowDown aria-hidden className="h-3.5 w-3.5" />
)}
<span className="sr-only">Improved: </span>
{row.improvement}
</span>
) : null}
</td>
</tr>
))}
</tbody>
</table>
</div>
{footnote ? (
<p className="mt-8 text-pretty text-center text-xs leading-relaxed text-muted-foreground">
{footnote}
</p>
) : null}
</section>
)
}
bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.Drop it at components/stats-comparison.tsx and import it where you need the section:
import { StatsComparison } from '@/components/stats-comparison'4 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 |
|---|---|---|
eyebrow | string | 'Before and after' |
heading | string | 'Measured against what you are doing now, not… |
beforeLabel | string | 'Shared inbox and a rota' |
afterLabel | string | 'On the platform' |
rows | ComparisonRow[] | DEFAULT_ROWS |
footnote | string | — |
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.
<!--
Before-and-After Metric Table — 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 has no interactive behaviour, so nothing is missing.
-->
<section aria-labelledby="stats-comparison-heading-ysfban" class="mx-auto w-full max-w-5xl px-4 py-16 sm:px-6 lg:px-8 ">
<div class="mx-auto max-w-2xl text-center">
<p class="text-sm font-medium uppercase tracking-wider text-primary">Before and after</p>
<h2 id="stats-comparison-heading-ysfban" class="mt-3 text-balance text-3xl font-bold tracking-tight text-foreground sm:text-4xl">Measured against what you are doing now, not against last quarter</h2>
</div>
<div class="relative mt-12 overflow-x-auto">
<table class="w-full min-w-[36rem] border-collapse text-start">
<caption class="sr-only">Support metrics Shared inbox and a rota compared with On the platform</caption>
<thead>
<tr class="border-b border-border">
<th scope="col" class="py-4 pe-6 text-sm font-semibold text-foreground">Metric</th>
<th scope="col" class="px-6 py-4 text-sm font-medium text-muted-foreground">Shared inbox and a rota</th>
<th scope="col" class="px-6 py-4 text-sm font-semibold text-foreground">On the platform</th>
<th scope="col" class="py-4 ps-6 text-end text-sm font-medium text-muted-foreground">Change</th>
</tr>
</thead>
<tbody class="divide-y divide-border/60">
<tr>
<th scope="row" class="py-5 pe-6 text-sm font-medium leading-snug text-foreground">Median first response</th>
<td class="px-6 py-5 text-lg tabular-nums text-muted-foreground line-through decoration-muted-foreground/40">3.2 hrs</td>
<td class="px-6 py-5 text-lg font-bold tabular-nums text-foreground">11 min</td>
<td class="py-5 ps-6 text-end">
<span class="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2.5 py-1 text-xs font-semibold text-primary"><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-arrow-down h-3.5 w-3.5" aria-hidden="true"><path d="M12 5v14"></path><path d="m19 12-7 7-7-7"></path></svg><span class="sr-only">Improved: </span>94% faster</span>
</td>
</tr>
<tr>
<th scope="row" class="py-5 pe-6 text-sm font-medium leading-snug text-foreground">Tickets escalated to engineering</th>
<td class="px-6 py-5 text-lg tabular-nums text-muted-foreground line-through decoration-muted-foreground/40">1 in 6</td>
<td class="px-6 py-5 text-lg font-bold tabular-nums text-foreground">1 in 12</td>
<td class="py-5 ps-6 text-end">
<span class="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2.5 py-1 text-xs font-semibold text-primary"><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-arrow-down h-3.5 w-3.5" aria-hidden="true"><path d="M12 5v14"></path><path d="m19 12-7 7-7-7"></path></svg><span class="sr-only">Improved: </span>48% fewer</span>
</td>
</tr>
<tr>
<th scope="row" class="py-5 pe-6 text-sm font-medium leading-snug text-foreground">Resolved without a second reply</th>
<td class="px-6 py-5 text-lg tabular-nums text-muted-foreground line-through decoration-muted-foreground/40">41%</td>
<td class="px-6 py-5 text-lg font-bold tabular-nums text-foreground">73%</td>
<td class="py-5 ps-6 text-end">
<span class="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2.5 py-1 text-xs font-semibold text-primary"><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-arrow-up h-3.5 w-3.5" aria-hidden="true"><path d="m5 12 7-7 7 7"></path><path d="M12 19V5"></path></svg><span class="sr-only">Improved: </span>+32 pts</span>
</td>
</tr>
<tr>
<th scope="row" class="py-5 pe-6 text-sm font-medium leading-snug text-foreground">Cost per resolved ticket</th>
<td class="px-6 py-5 text-lg tabular-nums text-muted-foreground line-through decoration-muted-foreground/40">$4.90</td>
<td class="px-6 py-5 text-lg font-bold tabular-nums text-foreground">$3.38</td>
<td class="py-5 ps-6 text-end">
<span class="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2.5 py-1 text-xs font-semibold text-primary"><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-arrow-down h-3.5 w-3.5" aria-hidden="true"><path d="M12 5v14"></path><path d="m19 12-7 7-7-7"></path></svg><span class="sr-only">Improved: </span>31% lower</span>
</td>
</tr>
<tr>
<th scope="row" class="py-5 pe-6 text-sm font-medium leading-snug text-foreground">Hours a week spent on triage</th>
<td class="px-6 py-5 text-lg tabular-nums text-muted-foreground line-through decoration-muted-foreground/40">22</td>
<td class="px-6 py-5 text-lg font-bold tabular-nums text-foreground">6</td>
<td class="py-5 ps-6 text-end">
<span class="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2.5 py-1 text-xs font-semibold text-primary"><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-arrow-down h-3.5 w-3.5" aria-hidden="true"><path d="M12 5v14"></path><path d="m19 12-7 7-7-7"></path></svg><span class="sr-only">Improved: </span>16 hrs back</span>
</td>
</tr>
</tbody>
</table>
</div>
<p class="mt-8 text-pretty text-center text-xs leading-relaxed text-muted-foreground">Medians across 412 accounts over twelve months. “Before” figures are the ones those teams reported at the start of their pilot.</p>
</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 StatsCopying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.
A four-up metrics strip with 1px hairline dividers, built from a single gap-px grid so the rules stay crisp on any display.
Metrics carrying the direction they moved, with the good direction declared per stat so a falling churn rate reads as a win — arrow and text, never colour alone.
Four figures in a hairline card next to the paragraph making the claim, each carrying an optional source line — for numbers that mean nothing without knowing lower than what, over how long, across how many.
The same measurement taken year after year down a vertical rail, each with the event that explains it — the shape for trajectory, which no snapshot of four numbers can claim.
Performance figures with the measurement method printed beside each one, so a sceptical reader can check rather than take them.
The compliance and security facts a procurement reviewer asks for, stated as figures with their scope rather than as badges.