Skip to content

Stats Section

Number tickers that start in view, with hairline dividers.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/stats-section.json

Manual

  1. Install the dependencies

    npm install clsx tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts. See Installation if you have not set them up yet.

  3. Add the theme tokens

    This component uses --link, which shadcn does not define by default. Add it to your global CSS (--link falls back to your --primary unless you set it):

    app/globals.css
    @theme inline {
      --color-link: var(--link, var(--primary));
    }
  4. Copy the source

    components/ui/stats-section/stats-section.tsx
    "use client";
    
    import type { ComponentProps } from "react";
    import { NumberTicker } from "@/components/ui/number-ticker/number-ticker";
    import { cn } from "@/lib/utils";
    
    export interface Stat {
      /** Final value the ticker counts to. */
      value: number;
      label: string;
      /** Text before the number, e.g. "$". */
      prefix?: string;
      /** Text after the number, e.g. "%" or "M+". */
      suffix?: string;
      /** Decimal places. @default 0 */
      decimals?: number;
      /** Optional line under the label. */
      description?: string;
    }
    
    export interface StatsSectionProps extends Omit<ComponentProps<"section">, "children" | "title"> {
      /** Stats, ideally four. */
      stats: Stat[];
      /** Small line above the title. */
      eyebrow?: string;
      /** Section title. */
      title?: string;
      /** Supporting line under the title. */
      description?: string;
    }
    
    export function StatsSection({ stats, eyebrow, title, description, className, ...props }: StatsSectionProps) {
      return (
        <section className={cn("@container w-full", className)} {...props}>
          {eyebrow || title || description ? (
            <div className="mb-10 max-w-xl">
              {eyebrow ? <p className="text-sm font-medium text-link">{eyebrow}</p> : null}
              {title ? <h2 className="mt-1.5 text-2xl font-semibold tracking-tight text-balance text-foreground @[40rem]:text-3xl">{title}</h2> : null}
              {description ? <p className="mt-2 text-[15px] leading-7 text-muted-foreground">{description}</p> : null}
            </div>
          ) : null}
          <dl className="grid grid-cols-2 gap-px overflow-hidden border-y border-border bg-border @[44rem]:grid-cols-4">
            {stats.map((stat) => (
              <div key={stat.label} className="flex flex-col bg-background px-5 py-7">
                <dt className="order-2 mt-2 text-sm font-medium text-foreground">{stat.label}</dt>
                <dd className="order-1 font-mono text-4xl font-semibold tracking-tight text-foreground tabular-nums @[44rem]:text-[2.75rem]">
                  <NumberTicker value={stat.value} decimals={stat.decimals ?? 0} prefix={stat.prefix} suffix={stat.suffix} trigger="inView" />
                </dd>
                {stat.description ? <dd className="order-3 mt-1 text-[13px] leading-5 text-muted-foreground">{stat.description}</dd> : null}
              </div>
            ))}
          </dl>
        </section>
      );
    }

Usage

import { StatsSection } from "@/components/ui/stats-section/stats-section";

<StatsSection
  title="Built for teams that ship"
  stats={[{ value: 38, suffix: "%", label: "Faster launches" }]}
/>

API reference

PropTypeDefaultDescription
stats*Stat[]–Stats, ideally four.
eyebrowstring–Small line above the title.
titlestring–Section title.
descriptionstring–Supporting line under the title.

Also accepts every native <section> attribute, including ref and className.

  • Hairlines come from a 1px gap over the border color. Two columns become four at a 44rem container width.
  • Reuses Number Ticker, so it installs with it.

Accessibility

  • Stats are a description list: each label is a dt and its value a dd, read in a sensible order even though the number is shown first.
  • Number Ticker exposes the final value as text, so screen readers never hear the count-up.
  • Counting starts when the section scrolls into view. With reduced motion the final values show immediately.