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 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.
components/stats-timeline.tsxnpx hoverlab add stats-timeline
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.
Six years
2021
12customers
Two founders, one region, and a product that only did ticket routing.
2022
190customers
The API shipped. Half of that year’s signups came through it.
2023
1,100customers
First enterprise contract, and the audit tooling it forced us to build.
2024
2,400customers
Broke even in Q3 and have not raised since.
2025
3,300customers
Opened Frankfurt and Sydney. Data residency stopped being a blocker.
2026
4,180customers
94% of them renewed at twelve months.
Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.
/**
* <StatsTimeline> — the same measurement, taken repeatedly.
*
* The other stats blocks are all snapshots. <StatsBand> says how big,
* <StatsCards> says which way, <StatsComparison> says better than what — and
* none of them can say "for six years running", which is the claim that
* separates a company still here from a company that had a good quarter.
* Trajectory is a different argument from magnitude, and on an about page,
* an investor update or a Series B announcement it is the only argument
* anyone is reading for.
*
* An <ol>, because the order is the content. A definition list would say
* these are labelled values; an ordered list says the sequence matters and
* that reading them out of order loses the point. It also means a screen
* reader announces "3 of 6", so a listener knows where in the arc they are
* — which is the whole reason a sighted reader can follow the rule down
* the page.
*
* The rule is drawn with a border on the list and dots positioned over it,
* rather than a segment per item. Per-item segments leave a stub hanging
* below the final entry, and the usual fix — hiding the last one — breaks
* the moment someone renders a single milestone.
*
* `note` exists so a year can carry the thing that actually happened. A
* column of numbers with no events is a chart drawn badly; the number is
* the evidence and the note is the claim it supports.
*/
import * as React from 'react'
export interface Milestone {
/** Year, quarter, or any period label. Rendered as authored. */
period: string
value: string
label: string
/** What happened that year. The number alone is not a story. */
note?: string
}
export interface StatsTimelineProps {
eyebrow?: string
heading?: string
milestones?: Milestone[]
className?: string
}
const DEFAULT_MILESTONES: Milestone[] = [
{
period: '2021',
value: '12',
label: 'customers',
note: 'Two founders, one region, and a product that only did ticket routing.',
},
{
period: '2022',
value: '190',
label: 'customers',
note: 'The API shipped. Half of that year’s signups came through it.',
},
{
period: '2023',
value: '1,100',
label: 'customers',
note: 'First enterprise contract, and the audit tooling it forced us to build.',
},
{
period: '2024',
value: '2,400',
label: 'customers',
note: 'Broke even in Q3 and have not raised since.',
},
{
period: '2025',
value: '3,300',
label: 'customers',
note: 'Opened Frankfurt and Sydney. Data residency stopped being a blocker.',
},
{
period: '2026',
value: '4,180',
label: 'customers',
note: '94% of them renewed at twelve months.',
},
]
/*
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 StatsTimeline({
eyebrow = 'Six years',
heading = 'Growth is a line, not a number',
milestones = DEFAULT_MILESTONES,
className = '',
}: StatsTimelineProps) {
const headingId = `stats-timeline-heading-${instanceId(heading, eyebrow)}`
return (
<section
aria-labelledby={headingId}
className={`mx-auto w-full max-w-3xl px-4 py-16 sm:px-6 lg:px-8 ${className}`}
>
<div className="max-w-2xl">
<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>
{/* The rule is this list's left border, so it starts at the first dot
and ends at the last one however many there are. */}
<ol className="mt-12 space-y-10 border-s border-border/60 ps-8 sm:ps-10">
{milestones.map((milestone) => (
<li key={milestone.period} className="relative">
<span
aria-hidden
className="absolute -start-8 top-2 h-2.5 w-2.5 -translate-x-1/2 rtl:translate-x-1/2 rounded-full bg-primary ring-4 ring-background sm:-start-10 border border-transparent"
/>
<p className="text-sm font-medium tabular-nums text-muted-foreground">
{milestone.period}
</p>
<p className="mt-1 flex flex-wrap items-baseline gap-x-2">
<span className="text-3xl font-bold tracking-tight text-foreground sm:text-4xl">
{milestone.value}
</span>
<span className="text-base text-muted-foreground">{milestone.label}</span>
</p>
{milestone.note ? (
<p className="mt-2 max-w-prose text-pretty text-sm leading-relaxed text-muted-foreground">
{milestone.note}
</p>
) : null}
</li>
))}
</ol>
</section>
)
}
bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.Drop it at components/stats-timeline.tsx and import it where you need the section:
import { StatsTimeline } from '@/components/stats-timeline'2 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 | 'Six years' |
heading | string | 'Growth is a line, not a number' |
milestones | Milestone[] | DEFAULT_MILESTONES |
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.
<!--
Milestone Timeline — 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-timeline-heading-1sor1s" class="mx-auto w-full max-w-3xl px-4 py-16 sm:px-6 lg:px-8 ">
<div class="max-w-2xl">
<p class="text-sm font-medium uppercase tracking-wider text-primary">Six years</p>
<h2 id="stats-timeline-heading-1sor1s" class="mt-3 text-balance text-3xl font-bold tracking-tight text-foreground sm:text-4xl">Growth is a line, not a number</h2>
</div>
<ol class="mt-12 space-y-10 border-s border-border/60 ps-8 sm:ps-10">
<li class="relative">
<span aria-hidden="true" class="absolute -start-8 top-2 h-2.5 w-2.5 -translate-x-1/2 rtl:translate-x-1/2 rounded-full bg-primary ring-4 ring-background sm:-start-10 border border-transparent"></span>
<p class="text-sm font-medium tabular-nums text-muted-foreground">2021</p>
<p class="mt-1 flex flex-wrap items-baseline gap-x-2">
<span class="text-3xl font-bold tracking-tight text-foreground sm:text-4xl">12</span>
<span class="text-base text-muted-foreground">customers</span>
</p>
<p class="mt-2 max-w-prose text-pretty text-sm leading-relaxed text-muted-foreground">Two founders, one region, and a product that only did ticket routing.</p>
</li>
<li class="relative">
<span aria-hidden="true" class="absolute -start-8 top-2 h-2.5 w-2.5 -translate-x-1/2 rtl:translate-x-1/2 rounded-full bg-primary ring-4 ring-background sm:-start-10 border border-transparent"></span>
<p class="text-sm font-medium tabular-nums text-muted-foreground">2022</p>
<p class="mt-1 flex flex-wrap items-baseline gap-x-2">
<span class="text-3xl font-bold tracking-tight text-foreground sm:text-4xl">190</span>
<span class="text-base text-muted-foreground">customers</span>
</p>
<p class="mt-2 max-w-prose text-pretty text-sm leading-relaxed text-muted-foreground">The API shipped. Half of that year’s signups came through it.</p>
</li>
<li class="relative">
<span aria-hidden="true" class="absolute -start-8 top-2 h-2.5 w-2.5 -translate-x-1/2 rtl:translate-x-1/2 rounded-full bg-primary ring-4 ring-background sm:-start-10 border border-transparent"></span>
<p class="text-sm font-medium tabular-nums text-muted-foreground">2023</p>
<p class="mt-1 flex flex-wrap items-baseline gap-x-2">
<span class="text-3xl font-bold tracking-tight text-foreground sm:text-4xl">1,100</span>
<span class="text-base text-muted-foreground">customers</span>
</p>
<p class="mt-2 max-w-prose text-pretty text-sm leading-relaxed text-muted-foreground">First enterprise contract, and the audit tooling it forced us to build.</p>
</li>
<li class="relative">
<span aria-hidden="true" class="absolute -start-8 top-2 h-2.5 w-2.5 -translate-x-1/2 rtl:translate-x-1/2 rounded-full bg-primary ring-4 ring-background sm:-start-10 border border-transparent"></span>
<p class="text-sm font-medium tabular-nums text-muted-foreground">2024</p>
<p class="mt-1 flex flex-wrap items-baseline gap-x-2">
<span class="text-3xl font-bold tracking-tight text-foreground sm:text-4xl">2,400</span>
<span class="text-base text-muted-foreground">customers</span>
</p>
<p class="mt-2 max-w-prose text-pretty text-sm leading-relaxed text-muted-foreground">Broke even in Q3 and have not raised since.</p>
</li>
<li class="relative">
<span aria-hidden="true" class="absolute -start-8 top-2 h-2.5 w-2.5 -translate-x-1/2 rtl:translate-x-1/2 rounded-full bg-primary ring-4 ring-background sm:-start-10 border border-transparent"></span>
<p class="text-sm font-medium tabular-nums text-muted-foreground">2025</p>
<p class="mt-1 flex flex-wrap items-baseline gap-x-2">
<span class="text-3xl font-bold tracking-tight text-foreground sm:text-4xl">3,300</span>
<span class="text-base text-muted-foreground">customers</span>
</p>
<p class="mt-2 max-w-prose text-pretty text-sm leading-relaxed text-muted-foreground">Opened Frankfurt and Sydney. Data residency stopped being a blocker.</p>
</li>
<li class="relative">
<span aria-hidden="true" class="absolute -start-8 top-2 h-2.5 w-2.5 -translate-x-1/2 rtl:translate-x-1/2 rounded-full bg-primary ring-4 ring-background sm:-start-10 border border-transparent"></span>
<p class="text-sm font-medium tabular-nums text-muted-foreground">2026</p>
<p class="mt-1 flex flex-wrap items-baseline gap-x-2">
<span class="text-3xl font-bold tracking-tight text-foreground sm:text-4xl">4,180</span>
<span class="text-base text-muted-foreground">customers</span>
</p>
<p class="mt-2 max-w-prose text-pretty text-sm leading-relaxed text-muted-foreground">94% of them renewed at twelve months.</p>
</li>
</ol>
</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 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.
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.