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.
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.
components/stats-narrative.tsxnpx hoverlab add stats-narrative
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.
Results
None of these are from a launch week. They are medians across every account that has been on the platform for a full year, including the ones that churned — which is why the figures are lower than the ones on our competitors’ pages and why we are willing to name the sample size.
Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.
/**
* <StatsNarrative> — numbers with the argument they belong to.
*
* <StatsBand> and <StatsCards> are both strips: four figures across a page,
* no prose, dropped between two other sections. That works when the numbers
* are self-evident — "4,000 customers", "99.99% uptime" — and fails the
* moment they are not. "31% lower cost per ticket" is meaningless without
* knowing lower than what, measured over how long, across how many teams,
* and a strip has nowhere to say any of it.
*
* This block gives the numbers a paragraph. The copy makes the claim and the
* figures stand as its evidence, which is the arrangement an investor
* update, an about page or a results section actually needs. The stats sit
* in a card beside the prose rather than under it, so on a wide screen the
* claim and its proof are read together instead of one scrolling past the
* other.
*
* Every stat carries an optional `source`. A number on a vendor's own site
* with no attribution is a number the vendor made up, and the difference
* between "31% lower" and "31% lower — 2026 customer survey, n=412" is the
* difference between marketing and evidence. It is optional because some
* figures are internal telemetry and saying so is better than inventing a
* citation, but the field being there is a prompt to fill it in.
*
* A <dl> for the figures, and the value comes before the label in the DOM as
* well as visually — a <dt> read as "31%" followed by "lower cost per
* ticket" is the order the eye takes them in, and reversing it for the sake
* of a tidier stylesheet makes the section worse to listen to.
*/
import * as React from 'react'
import { ArrowRight } from 'lucide-react'
export interface NarrativeStat {
value: string
label: string
/** Where the figure comes from. Absent is better than invented. */
source?: string
}
export interface StatsNarrativeProps {
eyebrow?: string
heading?: string
body?: string
stats?: NarrativeStat[]
ctaLabel?: string
ctaHref?: string
className?: string
}
const DEFAULT_STATS: NarrativeStat[] = [
{
value: '31%',
label: 'lower cost per resolved ticket',
source: 'Median across 412 accounts, 12 months',
},
{
value: '11 min',
label: 'median first response, from 3.2 hrs',
source: 'Platform telemetry, Q2 2026',
},
{
value: '4 weeks',
label: 'median pilot to full rollout',
source: 'Onboarding records, 2025–26',
},
{
value: '94%',
label: 'of accounts renew at twelve months',
source: 'Billing, cohort of 2,180',
},
]
/*
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 StatsNarrative({
eyebrow = 'Results',
heading = 'The case for switching, in four numbers we will show our working on',
body = 'None of these are from a launch week. They are medians across every account that has been on the platform for a full year, including the ones that churned — which is why the figures are lower than the ones on our competitors’ pages and why we are willing to name the sample size.',
stats = DEFAULT_STATS,
ctaLabel = 'Read the methodology',
ctaHref,
className = '',
}: StatsNarrativeProps) {
const headingId = `stats-narrative-heading-${instanceId(heading, eyebrow)}`
return (
<section
aria-labelledby={headingId}
className={`mx-auto w-full max-w-6xl px-4 py-16 sm:px-6 lg:px-8 ${className}`}
>
<div className="grid gap-12 lg:grid-cols-2 lg:items-center lg:gap-16">
<div className="max-w-xl">
<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>
<p className="mt-5 text-pretty leading-relaxed text-muted-foreground">{body}</p>
{ctaHref ? (
<a
href={ctaHref}
className="group mt-7 inline-flex items-center gap-1.5 text-sm font-medium text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
{ctaLabel}
<ArrowRight
aria-hidden
className="h-4 w-4 transition-transform motion-safe:group-hover:translate-x-0.5 rtl:rotate-180 rtl:motion-safe:group-hover:-translate-x-0.5"
/>
</a>
) : null}
</div>
{/* The container paints the ground the 1px gaps expose; each cell
paints its own background over it, so neighbours share a divider
instead of each drawing one. */}
<dl className="grid gap-px overflow-hidden rounded-2xl border border-border/60 bg-border/60 sm:grid-cols-2">
{stats.map((stat) => (
<div key={stat.label} className="min-w-0 bg-card p-6 sm:p-8">
<dt className="break-words text-4xl font-bold tracking-tight text-primary">{stat.value}</dt>
<dd className="mt-2 break-words text-sm font-medium leading-snug text-foreground">
{stat.label}
</dd>
{stat.source ? (
<dd className="mt-3 break-words text-xs leading-snug text-muted-foreground">{stat.source}</dd>
) : null}
</div>
))}
</dl>
</div>
</section>
)
}
bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.Drop it at components/stats-narrative.tsx and import it where you need the section:
import { StatsNarrative } from '@/components/stats-narrative'3 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 | 'Results' |
heading | string | 'The case for switching, in four numbers we w… |
body | string | — |
stats | NarrativeStat[] | DEFAULT_STATS |
ctaLabel | string | 'Read the methodology' |
ctaHref | 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.
<!--
Stats Beside the Argument — 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-narrative-heading-1v2t3s" class="mx-auto w-full max-w-6xl px-4 py-16 sm:px-6 lg:px-8 ">
<div class="grid gap-12 lg:grid-cols-2 lg:items-center lg:gap-16">
<div class="max-w-xl">
<p class="text-sm font-medium uppercase tracking-wider text-primary">Results</p>
<h2 id="stats-narrative-heading-1v2t3s" class="mt-3 text-balance text-3xl font-bold tracking-tight text-foreground sm:text-4xl">The case for switching, in four numbers we will show our working on</h2>
<p class="mt-5 text-pretty leading-relaxed text-muted-foreground">None of these are from a launch week. They are medians across every account that has been on the platform for a full year, including the ones that churned — which is why the figures are lower than the ones on our competitors’ pages and why we are willing to name the sample size.</p>
</div>
<dl class="grid gap-px overflow-hidden rounded-2xl border border-border/60 bg-border/60 sm:grid-cols-2">
<div class="min-w-0 bg-card p-6 sm:p-8">
<dt class="break-words text-4xl font-bold tracking-tight text-primary">31%</dt>
<dd class="mt-2 break-words text-sm font-medium leading-snug text-foreground">lower cost per resolved ticket</dd>
<dd class="mt-3 break-words text-xs leading-snug text-muted-foreground">Median across 412 accounts, 12 months</dd>
</div>
<div class="min-w-0 bg-card p-6 sm:p-8">
<dt class="break-words text-4xl font-bold tracking-tight text-primary">11 min</dt>
<dd class="mt-2 break-words text-sm font-medium leading-snug text-foreground">median first response, from 3.2 hrs</dd>
<dd class="mt-3 break-words text-xs leading-snug text-muted-foreground">Platform telemetry, Q2 2026</dd>
</div>
<div class="min-w-0 bg-card p-6 sm:p-8">
<dt class="break-words text-4xl font-bold tracking-tight text-primary">4 weeks</dt>
<dd class="mt-2 break-words text-sm font-medium leading-snug text-foreground">median pilot to full rollout</dd>
<dd class="mt-3 break-words text-xs leading-snug text-muted-foreground">Onboarding records, 2025–26</dd>
</div>
<div class="min-w-0 bg-card p-6 sm:p-8">
<dt class="break-words text-4xl font-bold tracking-tight text-primary">94%</dt>
<dd class="mt-2 break-words text-sm font-medium leading-snug text-foreground">of accounts renew at twelve months</dd>
<dd class="mt-3 break-words text-xs leading-snug text-muted-foreground">Billing, cohort of 2,180</dd>
</div>
</dl>
</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 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.
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.
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.