Stats strip

Four big numbers that count up once in view, with mono labels and hairline dividers.

blockstatsany
$npx shadcn@latest add @storekit/trust-stats
trust-stats

Live and interactive. Switch the brand in the frame header to see the same source under another token file.

Installation

$npx shadcn@latest add @storekit/trust-stats
$pnpm dlx shadcn@latest add @storekit/trust-stats
$yarn dlx shadcn@latest add @storekit/trust-stats
$bunx --bun shadcn@latest add @storekit/trust-stats

Source

components/storekit/trust/trust-stats.tsx
89 lines
"use client"

import { useEffect, useRef } from "react"
import { animate, useInView, useReducedMotion } from "motion/react"
import { cn } from "@/components/storekit/lib/cn"

/* Four-up proof strip. Numbers count up from zero once, the first time the
   strip scrolls into view. Tabular numerals keep the width steady while they
   count; reduced motion shows the final value immediately. */

const EASE = [0.23, 1, 0.32, 1] as const

type Stat = { value: number; label: string; decimals?: number; prefix?: string; suffix?: string }

function format(n: number, s: Stat) {
  const num = n.toLocaleString("en-US", { minimumFractionDigits: s.decimals ?? 0, maximumFractionDigits: s.decimals ?? 0 })
  return `${s.prefix ?? ""}${num}${s.suffix ?? ""}`
}

function Counter({ stat, run }: { stat: Stat; run: boolean }) {
  const ref = useRef<HTMLSpanElement>(null)
  const reduce = useReducedMotion()

  useEffect(() => {
    const node = ref.current
    if (!node || !run) return
    if (reduce) {
      node.textContent = format(stat.value, stat)
      return
    }
    const controls = animate(0, stat.value, {
      duration: 1.4,
      ease: EASE,
      onUpdate: (v) => {
        node.textContent = format(v, stat)
      },
    })
    return () => controls.stop()
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [run, reduce, stat.value, stat.decimals, stat.prefix, stat.suffix])

  return (
    <span ref={ref} aria-hidden>
      {format(0, stat)}
    </span>
  )
}

export function TrustStats({
  stats = [
    { value: 1240, label: "Batches tested", suffix: "+" },
    { value: 120, label: "Members on subscription", suffix: "k" },
    { value: 48, label: "Hours to ship", suffix: "h" },
    { value: 4.9, label: "Customer rating", decimals: 1 },
  ],
  className,
}: {
  stats?: Stat[]
  className?: string
}) {
  const ref = useRef<HTMLDListElement>(null)
  const inView = useInView(ref, { once: true, margin: "0px 0px -15% 0px" })

  return (
    <dl
      ref={ref}
      data-sk-section="trust"
      data-sk-variant="trust-stats"
      className={cn(
        "grid grid-cols-2 gap-px overflow-hidden rounded-sk-card border border-sk-line bg-sk-line lg:grid-cols-4",
        className
      )}
    >
      {stats.map((s) => (
        <div key={s.label} className="flex flex-col-reverse justify-end gap-2 bg-sk-paper px-5 py-6 sm:px-6 sm:py-8">
          <dt className="font-sk-mono text-[11px] uppercase tracking-[0.1em] text-sk-muted sm:tracking-[0.14em]">{s.label}</dt>
          <dd
            className="font-sk-display font-semibold leading-[1.1] tracking-[-0.02em] tabular-nums text-sk-ink"
            style={{ fontSize: "clamp(2rem, 4vw, 3rem)" }}
          >
            <span className="sr-only">{format(s.value, s)}</span>
            <Counter stat={s} run={inView} />
          </dd>
        </div>
      ))}
    </dl>
  )
}

Usage

Import from "@/components/storekit/trust/trust-stats" and pass your product data. The demo above exercises the full prop surface with sample values.

Stats strip, StoreKit