# Metrics 5

> Header beside an animated numeral grid, with a logo strip (from Marbrook)

A shadcn/ui block from the [@cnippet-blocks](https://blocks.cnippet.dev/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/cnippet-blocks/metrics-5).

- Page, with a live preview: https://shadcn.exchange/r/cnippet-blocks/metrics-5
- Screenshot: https://shadcn.exchange/shot/cnippet-blocks/metrics-5/light.png
- Registry item: https://blocks.cnippet.dev/r/metrics-5.json
- Categories: metrics
- Listed: 2026-11-01, source last changed 2026-11-01

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/cnippet-blocks/metrics-5.json
```

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

## Dependencies

- Registry items: `@cnippet/utils`

## Files

### `registry/default/sections/metrics-5/index.tsx`

```tsx
import Image from "next/image";

import { cn } from "@/lib/utils";

import type { Surface } from "../_types/common";
import type { MetricsContent } from "../_types/metrics";
import { AnimatedNumber } from "./animated-number";
import { defaultMetrics5Content } from "./data";
import {
  eyebrowClass,
  SectionHeader,
  surfaceClass,
  toneFor,
} from "./section-header";

/** Header in a narrow column beside a hairline-ruled numeral grid, with an
 *  optional strip of partner logos underneath. */
export default function Metrics5({
  content = defaultMetrics5Content,
  surface = "default",
}: {
  content?: MetricsContent;
  surface?: Surface;
}) {
  return (
    <section className={cn("py-20 sm:py-24", surfaceClass[surface])}>
      <div className="mx-auto max-w-7xl px-5 sm:px-8 lg:px-12">
        <div className="grid gap-10 lg:grid-cols-12 lg:gap-8">
          <div className="lg:col-span-4">
            <SectionHeader
              description={content.description}
              eyebrow={content.eyebrow}
              size="sm"
              title={content.title}
              tone={toneFor(surface)}
            />
          </div>

          {/* Cells sit on a `gap-px` parent whose background is the rule
              colour, so each one repaints the band's own surface. */}
          <dl className="grid grid-cols-2 gap-px overflow-hidden rounded-lg bg-border lg:col-span-8 lg:grid-cols-4">
            {content.stats.map((stat) => (
              <div
                className={cn("px-6 py-8 lg:px-7", surfaceClass[surface])}
                key={stat.label}
              >
                <dd className="font-display text-4xl text-primary lg:text-5xl">
                  <AnimatedNumber
                    decimals={stat.decimals ?? 0}
                    prefix={stat.prefix ?? ""}
                    suffix={stat.suffix ?? ""}
                    value={stat.value}
                  />
                </dd>
                <dt className="mt-3 text-muted-foreground text-sm leading-snug">
                  {stat.label}
                </dt>
              </div>
            ))}
          </dl>
        </div>

        {content.logos && content.logos.items.length > 0 && (
          <div className="mt-16 flex flex-col items-center gap-8 border-border border-t pt-10 sm:flex-row sm:justify-between">
            <p
              className={cn(eyebrowClass, "shrink-0 text-muted-foreground/70")}
            >
              {content.logos.label}
            </p>
            <div className="flex flex-wrap items-center justify-center gap-x-10 gap-y-6">
              {content.logos.items.map((logo, index) => (
                <Image
                  alt={logo.alt}
                  className="h-5 w-auto opacity-40 grayscale transition-opacity hover:opacity-70 dark:invert"
                  height={24}
                  // biome-ignore lint/suspicious/noArrayIndexKey: logos share alt text
                  key={`${logo.src}-${index}`}
                  src={logo.src}
                  width={100}
                />
              ))}
            </div>
          </div>
        )}
      </div>
    </section>
  );
}
```

### `registry/default/sections/metrics-5/animated-number.tsx`

```tsx
"use client";

import { useEffect, useRef, useState } from "react";

// Ease-out cubic: fast start, gentle settle — reads as "counting up".
const easeOut = (t: number): number => 1 - (1 - t) ** 3;

export function AnimatedNumber({
  className,
  decimals = 0,
  duration = 1600,
  prefix = "",
  suffix = "",
  value,
}: {
  className?: string;
  decimals?: number;
  /** Milliseconds the count-up takes to reach `value`. */
  duration?: number;
  prefix?: string;
  suffix?: string;
  value: number;
}) {
  const ref = useRef<HTMLSpanElement>(null);
  const [display, setDisplay] = useState(0);

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

    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      setDisplay(value);
      return;
    }

    let frame = 0;
    let start: number | null = null;

    const step = (timestamp: number) => {
      start ??= timestamp;
      const progress = Math.min((timestamp - start) / duration, 1);
      setDisplay(value * easeOut(progress));
      if (progress < 1) frame = requestAnimationFrame(step);
    };

    const observer = new IntersectionObserver(
      (entries) => {
        for (const entry of entries) {
          if (entry.isIntersecting) {
            frame = requestAnimationFrame(step);
            observer.disconnect();
          }
        }
      },
      { rootMargin: "-80px", threshold: 0 },
    );

    observer.observe(node);

    return () => {
      observer.disconnect();
      cancelAnimationFrame(frame);
    };
  }, [duration, value]);

  return (
    <span className={className} ref={ref}>
      {prefix}
      {display.toLocaleString("en-US", {
        maximumFractionDigits: decimals,
        minimumFractionDigits: decimals,
      })}
      {suffix}
    </span>
  );
}
```

### `registry/default/sections/metrics-5/data.ts`

```ts
import type { MetricsContent } from "../_types/metrics";

export const defaultMetrics5Content: MetricsContent = {
  description:
    "We take on fewer listings than a volume brokerage — and put the whole practice behind each one.",
  eyebrow: "By the numbers",
  logos: {
    items: [
      {
        alt: "Partner logo",
        src: "https://res.cloudinary.com/dvwtcsh5v/image/upload/v1770737806/logoipsum-1.png",
      },
      {
        alt: "Partner logo",
        src: "https://res.cloudinary.com/dvwtcsh5v/image/upload/v1770737806/logoipsum-2.png",
      },
      {
        alt: "Partner logo",
        src: "https://res.cloudinary.com/dvwtcsh5v/image/upload/v1770737806/logoipsum-3.png",
      },
      {
        alt: "Partner logo",
        src: "https://res.cloudinary.com/dvwtcsh5v/image/upload/v1770737806/logoipsum-4.png",
      },
      {
        alt: "Partner logo",
        src: "https://res.cloudinary.com/dvwtcsh5v/image/upload/v1770737806/logoipsum-5.png",
      },
    ],
    label: "Trusted by developers & private offices",
  },
  stats: [
    {
      label: "Years advising owners",
      value: 31,
    },
    {
      decimals: 1,
      label: "Closed volume",
      prefix: "$",
      suffix: "B",
      value: 4.8,
    },
    {
      label: "Average days on market",
      value: 27,
    },
    {
      label: "Sold above asking",
      suffix: "%",
      value: 94,
    },
  ],
  title: "Three decades of quiet, well-negotiated deals.",
};
```

### `registry/default/sections/metrics-5/section-header.tsx`

```tsx
import type React from "react";

import { cn } from "@/lib/utils";

import type { Surface } from "../_types/common";

/** The small uppercase label that opens most headers. */
export const eyebrowClass =
  "inline-flex items-center gap-2 font-medium text-[0.6875rem] uppercase tracking-[0.22em]";

export const surfaceClass: Record<Surface, string> = {
  default: "bg-background text-foreground",
  muted: "bg-muted text-foreground",
  primary: "bg-primary text-primary-foreground",
  secondary: "bg-secondary text-secondary-foreground",
};

/**
 * Which band the header sits on. `photo` keeps literal white, because
 * `--primary-foreground` inverts in dark mode and would be unreadable over a
 * photograph.
 */
export type SectionTone = "light" | "photo" | "primary" | "secondary";

export function toneFor(surface: Surface): SectionTone {
  if (surface === "primary") return "primary";
  if (surface === "secondary") return "secondary";
  return "light";
}

const toneClasses: Record<
  SectionTone,
  { eyebrow: string; heading: string; lede: string; tick: string }
> = {
  light: {
    eyebrow: "text-muted-foreground/70",
    heading: "text-primary",
    lede: "text-muted-foreground",
    tick: "bg-secondary",
  },
  photo: {
    eyebrow: "text-white/50",
    heading: "text-white",
    lede: "text-white/65",
    tick: "bg-secondary",
  },
  primary: {
    eyebrow: "text-primary-foreground/40",
    heading: "text-primary-foreground",
    lede: "text-primary-foreground/60",
    tick: "bg-secondary",
  },
  secondary: {
    eyebrow: "text-secondary-foreground/50",
    heading: "text-secondary-foreground",
    lede: "text-secondary-foreground/70",
    tick: "bg-primary",
  },
};

export function SectionHeader({
  action,
  actionAt = "sm",
  className,
  description,
  eyebrow,
  size = "default",
  title,
  tone = "light",
}: {
  /** Rendered beside the heading block once `actionAt` is reached. */
  action?: React.ReactNode;
  /** A text link fits from `sm`; a row of filter tabs needs `lg`. */
  actionAt?: "lg" | "sm";
  className?: string;
  description?: React.ReactNode;
  eyebrow: string;
  /** `sm` is the narrow-sidebar size used by 4/8 splits. */
  size?: "default" | "sm";
  title: React.ReactNode;
  tone?: SectionTone;
}): React.ReactElement {
  const tones = toneClasses[tone];

  return (
    <div
      className={cn(
        action && "flex flex-col gap-6",
        action &&
          (actionAt === "lg"
            ? "gap-8 lg:flex-row lg:items-end lg:justify-between"
            : "sm:flex-row sm:items-end sm:justify-between"),
        className,
      )}
    >
      <div className={cn(action && "max-w-xl")}>
        <p className={cn(eyebrowClass, tones.eyebrow)}>
          <span className={cn("size-1", tones.tick)} />
          {eyebrow}
        </p>

        <h2
          className={cn(
            "mt-5 font-display",
            tones.heading,
            size === "sm"
              ? "text-3xl leading-[1.1] sm:text-4xl"
              : "text-4xl leading-[1.05] sm:text-5xl",
          )}
        >
          {title}
        </h2>

        {description && (
          <p className={cn("mt-4 max-w-xl leading-relaxed", tones.lede)}>
            {description}
          </p>
        )}
      </div>

      {action}
    </div>
  );
}
```

### `registry/default/sections/_types/common.ts`

```ts
/**
 * Shapes shared by every token-based section. Each section ships this file
 * alongside its own category types, so installing several sections simply
 * rewrites the same file.
 */

/** Which band a section sits on. A style choice, never part of `content`. */
export type Surface = "default" | "muted" | "primary" | "secondary";

export type ImageAsset = {
  alt: string;
  src: string;
};

export type LinkAction = {
  href: string;
  label: string;
};

/** Eyebrow + heading + optional lede, the header most sections open with. */
export type SectionIntro = {
  description?: string;
  eyebrow: string;
  title: string;
};

/** A figure that counts up when it scrolls into view. */
export type Stat = {
  decimals?: number;
  label: string;
  prefix?: string;
  suffix?: string;
  value: number;
};

/** A figure that is displayed as written ("1,240+", "4.9/5"). */
export type Highlight = {
  label: string;
  value: string;
};

export type FormField = {
  label: string;
  name: string;
  /** Only for `type: "select"`. The first option is the default. */
  options?: string[];
  placeholder?: string;
  required?: boolean;
  /** `full` spans both columns of a two-column form. */
  span?: "full" | "half";
  type?: "email" | "select" | "tel" | "text" | "textarea";
};

export type FormContent = {
  description?: string;
  fields: FormField[];
  note?: string;
  submitLabel: string;
  success: {
    description: string;
    resetLabel: string;
    title: string;
  };
  title: string;
};
```

### `registry/default/sections/_types/metrics.ts`

```ts
import type { ImageAsset, SectionIntro, Stat } from "./common";

export type MetricsContent = SectionIntro & {
  logos?: {
    items: ImageAsset[];
    label: string;
  };
  stats: Stat[];
};
```
