# Stats 03

> Split stats section: a short growth story on one side, four metrics tracked against their targets on the other. Bars fill from zero on load and a Quarter / Year toggle swaps the figures.

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

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

## Install

```bash
npx shadcn@latest add https://hirael.com/r/stats-03.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: `lucide-react`
- Registry items: `progress`, `toggle-group`

## Files

### `registry/hirael/bases/radix/blocks/stats-03/stats-03.tsx`

```tsx
// Stats 03 from Hirael <https://hirael.com/blocks/stats/stats-03>
// MIT · Mohammad Shehadeh · https://github.com/MohammadShehadeh/hirael

'use client';

import * as React from 'react';
import { ArrowRight } from 'lucide-react';

import { cn } from '@/lib/utils';
import { Progress } from '@/registry/hirael/bases/radix/ui/progress';
import { ToggleGroup, ToggleGroupItem } from '@/registry/hirael/bases/radix/ui/toggle-group';

const ENTER =
  'animate-in fade-in slide-in-from-bottom-4 duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] fill-mode-both motion-reduce:animate-none';
const SWAP =
  'animate-in fade-in duration-250 ease-[cubic-bezier(0.22,1,0.36,1)] fill-mode-both motion-reduce:animate-none';

const stagger = (index: number, step = 60, offset = 0): React.CSSProperties => ({
  animationDelay: `${offset + index * step}ms`,
});

type Period = 'quarter' | 'year';

interface Metric {
  name: string;
  current: number;
  target: number;
  format: (value: number) => string;
  unit: string;
  context: string;
}

const count = (value: number) => value.toLocaleString('en-US');
const money = (value: number) => `$${Math.round(value / 1000)}k`;
const percent = (value: number) => `${value}%`;

const PERIODS: Record<Period, { caption: string; metrics: readonly Metric[] }> = {
  quarter: {
    caption: 'Q3 2026 targets, July to September',
    metrics: [
      {
        name: 'Paid seats',
        current: 8412,
        target: 10000,
        format: count,
        unit: 'seats',
        context: 'Up 18% since June',
      },
      {
        name: 'Monthly recurring revenue',
        current: 412000,
        target: 450000,
        format: money,
        unit: '',
        context: 'Up $61k since June',
      },
      {
        name: 'Teams on annual plans',
        current: 214,
        target: 250,
        format: count,
        unit: 'teams',
        context: '31 switched from monthly this quarter',
      },
      {
        name: 'Net revenue retention',
        current: 112,
        target: 110,
        format: percent,
        unit: 'target',
        context: 'Expansion outpaced churn for the fourth quarter running',
      },
    ],
  },
  year: {
    caption: '2026 targets, January to December',
    metrics: [
      {
        name: 'Paid seats',
        current: 8412,
        target: 12000,
        format: count,
        unit: 'seats',
        context: 'Up 64% since January',
      },
      {
        name: 'Monthly recurring revenue',
        current: 412000,
        target: 600000,
        format: money,
        unit: '',
        context: 'Up $158k since January',
      },
      {
        name: 'Teams on annual plans',
        current: 214,
        target: 400,
        format: count,
        unit: 'teams',
        context: 'Most renewals land in November',
      },
      {
        name: 'Net revenue retention',
        current: 112,
        target: 120,
        format: percent,
        unit: 'target',
        context: 'Held above 105% every month this year',
      },
    ],
  },
};

const Stats03 = () => {
  const [period, setPeriod] = React.useState<Period>('quarter');
  const [filled, setFilled] = React.useState(false);
  const { caption, metrics } = PERIODS[period];

  React.useEffect(() => {
    const frame = requestAnimationFrame(() => setFilled(true));

    return () => cancelAnimationFrame(frame);
  }, []);

  return (
    <section data-slot="stats" className="bg-background py-20 sm:py-28">
      <div className="mx-auto grid w-full max-w-6xl gap-12 px-6 md:px-10 lg:grid-cols-[minmax(0,5fr)_minmax(0,7fr)] lg:gap-20">
        <div data-slot="stats-story" className="flex max-w-md flex-col gap-4">
          <span className={cn(ENTER, 'text-xs text-muted-foreground uppercase')}>Growth</span>
          <h2
            style={stagger(1, 70)}
            className={cn(ENTER, 'text-3xl font-semibold tracking-tight text-balance sm:text-4xl')}
          >
            Eighteen months, 8,412 seats
          </h2>
          <p style={stagger(2, 70)} className={cn(ENTER, 'text-base leading-relaxed text-muted-foreground')}>
            We sent the first invoice in March 2025 to a team of six. Today 1,140 companies pay for Hirael, and most of
            them started on the free plan with fewer than ten people before rolling it out to other departments.
          </p>
          <a
            href="#"
            style={stagger(3, 70)}
            className={cn(
              ENTER,
              'group/link mt-2 inline-flex items-center gap-1.5 self-start rounded-sm text-sm font-medium text-foreground transition-colors duration-150 hover:text-primary focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none',
            )}
          >
            Read the Q3 investor update
            <ArrowRight
              aria-hidden
              className="size-3.5 transition-transform duration-150 group-hover/link:translate-x-0.5 rtl:rotate-180 rtl:group-hover/link:-translate-x-0.5"
            />
          </a>
        </div>

        <div data-slot="stats-metrics" style={stagger(0, 0, 240)} className={cn(ENTER, 'flex flex-col')}>
          <div className="flex flex-wrap items-center justify-between gap-3 border-b border-border pb-4">
            <span key={period} className={cn(SWAP, 'text-sm text-muted-foreground')}>
              {caption}
            </span>
            <ToggleGroup
              type="single"
              variant="outline"
              size="sm"
              value={period}
              onValueChange={(value) => {
                if (value) setPeriod(value as Period);
              }}
              aria-label="Period"
            >
              <ToggleGroupItem value="quarter">Quarter</ToggleGroupItem>
              <ToggleGroupItem value="year">Year</ToggleGroupItem>
            </ToggleGroup>
          </div>

          <ul className="flex flex-col">
            {metrics.map((metric, index) => {
              const ratio = Math.round((metric.current / metric.target) * 100);
              const met = ratio >= 100;

              return (
                <li
                  key={metric.name}
                  data-slot="stats-metric"
                  className="flex flex-col gap-3 border-b border-border py-5 last:border-b-0"
                >
                  <div className="flex items-baseline justify-between gap-4">
                    <span className="text-sm font-medium">{metric.name}</span>
                    <span
                      key={`${period}-ratio`}
                      dir="ltr"
                      className={cn(SWAP, 'text-sm font-medium tabular-nums', met ? 'text-success' : 'text-foreground')}
                    >
                      {ratio}%
                    </span>
                  </div>
                  <Progress
                    value={filled ? Math.min(ratio, 100) : 0}
                    aria-label={`${metric.name}, ${ratio}% of target`}
                    className={cn(
                      'h-1.5 rtl:-scale-x-100',
                      '**:data-[slot=progress-indicator]:delay-(--bar-delay) **:data-[slot=progress-indicator]:duration-700 **:data-[slot=progress-indicator]:ease-[cubic-bezier(0.22,1,0.36,1)]',
                    )}
                    style={{ '--bar-delay': `${index * 60}ms` } as React.CSSProperties}
                  />
                  <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 text-sm">
                    <span key={`${period}-value`} className={cn(SWAP, 'text-foreground tabular-nums')}>
                      <span dir="ltr">{metric.format(metric.current)}</span>
                      <span className="text-muted-foreground">
                        {' of '}
                        <span dir="ltr">{metric.format(metric.target)}</span>
                        {metric.unit && ` ${metric.unit}`}
                      </span>
                    </span>
                    <span key={`${period}-context`} className={cn(SWAP, 'text-muted-foreground')}>
                      {metric.context}
                    </span>
                  </div>
                </li>
              );
            })}
          </ul>
        </div>
      </div>
    </section>
  );
};

export default Stats03;
```
