Feedback & Data
StatPulse
A live number tile. It counts up when first seen, and every new value counts over while a pulse of colour runs through the tile. Takes an optional change percent and small trend line.
Download
- React
- Tailwind CSS v4
- motion
components/ui/stat-pulse.tsx
"use client";
import { useEffect, useRef, type CSSProperties } from "react";
import { animate, motion, useInView, useMotionValue, useReducedMotion, useTransform } from "motion/react";
type StatPulseProps = {
/** What the number is, like "Active users". */
label: string;
/** Current number. Change it and the tile pulses while the number counts over. */
value: number;
/** Text before the number, like "$". */
prefix?: string;
/** Text after the number, like "%" or "+". */
suffix?: string;
/** Digits after the point. */
decimals?: number;
/** Number format, like "en-IN" for lakh commas. */
locale?: string;
/** Change against the last period, in percent. Leave out to hide it. */
delta?: number;
/** Recent values, oldest first. Two or more draw the small line. */
trend?: number[];
/** Pulse, live dot and line colour. */
accent?: string;
/** Replaces the tile look. */
className?: string;
};
function trendPath(points: number[]) {
const min = Math.min(...points);
const span = Math.max(...points) - min || 1;
return points.map((point, i) => `${i ? "L" : "M"}${(i / (points.length - 1)) * 100} ${36 - ((point - min) / span) * 32}`).join(" ");
}
/* StatPulse: a live number tile. It counts up when first seen, and every change sends a pulse of colour through the tile. */
export function StatPulse({
label,
value,
prefix = "",
suffix = "",
decimals = 0,
locale = "en-US",
delta,
trend,
accent = "#a78bfa",
className = "rounded-2xl border border-white/10 bg-white/5 p-5 text-white",
}: StatPulseProps) {
const reduce = useReducedMotion();
const box = useRef<HTMLDivElement>(null);
const seen = useInView(box, { once: true, amount: 0.5 });
const count = useMotionValue(0);
const format = (n: number) => `${prefix}${n.toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })}${suffix}`;
const text = useTransform(count, format);
const change = delta === undefined ? null : `${Math.abs(delta)}%`;
useEffect(() => {
if (reduce) {
count.set(value);
return;
}
if (!seen) return;
const run = animate(count, value, { duration: 1.1, ease: [0.16, 1, 0.3, 1] });
return () => run.stop();
}, [count, reduce, seen, value]);
return (
<div ref={box} style={{ "--sp": accent } as CSSProperties} className={`relative overflow-hidden ${className}`}>
{!reduce && seen && (
<motion.span
key={value}
aria-hidden
className="pointer-events-none absolute inset-0 bg-[radial-gradient(120%_100%_at_100%_0%,var(--sp),transparent_70%)]"
initial={{ opacity: 0.45 }}
animate={{ opacity: 0 }}
transition={{ duration: 1.2, ease: "easeOut" }}
/>
)}
<p className="sr-only">
{`${label}: ${format(value)}`}
{delta !== undefined && delta !== 0 && `, ${delta > 0 ? "up" : "down"} ${change}`}
</p>
<div aria-hidden className="relative">
<div className="flex items-center justify-between gap-3">
<p className="truncate text-sm text-white/60">{label}</p>
<span className="relative size-2 shrink-0 rounded-full bg-(--sp)">
{!reduce && (
<motion.span
className="absolute inset-0 rounded-full bg-(--sp)"
animate={{ scale: [1, 3], opacity: [0.6, 0] }}
transition={{ duration: 1.8, ease: "easeOut", repeat: Infinity }}
/>
)}
</span>
</div>
<div className="mt-2 flex flex-wrap items-baseline gap-x-3">
<motion.span className="text-3xl font-semibold tabular-nums">{text}</motion.span>
{delta !== undefined && (
<span className="text-sm text-white/70">
<span className={delta > 0 ? "text-emerald-400" : delta < 0 ? "text-rose-400" : ""}>{delta > 0 ? "▲" : delta < 0 ? "▼" : "–"}</span> {change}
</span>
)}
</div>
{trend && trend.length > 1 && (
<motion.svg
viewBox="0 0 100 40"
preserveAspectRatio="none"
className="mt-3 h-11 w-full overflow-visible"
initial={reduce ? false : { clipPath: "inset(-10% 100% -10% 0%)" }}
animate={seen ? { clipPath: "inset(-10% 0% -10% 0%)" } : undefined}
transition={{ duration: 1.1, ease: [0.16, 1, 0.3, 1] }}
>
<path d={trendPath(trend)} fill="none" stroke={accent} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" vectorEffect="non-scaling-stroke" />
</motion.svg>
)}
</div>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/stat-pulse.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/stat-pulse-demo.tsx
"use client"; import { useEffect, useState } from "react"; import { StatPulse } from "@/components/ui/stat-pulse"; export default function StatPulseExample() { const [trend, setTrend] = useState([412, 436, 428, 451, 447, 472, 468, 491]); // Fake live feed: a new reading every few seconds. useEffect(() => { const id = setInterval(() => { setTrend((t) => [...t.slice(1), t[t.length - 1] + Math.round(Math.random() * 40 - 14)]); }, 2800); return () => clearInterval(id); }, []); return ( <div className="grid h-full place-items-center bg-[#0b0a14] p-5"> <StatPulse label="Active users" value={trend[trend.length - 1]} delta={12.4} trend={trend} className="w-full max-w-[280px] rounded-2xl border border-white/10 bg-white/5 p-5 text-white" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| label | string | — | What the number is, like "Active users". |
| value | number | — | Current number. Change it and the tile pulses. |
| prefix | string | — | Text before the number, like "$". |
| suffix | string | — | Text after the number, like "%" or "+". |
| decimals | number | 0 | Digits after the point. |
| locale | string | "en-US" | Number format, like "en-IN" for lakh commas. |
| delta | number | — | Change against the last period, in percent. Leave out to hide it. |
| trend | number[] | — | Recent values, oldest first. Two or more draw the small line. |
| accent | string | "#a78bfa" | Pulse, live dot and line colour. |
| className | string | glass tile | Replaces the tile look. |