# Stats band

Band of four big animated numbers with labels, trend chips and divider lines that draw in on view.

- Type: block
- Category: Sections
- Tier: Free
- Dependencies: lucide-react, motion

## Install

```bash
npx shadcn@latest add https://facadeui.com/r/stats-band.json
```

## Usage

```tsx
import { StatsBand } from "@/components/facade/stats-band";

export default function Page() {
  return <StatsBand />;
}
```

## Source

```tsx
"use client";

import * as React from "react";
import { animate, motion, useInView, useReducedMotion } from "motion/react";
import { TrendingUp } from "lucide-react";
import { cn } from "@/lib/utils";

export interface StatsBandItem {
  value: number;
  prefix?: string;
  suffix?: string;
  decimals?: number;
  label: string;
  /** Short trend text, e.g. "+18% vs 2024". */
  trend?: string;
  /** Direction of the trend. "down" can still be positive (e.g. response time). */
  trendDirection?: "up" | "down";
  /** Whether the trend is good news. Defaults to true. */
  trendPositive?: boolean;
}

export interface StatsBandProps {
  eyebrow?: string;
  title?: string;
  stats?: StatsBandItem[];
  /** Renders the band on an inverted (primary) surface. */
  inverted?: boolean;
  className?: string;
}

const EASE = [0.22, 1, 0.36, 1] as const;

const DEFAULT_STATS: StatsBandItem[] = [
  { value: 12480, suffix: "+", label: "Jobs completed in Central Texas", trend: "+18% vs last year" },
  { value: 4.9, decimals: 1, suffix: "/5", label: "Average rating across 1,200 reviews", trend: "+0.2 this year" },
  { value: 47, suffix: " min", label: "Average arrival time for same-day calls", trend: "-9 min faster", trendDirection: "down" },
  { value: 98, suffix: "%", label: "Customers who would book us again", trend: "+3 pts" },
];

function AnimatedNumber({ item, delay }: { item: StatsBandItem; delay: number }) {
  const ref = React.useRef<HTMLSpanElement>(null);
  const inView = useInView(ref, { once: true, margin: "-80px 0px" });
  const reduce = useReducedMotion();
  const decimals = item.decimals ?? 0;

  const format = React.useCallback(
    (n: number) =>
      n.toLocaleString("en-US", {
        minimumFractionDigits: decimals,
        maximumFractionDigits: decimals,
      }),
    [decimals],
  );

  React.useEffect(() => {
    const node = ref.current;
    if (!node || !inView) return;
    if (reduce) {
      node.textContent = format(item.value);
      return;
    }
    const c = animate(0, item.value, {
      duration: 2.2,
      delay,
      ease: EASE,
      onUpdate: (v) => {
        node.textContent = format(v);
      },
    });
    return () => c.stop();
  }, [inView, reduce, item.value, delay, format]);

  return (
    <span className="tabular-nums">
      {item.prefix}
      <span ref={ref}>{format(0)}</span>
      {item.suffix && <span className="text-[0.55em] font-medium opacity-60">{item.suffix}</span>}
    </span>
  );
}

export function StatsBand({
  eyebrow = "By the numbers",
  title = "Fifteen years of showing up on time.",
  stats = DEFAULT_STATS,
  inverted = false,
  className,
}: StatsBandProps) {
  const ref = React.useRef<HTMLDivElement>(null);
  const inView = useInView(ref, { once: true, margin: "-80px 0px" });
  const reduce = useReducedMotion();

  return (
    <section
      className={cn(
        "relative overflow-hidden py-20 sm:py-24",
        inverted ? "bg-primary text-primary-foreground" : "bg-background text-foreground",
        className,
      )}
    >
      <div className="mx-auto max-w-7xl px-5 sm:px-8">
        {(eyebrow || title) && (
          <div className="mb-12 flex flex-col gap-3 sm:mb-16 sm:flex-row sm:items-end sm:justify-between">
            {title && (
              <h2 className="max-w-xl font-display text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
                {title}
              </h2>
            )}
            {eyebrow && (
              <p className="order-first text-xs font-semibold uppercase tracking-[0.2em] text-brand sm:order-none">
                {eyebrow}
              </p>
            )}
          </div>
        )}

        <div ref={ref} className="relative">
          {/* Top rule drawing in */}
          <motion.div
            aria-hidden
            className={cn("absolute inset-x-0 top-0 h-px origin-left", inverted ? "bg-primary-foreground/20" : "bg-border")}
            initial={{ scaleX: reduce ? 1 : 0 }}
            animate={inView ? { scaleX: 1 } : undefined}
            transition={{ duration: 1.2, ease: EASE }}
          />
          <ul className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4">
            {stats.map((s, i) => {
              const positive = s.trendPositive ?? true;
              return (
                <li key={s.label} className="relative px-0 py-8 sm:px-6 lg:py-10 lg:first:pl-0">
                  {/* Vertical divider drawing in (desktop) */}
                  {i > 0 && (
                    <motion.span
                      aria-hidden
                      className={cn(
                        "absolute left-0 top-8 bottom-8 hidden w-px origin-top lg:block",
                        inverted ? "bg-primary-foreground/20" : "bg-border",
                      )}
                      initial={{ scaleY: reduce ? 1 : 0 }}
                      animate={inView ? { scaleY: 1 } : undefined}
                      transition={{ duration: 0.9, delay: 0.3 + i * 0.12, ease: EASE }}
                    />
                  )}
                  {/* Horizontal divider on small screens */}
                  {i > 0 && (
                    <motion.span
                      aria-hidden
                      className={cn(
                        "absolute inset-x-0 top-0 h-px origin-left lg:hidden",
                        i === 1 && "sm:hidden",
                        inverted ? "bg-primary-foreground/20" : "bg-border",
                      )}
                      initial={{ scaleX: reduce ? 1 : 0 }}
                      animate={inView ? { scaleX: 1 } : undefined}
                      transition={{ duration: 0.9, delay: 0.2 + i * 0.1, ease: EASE }}
                    />
                  )}
                  <motion.div
                    initial={{ opacity: 0, y: 14 }}
                    animate={inView ? { opacity: 1, y: 0 } : undefined}
                    transition={{ duration: 0.8, delay: 0.15 + i * 0.1, ease: EASE }}
                    className="flex flex-col-reverse"
                  >
                    <p
                      className={cn(
                        "mt-3 max-w-[16rem] text-sm leading-relaxed",
                        inverted ? "text-primary-foreground/70" : "text-muted-foreground",
                      )}
                    >
                      {s.label}
                    </p>
                    <p className="font-display text-5xl font-semibold tracking-tight sm:text-6xl">
                      <AnimatedNumber item={s} delay={0.2 + i * 0.1} />
                    </p>
                  </motion.div>
                  {s.trend && (
                    <motion.p
                      initial={{ opacity: 0 }}
                      animate={inView ? { opacity: 1 } : undefined}
                      transition={{ duration: 0.6, delay: 1.4 + i * 0.1 }}
                      className={cn(
                        "mt-4 inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold",
                        positive ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive",
                      )}
                    >
                      <TrendingUp
                        className={cn("size-3", s.trendDirection === "down" && "-scale-y-100")}
                        aria-hidden
                      />
                      {s.trend}
                    </motion.p>
                  )}
                </li>
              );
            })}
          </ul>
          <motion.div
            aria-hidden
            className={cn("absolute inset-x-0 bottom-0 h-px origin-right", inverted ? "bg-primary-foreground/20" : "bg-border")}
            initial={{ scaleX: reduce ? 1 : 0 }}
            animate={inView ? { scaleX: 1 } : undefined}
            transition={{ duration: 1.2, delay: 0.2, ease: EASE }}
          />
        </div>
      </div>
    </section>
  );
}

export default StatsBand;
```
