# Count-Up Stats On Scroll

> Four-up stat grid whose numbers count up from zero when scrolled into view, each cell pairing an icon with a growth delta badge.

A shadcn/ui block from the [@7ovr](https://7ovr.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/7ovr/stats-4).

- Page, with a live preview: https://shadcn.exchange/r/7ovr/stats-4
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/7ovr/stats-4-light-0041293f9fe65b3b.png
- Registry item: https://7ovr.com/r/stats-4.json
- Categories: stats
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://7ovr.com/r/stats-4.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `@base-ui/react`
- Registry items: `badge`

## Files

### `registry/blocks/stats/4/stats-block.tsx`

```tsx
"use client"

import { useEffect, useRef, useState } from "react"
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
import { IconPlaceholder } from "@/components/icons/icon-placeholder"

type IconProps = { className?: string; size?: number | string }

type IconRenderer = (props: IconProps) => React.ReactNode

type Metric = {
  id: string
  icon: IconRenderer
  target: number
  decimals: number
  prefix: string
  suffix: string
  label: string
  delta: string
}

const metrics: Metric[] = [
  {
    id: "uptime",
    icon: (p: IconProps) => (
      <IconPlaceholder
        lucide="Globe"
        tabler="IconGlobe"
        hugeicons="GlobalIcon"
        phosphor="Globe"
        remixicon="RiGlobalLine"
        {...p}
      />
    ),
    target: 99.9,
    decimals: 1,
    prefix: "",
    suffix: "%",
    label: "Service uptime",
    delta: "+0.4%",
  },
  {
    id: "users",
    icon: (p: IconProps) => (
      <IconPlaceholder
        lucide="Users"
        tabler="IconUsersGroup"
        hugeicons="UserGroupIcon"
        phosphor="UsersThree"
        remixicon="RiTeamLine"
        {...p}
      />
    ),
    target: 12,
    decimals: 0,
    prefix: "",
    suffix: "k+",
    label: "Active teams",
    delta: "+18%",
  },
  {
    id: "rating",
    icon: (p: IconProps) => (
      <IconPlaceholder
        lucide="Star"
        tabler="IconStar"
        hugeicons="StarIcon"
        phosphor="Star"
        remixicon="RiStarSmileLine"
        {...p}
      />
    ),
    target: 4.9,
    decimals: 1,
    prefix: "",
    suffix: "/5",
    label: "Average rating",
    delta: "+0.2",
  },
  {
    id: "integrations",
    icon: (p: IconProps) => (
      <IconPlaceholder
        lucide="Clock"
        tabler="IconClock"
        hugeicons="TimeIcon"
        phosphor="Clock"
        remixicon="RiTimeLine"
        {...p}
      />
    ),
    target: 250,
    decimals: 0,
    prefix: "",
    suffix: "+",
    label: "Integrations shipped",
    delta: "+32",
  },
]

function formatValue(value: number, metric: Metric) {
  const fixed = value.toFixed(metric.decimals)
  return `${metric.prefix}${fixed}${metric.suffix}`
}

export default function StatsBlock() {
  const sectionRef = useRef<HTMLElement | null>(null)
  const [progress, setProgress] = useState(0)

  useEffect(() => {
    const node = sectionRef.current
    if (!node) return

    let frame = 0
    let started = false

    const animate = (start: number) => {
      const duration = 1600

      const step = (now: number) => {
        const elapsed = now - start
        const linear = Math.min(elapsed / duration, 1)
        const eased = 1 - Math.pow(1 - linear, 3)
        setProgress(eased)
        if (linear < 1) {
          frame = requestAnimationFrame(step)
        }
      }

      frame = requestAnimationFrame(step)
    }

    const prefersReduced =
      typeof window !== "undefined" &&
      window.matchMedia("(prefers-reduced-motion: reduce)").matches

    if (prefersReduced) {
      const id = requestAnimationFrame(() => setProgress(1))
      return () => cancelAnimationFrame(id)
    }

    const observer = new IntersectionObserver(
      (entries) => {
        for (const entry of entries) {
          if (entry.isIntersecting && !started) {
            started = true
            animate(performance.now())
            observer.disconnect()
          }
        }
      },
      { threshold: 0.35 }
    )

    observer.observe(node)

    return () => {
      observer.disconnect()
      if (frame) cancelAnimationFrame(frame)
    }
  }, [])

  return (
    <section
      ref={sectionRef}
      className="flex w-full items-center justify-center bg-muted/30 px-6 py-16 text-foreground"
    >
      <div className="mx-auto w-full max-w-6xl">
        <div className="mx-auto max-w-2xl text-center">
          <Badge variant="secondary" className="mb-4">
            <IconPlaceholder
              lucide="ArrowUp"
              tabler="IconArrowUp"
              hugeicons="ArrowUpIcon"
              phosphor="ArrowUp"
              remixicon="RiArrowUpLine"
              data-icon="inline-start"
            />
            By the Numbers
          </Badge>
          <h2 className="font-heading text-3xl font-semibold tracking-tight sm:text-4xl">
            Numbers teams at Acme trust
          </h2>
          <p className="mt-3 text-base text-balance text-muted-foreground">
            Real performance from a platform built to scale with you, measured
            and reported every single quarter.
          </p>
        </div>

        <dl className="mt-12 grid grid-cols-1 gap-px overflow-hidden rounded-xl border border-border bg-border sm:grid-cols-2 md:grid-cols-4">
          {metrics.map((metric) => {
            const Icon = metric.icon
            const current = metric.target * progress
            return (
              <div
                key={metric.id}
                className="group flex flex-col gap-4 bg-card p-6 transition-colors hover:bg-card/60 sm:p-8"
              >
                <div className="flex items-center justify-between">
                  <span className="flex size-10 items-center justify-center rounded-md bg-muted text-muted-foreground transition-colors group-hover:bg-primary/10 group-hover:text-primary">
                    <Icon className="size-5" aria-hidden="true" />
                  </span>
                  <Badge
                    variant="outline"
                    className="border-transparent bg-primary/10 text-primary"
                  >
                    <IconPlaceholder
                      lucide="ArrowUp"
                      tabler="IconArrowUp"
                      hugeicons="ArrowUpIcon"
                      phosphor="ArrowUp"
                      remixicon="RiArrowUpLine"
                      data-icon="inline-start"
                    />
                    {metric.delta}
                  </Badge>
                </div>
                <div>
                  <dd
                    className={cn(
                      "text-4xl font-semibold tracking-tight tabular-nums sm:text-5xl"
                    )}
                  >
                    {formatValue(current, metric)}
                  </dd>
                  <dt className="mt-2 text-sm text-muted-foreground">
                    {metric.label}
                  </dt>
                </div>
              </div>
            )
          })}
        </dl>
      </div>
    </section>
  )
}
```
