Components
Animated Metric
Displays a numerical metric that animates into view and counts up to a target value when it scrolls into the viewport.
Installation
CLI
Installation via the CLI is coming soon. For now, please follow the manual installation instructions below.
Manual
- Install the following dependencies required for the animation:
npm install gsap @gsap/react
yarn add gsap @gsap/react
pnpm add gsap @gsap/react
- 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.
- 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
Prop | Type | Default | Description |
---|---|---|---|
targetValue | number | Required | The final number the component will count up to. |
label | string | Required | The descriptive text displayed below the number. |
duration | number | 2 | The total time the animation should take, in seconds. |
delay | number | 0 | A delay in seconds before the animation starts. |
prefix | string | '' | A string to display before the number. |
suffix | string | '' | A string to display after the number. |
useGrouping | boolean | true | If true , formats the number with locale-appropriate grouping separators. |
decimals | number | 0 | The number of decimal places to show. |
formatValue | (value: number) => string | | An optional custom formatting function. Overrides other formatting props. |
className | string | | Additional class names for the root container. |
metricClassName | string | | Additional class names specifically for the metric number element. |
labelClassName | string | | Additional class names specifically for the label text element. |