Skip to content
Marketing 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.

151 lineslucide-reactAdded 24 Aug 2026Updated 13 Sept 2026
  • stats
  • metrics
  • proof
  • results
  • narrative

What's included

  • components/stats-narrative.tsx
  • Needs lucide-react

Works with

  • React
  • Next.js
  • Tailwind CSS
  • TypeScript

npx hoverlab add stats-narrative

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

Results

The case for switching, in four numbers we will show our working on

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.

31%
lower cost per resolved ticket
Median across 412 accounts, 12 months
11 min
median first response, from 3.2 hrs
Platform telemetry, Q2 2026
4 weeks
median pilot to full rollout
Onboarding records, 2025–26
94%
of accounts renew at twelve months
Billing, cohort of 2,180

Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.

Source

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

Before you paste

  • Styling is Tailwind utility classes on semantic tokens (bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.
  • Install: npm i lucide-react
  • Every prop has a default, so it renders standalone before you wire it up.

Where it goes

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

import { StatsNarrative } from '@/components/stats-narrative'

Customize

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.

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'Results'
headingstring'The case for switching, in four numbers we w…
bodystring—
statsNarrativeStat[]DEFAULT_STATS
ctaLabelstring'Read the methodology'
ctaHrefstring—
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-narrative.html
<!--
  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>
  • 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

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

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.

Stats157 linesNo deps
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