Components

Animated Metric

Displays a numerical metric that animates into view and counts up to a target value when it scrolls into the viewport.

Portfolio Snapshot

$346,806.55 Total Portfolio Value

Total Portfolio Value

-$1883.79 Today's Gain / Loss

Today's Gain / Loss

20.56% Year-to-Date Return

Year-to-Date Return

58 Tasks Completed

Tasks Completed

12 Bugs Fixed

Bugs Fixed

21 pts Team Velocity

Team Velocity

6,168 Requests / Min

Requests / Min

74ms Avg. Latency

Avg. Latency

0.11% Error Rate

Error Rate

Installation

CLI

Installation via the CLI is coming soon. For now, please follow the manual installation instructions below.

Manual

  1. Install the following dependencies required for the animation:
npm install gsap @gsap/react
yarn add gsap @gsap/react
pnpm add gsap @gsap/react
  1. This component uses the ScrollTrigger plugin from GSAP to trigger the animation when the component scrolls into view. You need to register this plugin for it to work correctly. You can learn more from the official GSAP documentation.

Read the GSAP ScrollTrigger Documentation

You must only register it once in your application for it to work properly.

The component code provided already includes gsap.registerPlugin(ScrollTrigger);, so as long as you place it in your project, it should work out of the box.

  1. Copy and paste the component code into your project at your desired location (e.g., components/ui/animated-metric.tsx).
'use client';

import * as React from 'react';
import { gsap } from 'gsap';
import { useGSAP } from '@gsap/react';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { cn } from '@/lib/utils';

gsap.registerPlugin(ScrollTrigger);

/**
 * Props for the AnimatedMetric component.
 */
type AnimatedMetricProps = {
  /** The final number the component will count up to. */
  targetValue: number;
  /** The descriptive text displayed below the number. */
  label: string;
  /** The total time the animation should take, in seconds. Defaults to 2. */
  duration?: number;
  /** A delay in seconds before the animation starts after it enters the viewport. Defaults to 0. */
  delay?: number;
  /** A string to display before the number (e.g., "$"). Ignored if `formatValue` is provided. */
  prefix?: string;
  /** A string to display after the number (e.g., "+" or "%"). Ignored if `formatValue` is provided. */
  suffix?: string;
  /** If `true`, formats the number with locale-appropriate grouping separators. Defaults to true. Ignored if `formatValue` is provided. */
  useGrouping?: boolean;
  /** The number of decimal places to show. Defaults to 0. Ignored if `formatValue` is provided. */
  decimals?: number;
  /** An optional custom formatting function. Overrides all other formatting props. */
  formatValue?: (value: number) => string;
  /** Additional class names for the root container. */
  className?: string;
  /** Additional class names specifically for the metric number element. */
  metricClassName?: string;
  /** Additional class names specifically for the label text element. */
  labelClassName?: string;
} & React.HTMLAttributes<HTMLDivElement>;

/**
 * Displays a numerical metric that animates into view and counts up to a
 * target value when it scrolls into the viewport.
 */
const AnimatedMetric = React.forwardRef<HTMLDivElement, AnimatedMetricProps>(
  (
    {
      className,
      metricClassName,
      labelClassName,
      targetValue,
      label,
      duration = 2,
      delay = 0,
      prefix = '',
      suffix = '',
      useGrouping = true,
      decimals = 0,
      formatValue,
      ...props
    },
    ref
  ) => {
    const containerRef = React.useRef<HTMLDivElement>(null);
    const metricRef = React.useRef<HTMLSpanElement>(null);

    // WHY: Memoize the Intl.NumberFormat instance to avoid creating it on every render.
    const internalFormatter = React.useMemo(() => {
      if (formatValue) return null;
      return new Intl.NumberFormat(undefined, {
        useGrouping: useGrouping,
        minimumFractionDigits: decimals,
        maximumFractionDigits: decimals,
      });
    }, [useGrouping, decimals, formatValue]);

    const doFormat = React.useCallback(
      (value: number) => {
        if (formatValue) {
          return formatValue(value);
        }
        if (internalFormatter) {
          return `${prefix}${internalFormatter.format(value)}${suffix}`;
        }
        return `${prefix}${value}${suffix}`;
      },
      [formatValue, internalFormatter, prefix, suffix]
    );

    const initialFormattedValue = doFormat(0);
    const finalFormattedValue = doFormat(targetValue);

    useGSAP(
      () => {
        const counter = { value: 0 };
        const tl = gsap.timeline({
          scrollTrigger: {
            trigger: containerRef.current,
            start: 'top 85%',
            toggleActions: 'play none none none',
          },
          // WHY: Disable animations if the user prefers reduced motion for accessibility.
          defaults: {
            duration: window.matchMedia('(prefers-reduced-motion: reduce)')
              .matches
              ? 0
              : duration,
            ease: 'power3.out',
          },
        });

        tl.to(containerRef.current, {
          opacity: 1,
          y: 0,
          delay: delay,
          duration: Math.min(duration, 1),
        });

        tl.to(
          counter,
          {
            value: targetValue,
            onUpdate: () => {
              if (metricRef.current) {
                metricRef.current.textContent = doFormat(counter.value);
              }
            },
          },
          // WHY: Overlap the number animation with the container fade-in for a smoother effect.
          `>-${Math.min(duration, 1) * 0.8}`
        );
      },
      {
        scope: containerRef,
        dependencies: [targetValue, doFormat, duration, delay],
      }
    );

    return (
      <div
        ref={containerRef}
        className={cn(
          // Initial state for fade-in animation, prevents flash of styled content.
          'flex flex-col items-center justify-center text-center opacity-0 translate-y-5',
          className
        )}
        {...props}
      >
        <p
          className={cn(
            'relative text-4xl font-bold tracking-tighter sm:text-5xl md:text-6xl lg:text-7xl',
            metricClassName
          )}
        >
          {/*
           * WHY: This hidden "ghost" element contains the final formatted value.
           * It reserves the exact space needed, preventing any layout shift
           * when the animated number's width changes.
           */}
          <span className='opacity-0' aria-hidden='true'>
            {finalFormattedValue}
          </span>

          {/*
           * This element is absolutely positioned to overlay the ghost element.
           * Its content is animated, but its size changes do not affect layout.
           */}
          <span ref={metricRef} className='absolute inset-0' aria-hidden='true'>
            {initialFormattedValue}
          </span>

          {/*
           * This element provides the final value to screen readers immediately,
           * ensuring the component is accessible without waiting for the animation.
           */}
          <span className='sr-only'>{`${finalFormattedValue} ${label}`}</span>
        </p>
        <p
          className={cn(
            'mt-1 text-sm text-muted-foreground sm:text-base md:mt-2',
            labelClassName
          )}
        >
          {label}
        </p>
      </div>
    );
  }
);
AnimatedMetric.displayName = 'AnimatedMetric';

export { AnimatedMetric };

Usage

Here is a basic implementation of the AnimatedMetric component.

import { AnimatedMetric } from '@/components/ui/animated-metric';

const formatMillions = (value: number) => {
  const millions = value / 1_000_000;
  return `${millions.toFixed(1)}M+`;
};

export default function MetricsSection() {
  return (
    <div className='bg-background py-24'>
      <div className='mx-auto max-w-7xl'>
        <div className='grid grid-cols-1 gap-y-16 text-center lg:grid-cols-3'>
          <AnimatedMetric
            targetValue={1500000}
            label='Active Users'
            formatValue={formatMillions}
            delay={0}
          />
          <AnimatedMetric
            targetValue={99.9}
            label='Uptime SLA'
            suffix='%'
            decimals={1}
            delay={0.2}
          />
          <AnimatedMetric
            targetValue={25000}
            label='Initial Funding'
            prefix='$'
            delay={0.4}
          />
        </div>
      </div>
    </div>
  );
}

Props

PropTypeDefaultDescription
targetValuenumberRequiredThe final number the component will count up to.
labelstringRequiredThe descriptive text displayed below the number.
durationnumber2The total time the animation should take, in seconds.
delaynumber0A delay in seconds before the animation starts.
prefixstring''A string to display before the number.
suffixstring''A string to display after the number.
useGroupingbooleantrueIf true, formats the number with locale-appropriate grouping separators.
decimalsnumber0The number of decimal places to show.
formatValue(value: number) => string An optional custom formatting function. Overrides other formatting props.
classNamestring Additional class names for the root container.
metricClassNamestring Additional class names specifically for the metric number element.
labelClassNamestring Additional class names specifically for the label text element.