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.jsonManual
Install the dependencies
npm install clsx tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.ts. See Installation if you have not set them up yet.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)); }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
| Prop | Type | Default | Description |
|---|---|---|---|
stats* | Stat[] | – | Stats, ideally four. |
eyebrow | string | – | Small line above the title. |
title | string | – | Section title. |
description | string | – | 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.