Skip to content
Marketing block

Milestone Timeline

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.

157 linesNo dependenciesAdded 24 Aug 2026Updated 13 Sept 2026
  • stats
  • timeline
  • milestones
  • growth
  • about

What's included

  • components/stats-timeline.tsx
  • No runtime dependencies

Works with

  • React
  • Next.js
  • Tailwind CSS
  • TypeScript

npx hoverlab add stats-timeline

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

Six years

Growth is a line, not a number

  1. 2021

    12customers

    Two founders, one region, and a product that only did ticket routing.

  2. 2022

    190customers

    The API shipped. Half of that year’s signups came through it.

  3. 2023

    1,100customers

    First enterprise contract, and the audit tooling it forced us to build.

  4. 2024

    2,400customers

    Broke even in Q3 and have not raised since.

  5. 2025

    3,300customers

    Opened Frankfurt and Sydney. Data residency stopped being a blocker.

  6. 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.

Source

components/stats-timeline.tsx
/**
 * <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>
  )
}

Before you paste

  • Styling is Tailwind utility classes on semantic tokens (bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.
  • Nothing to install. No component library, no icon package.
  • Every prop has a default, so it renders standalone before you wire it up.

Where it goes

Drop it at components/stats-timeline.tsx and import it where you need the section:

import { StatsTimeline } from '@/components/stats-timeline'

Customize

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.

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
eyebrowstring'Six years'
headingstring'Growth is a line, not a number'
milestonesMilestone[]DEFAULT_MILESTONES
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.

stats-timeline.html
<!--
  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>
  • 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.
  • 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.

6 more blocks in Stats

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 Stats

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 Stats blocks

View category
Open the full page for this block

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.

Stats51 linesNo deps
Open the full page for this block

Stat Cards with Deltas

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.

Stats135 lines1 dep
Open the full page for this block

Stats Beside the Argument

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.

Stats151 lines1 dep
Open the full page for this block

Before-and-After Metric Table

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.

Stats210 lines1 dep
Open the full page for this block

Benchmark Stat Band

Performance figures with the measurement method printed beside each one, so a sceptical reader can check rather than take them.

Stats121 linesNo deps
Open the full page for this block

Security Posture Band

The compliance and security facts a procurement reviewer asks for, stated as figures with their scope rather than as badges.

Stats116 linesNo deps