Skip to content
6

Stats 1

Metrics band with four large numbers that count up on scroll, labels, optional deltas, and a two-column header on large screens. Ships composable parts.

Preview

Installation

npx shadcn@latest add https://hirael.com/r/stats-01.json

Code

components/blocks/stats-01.tsx
'use client';

import * as React from 'react';
import { ArrowDownRight, ArrowUpRight } from 'lucide-react';

import { cn } from '@/lib/utils';
import { AnimatedNumber } from '@/registry/hirael/bases/radix/components/animated-number';

type StatsProps = React.ComponentProps<'section'>;

const Stats = ({ className, children, ...props }: StatsProps) => {
  return (
    <section data-slot="stats" className={cn('bg-background py-20 sm:py-28', className)} {...props}>
      <div className="container grid w-full grid-cols-1 gap-12 lg:grid-cols-12 lg:items-center lg:gap-16">
        {children}
      </div>
    </section>
  );
};

type StatsHeaderProps = React.ComponentProps<'div'>;

const StatsHeader = ({ className, ...props }: StatsHeaderProps) => {
  return <div data-slot="stats-header" className={cn('flex flex-col gap-5 lg:col-span-5', className)} {...props} />;
};

type StatsEyebrowProps = React.ComponentProps<'span'>;

const StatsEyebrow = ({ className, ...props }: StatsEyebrowProps) => {
  return (
    <span
      data-slot="stats-eyebrow"
      className={cn('font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground', className)}
      {...props}
    />
  );
};

type StatsTitleProps = React.ComponentProps<'h2'>;

const StatsTitle = ({ className, ...props }: StatsTitleProps) => {
  return (
    <h2
      data-slot="stats-title"
      className={cn(
        'font-serif text-4xl font-medium leading-[1.04] tracking-tight text-foreground sm:text-5xl',
        className,
      )}
      {...props}
    />
  );
};

type StatsDescriptionProps = React.ComponentProps<'p'>;

const StatsDescription = ({ className, ...props }: StatsDescriptionProps) => {
  return (
    <p
      data-slot="stats-description"
      className={cn('max-w-md text-sm text-muted-foreground sm:text-base', className)}
      {...props}
    />
  );
};

type StatsGridProps = React.ComponentProps<'div'>;

const StatsGrid = ({ className, ...props }: StatsGridProps) => {
  return (
    <div
      data-slot="stats-grid"
      className={cn('grid grid-cols-2 border-y border-border md:grid-cols-4 lg:col-span-7', className)}
      {...props}
    />
  );
};

type StatsItemProps = React.ComponentProps<'div'>;

const StatsItem = ({ className, ...props }: StatsItemProps) => {
  return (
    <div
      data-slot="stats-item"
      className={cn(
        'flex flex-col gap-2 border-border px-4 py-6 sm:px-5 sm:py-8',
        'md:border-s md:first:border-s-0',
        'max-md:odd:border-e max-md:[&:nth-child(n+3)]:border-t',
        className,
      )}
      {...props}
    />
  );
};

/** Observes its own element and reports once it has scrolled into view. */
const useInView = <T extends Element>(margin = '0px 0px -10% 0px') => {
  const ref = React.useRef<T | null>(null);
  const [inView, setInView] = React.useState(false);

  React.useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries.some((entry) => entry.isIntersecting)) {
          setInView(true);
          observer.disconnect();
        }
      },
      { rootMargin: margin },
    );
    observer.observe(el);
    return () => observer.disconnect();
  }, [margin]);

  return [ref, inView] as const;
};

interface StatsValueProps extends Omit<React.ComponentProps<'div'>, 'children'> {
  value: number;
  prefix?: string;
  suffix?: string;
  decimals?: number;
  /** Tween length in milliseconds once the item scrolls into view. */
  duration?: number;
  format?: Intl.NumberFormatOptions;
}

const StatsValue = ({
  value,
  prefix,
  suffix,
  decimals = 0,
  duration = 1200,
  format,
  className,
  ...props
}: StatsValueProps) => {
  const [ref, inView] = useInView<HTMLDivElement>();

  return (
    <div
      ref={ref}
      data-slot="stats-value"
      className={cn(
        'font-serif text-4xl font-medium leading-none tracking-tight text-foreground sm:text-5xl',
        className,
      )}
      {...props}
    >
      <AnimatedNumber
        value={inView ? value : 0}
        duration={duration}
        decimals={decimals}
        prefix={prefix}
        suffix={suffix}
        format={format}
      />
    </div>
  );
};

type StatsLabelProps = React.ComponentProps<'div'>;

const StatsLabel = ({ className, ...props }: StatsLabelProps) => {
  return <div data-slot="stats-label" className={cn('text-sm text-muted-foreground', className)} {...props} />;
};

interface StatsDeltaProps extends React.ComponentProps<'span'> {
  trend?: 'up' | 'down';
}

const StatsDelta = ({ trend = 'up', className, children, ...props }: StatsDeltaProps) => {
  const Icon = trend === 'up' ? ArrowUpRight : ArrowDownRight;
  return (
    <span
      data-slot="stats-delta"
      data-trend={trend}
      className={cn(
        'inline-flex items-center gap-1 font-mono text-[10px] uppercase tracking-[0.12em] whitespace-nowrap text-muted-foreground',
        className,
      )}
      {...props}
    >
      <Icon aria-hidden className="size-3 text-foreground rtl:-scale-x-100" />
      {children}
    </span>
  );
};

export {
  Stats,
  StatsHeader,
  StatsEyebrow,
  StatsTitle,
  StatsDescription,
  StatsGrid,
  StatsItem,
  StatsValue,
  StatsLabel,
  StatsDelta,
};

const STATS = [
  {
    label: 'Deploys per week',
    value: 48000,
    suffix: '+',
    delta: '12% this quarter',
    trend: 'up',
  },
  {
    label: 'Median cold start',
    value: 38,
    suffix: 'ms',
    delta: '9ms faster',
    trend: 'down',
  },
  {
    label: 'Uptime, 90 days',
    value: 99.98,
    suffix: '%',
    decimals: 2,
  },
  {
    label: 'Teams on the platform',
    value: 2100,
    suffix: '+',
    delta: '310 new',
    trend: 'up',
  },
] as const;

const Stats01Block = () => {
  return (
    <Stats data-slot="stats-01-block">
      <StatsHeader>
        <StatsEyebrow>By the numbers</StatsEyebrow>
        <StatsTitle>Boring in production, on purpose.</StatsTitle>
        <StatsDescription>
          Every figure below is pulled from the public status page and updated monthly. No rounding up.
        </StatsDescription>
      </StatsHeader>

      <StatsGrid>
        {STATS.map((stat) => (
          <StatsItem key={stat.label}>
            <StatsValue value={stat.value} suffix={stat.suffix} decimals={'decimals' in stat ? stat.decimals : 0} />
            <StatsLabel>{stat.label}</StatsLabel>
            {'delta' in stat ? <StatsDelta trend={stat.trend}>{stat.delta}</StatsDelta> : null}
          </StatsItem>
        ))}
      </StatsGrid>
    </Stats>
  );
};

export default Stats01Block;

Dependencies

shadcn registry

animated-number

npm

lucide-react

Shipped