Skip to content

Statistics with context

Numbers on one side, the argument they support on the other.

Marketingstarterstatsnarrativecontextmetrics

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

Source

This exact file renders the preview above.

import { Container } from '@/components/ui/layout'
import { Metric } from '@/components/ui/metric'

/**
 * Statistics with narrative context
 *
 * Numbers on the right, the argument they support on the left. Figures without
 * a claim are trivia; a claim without figures is marketing. This layout
 * insists on both.
 */
export default function StatsWithContext() {
  return (
    <section className="border-b border-line bg-canvas py-section">
      <Container>
        <div className="grid gap-10 lg:grid-cols-[1fr_1.1fr] lg:gap-16">
          <div>
            <p className="label-caps text-accent">Measured, not claimed</p>
            <h2 className="display-type mt-3 text-2xl font-semibold text-ink-strong sm:text-3xl">
              Server-first rendering is not a preference. It is a measurement.
            </h2>
            <p className="mt-4 max-w-lg text-md text-ink-muted">
              Sections are static markup. Only overlays, menus and the command palette hydrate,
              which is why a landing page assembled from nine sections still ships almost nothing.
            </p>
            <p className="mt-4 max-w-lg text-sm text-ink-subtle">
              Figures are from the demo build of this site, measured on a throttled connection.
            </p>
          </div>

          <div className="grid gap-4 sm:grid-cols-2">
            <Metric
              label="JavaScript on a landing page"
              value="14 kB"
              delta={-78}
              deltaLabel="vs client-rendered"
              invertTrend
            />
            <Metric
              label="Largest contentful paint"
              value="0.9 s"
              delta={-41}
              deltaLabel="vs client-rendered"
              invertTrend
            />
            <Metric label="Routes prerendered" value="100%" />
            <Metric label="Runtime dependencies" value="3" deltaLabel="React, Next, icons" />
          </div>
        </div>
      </Container>
    </section>
  )
}

components/blocks/sections/stats/with-context.tsx

import { Container } from '@/components/ui/layout'
import { Metric } from '@/components/ui/metric'

/**
 * Statistics with narrative context
 *
 * Numbers on the right, the argument they support on the left. Figures without
 * a claim are trivia; a claim without figures is marketing. This layout
 * insists on both.
 */
export default function StatsWithContext() {
  return (
    <section className="border-b border-line bg-canvas py-section">
      <Container>
        <div className="grid gap-10 lg:grid-cols-[1fr_1.1fr] lg:gap-16">
          <div>
            <p className="label-caps text-accent">Measured, not claimed</p>
            <h2 className="display-type mt-3 text-2xl font-semibold text-ink-strong sm:text-3xl">
              Server-first rendering is not a preference. It is a measurement.
            </h2>
            <p className="mt-4 max-w-lg text-md text-ink-muted">
              Sections are static markup. Only overlays, menus and the command palette hydrate,
              which is why a landing page assembled from nine sections still ships almost nothing.
            </p>
            <p className="mt-4 max-w-lg text-sm text-ink-subtle">
              Figures are from the demo build of this site, measured on a throttled connection.
            </p>
          </div>

          <div className="grid gap-4 sm:grid-cols-2">
            <Metric
              label="JavaScript on a landing page"
              value="14 kB"
              delta={-78}
              deltaLabel="vs client-rendered"
              invertTrend
            />
            <Metric
              label="Largest contentful paint"
              value="0.9 s"
              delta={-41}
              deltaLabel="vs client-rendered"
              invertTrend
            />
            <Metric label="Routes prerendered" value="100%" />
            <Metric label="Runtime dependencies" value="3" deltaLabel="React, Next, icons" />
          </div>
        </div>
      </Container>
    </section>
  )
}

components/ui/metric.tsx

import type { ReactNode } from 'react'
import { ArrowUpRight, ArrowDownRight, Minus } from 'lucide-react'
import { cn } from '@/lib/cn'

/**
 * Metric
 *
 * A single number with optional trend. The delta carries an arrow *and* a sign
 * *and* a colour, so the direction survives a greyscale print or a colour
 * vision deficiency.
 *
 * `<dl>` semantics pair the label with the value; the sparkline is decorative
 * and hidden from assistive tech, since the number beside it is the content.
 */
export interface MetricProps {
  label: string
  value: string
  /** Percentage change. Positive is not automatically "good" — see `invertTrend`. */
  delta?: number
  deltaLabel?: string
  /** For metrics where down is good, e.g. churn or latency. */
  invertTrend?: boolean
  icon?: ReactNode
  /** 0–1 values rendered as a decorative sparkline. */
  sparkline?: number[]
  appearance?: 'plain' | 'card'
  className?: string
}

function Sparkline({ points }: { points: number[] }) {
  if (points.length < 2) return null
  const max = Math.max(...points)
  const min = Math.min(...points)
  const range = max - min || 1
  const path = points
    .map((point, index) => {
      const x = (index / (points.length - 1)) * 100
      const y = 28 - ((point - min) / range) * 26 - 1
      return `${index === 0 ? 'M' : 'L'}${x.toFixed(2)},${y.toFixed(2)}`
    })
    .join(' ')

  return (
    <svg viewBox="0 0 100 28" preserveAspectRatio="none" className="h-7 w-full" aria-hidden="true">
      <path
        d={path}
        fill="none"
        stroke="var(--color-accent)"
        strokeWidth="1.5"
        vectorEffect="non-scaling-stroke"
      />
    </svg>
  )
}

export function Metric({
  label,
  value,
  delta,
  deltaLabel,
  invertTrend = false,
  icon,
  sparkline,
  appearance = 'card',
  className,
}: MetricProps) {
  const direction = delta === undefined ? 'flat' : delta > 0 ? 'up' : delta < 0 ? 'down' : 'flat'
  const good = direction === 'flat' ? null : invertTrend ? direction === 'down' : direction === 'up'
  const TrendIcon =
    direction === 'up' ? ArrowUpRight : direction === 'down' ? ArrowDownRight : Minus

  return (
    <dl
      className={cn(
        'flex min-w-0 flex-col gap-1',
        appearance === 'card' && 'rounded-lg border border-line bg-surface p-card',
        className,
      )}
    >
      <div className="flex items-center justify-between gap-2">
        <dt className="label-caps truncate text-ink-subtle">{label}</dt>
        {icon ? <span className="shrink-0 text-ink-subtle">{icon}</span> : null}
      </div>
      <dd className="display-type text-2xl leading-none font-semibold text-ink-strong tabular-nums">
        {value}
      </dd>
      {delta !== undefined ? (
        <dd
          className={cn(
            'flex items-center gap-1 text-xs font-medium',
            good === null ? 'text-ink-muted' : good ? 'text-success' : 'text-danger',
          )}
        >
          <TrendIcon className="size-3.5 shrink-0" aria-hidden="true" />
          <span>
            {delta > 0 ? '+' : ''}
            {delta}%
          </span>
          {deltaLabel ? <span className="text-ink-subtle">{deltaLabel}</span> : null}
        </dd>
      ) : null}
      {sparkline ? (
        <dd className="mt-1">
          <Sparkline points={sparkline} />
        </dd>
      ) : null}
    </dl>
  )
}

Demo source — adapt to your project. Foundry is not published as a package.

Usage

Figures without a claim are trivia; a claim without figures is marketing. This layout insists on both, and names where the numbers came from.

  • Say how the figures were measured. An unattributed number is worth less than none.
  • Use invertTrend where a decrease is the good outcome.

Variants and states

Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.

  • Two-column layout
  • Four metric tiles
  • Provenance note

Accessibility

Metric semantics
Each tile is a description list pairing label and value.
Direction
Deltas carry an arrow, a sign and a colour, so direction survives greyscale.

Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.