KPI grid
Two to six stat cards that pop in one after another, reflowing to up to four columns in landscape (never leaving a lone card on its own row), two in square, and one or two in portrait.
KpiGrid, overview
Installation
npx shadcn add https://www.reelcn.dev/r/kpi-grid.jsonFiles land in src/reelcn/ with the items it builds on.
Usage
<Center>
<KpiGrid
items={[
{ label: "MRR", to: 48200, format: { style: "currency", currency: "USD", maximumFractionDigits: 0 }, delta: 12.4 },
{ label: "Active teams", to: 312, delta: 6.1 },
{ label: "Churn", to: 2.1, suffix: "%", delta: -0.4 },
{ label: "NPS", to: 61, delta: 4 },
]}
/>
</Center>Props
PropTypeDescription
delaynumberFrames to wait before entering.
durationnumberEnter duration in frames. Defaults to 0.6s. Some items reuse
duration for an unrelated local timing. See that item's own prop docs.exitnumber | booleanExit at the end of the parent `<Sequence>`.
false keeps it on screen, a number sets the exit length in frames.motionMotionPersonalityOverride the theme's motion personality.
posterbooleanRenders fully entered, with no exit: for a still frame or thumbnail. Defaults to
false.holdFramesnumberDelays the start of the exit window by this many frames, for a minimum on-screen hold. Defaults to
0.items*KpiGridItem[]
localestringBCP 47 locale applied to every card's numbers. Default
"en-US".sizenumberCard number font size in design units. Defaults by column count.
staggernumberFrames between cards popping in. Defaults to 0.15s.
widthnumberWidth in design units. Defaults to the safe-area width.
positiveColorstringDelta badge color when positive.
negativeColorstringDelta badge color when negative.
cardColorstringCard background. Defaults to the theme surface.
borderColorstringCard border. Defaults to the theme border.
styleCSSProperties
classNamestring
Built from
Use
- A dashboard summary slide: revenue, users, churn and NPS side by side
- End-of-video recap of the numbers that mattered
Avoid
- A single metric. Use
stat-counter
Guides
Components take their colors from a theme, their timing from motion props and their layout from the canvas.
Source
registry/items/kpi-grid.tsx
/**
* @title KPI Grid
* @category data
* @description Two to six stat cards that pop in one after another, reflowing to up to four columns in landscape (never leaving a lone card on its own row), two in square, and one or two in portrait.
* @duration 75
* @use A dashboard summary slide: revenue, users, churn and NPS side by side
* @use End-of-video recap of the numbers that mattered
* @avoid A single metric. Use `stat-counter`
* @tags kpi, dashboard, metrics, grid, cards, data
* @example
* <Center>
* <KpiGrid
* items={[
* { label: "MRR", to: 48200, format: { style: "currency", currency: "USD", maximumFractionDigits: 0 }, delta: 12.4 },
* { label: "Active teams", to: 312, delta: 6.1 },
* { label: "Churn", to: 2.1, suffix: "%", delta: -0.4 },
* { label: "NPS", to: 61, delta: 4 },
* ]}
* />
* </Center>
*/
import type React from "react";
import { type MotionProps, staggerDelay, tween, useMotion, useSceneClock, useTheme, useViewport } from "./core";
import { StatCounter } from "./stat-counter";
export type KpiGridItem = {
label: string;
to: number;
from?: number;
format?: Intl.NumberFormatOptions;
prefix?: string;
suffix?: string;
/** Percentage change shown as an up/down badge. Omit to hide it for this card. */
delta?: number;
/** Small line under this card's number. */
caption?: string;
/** Overrides this card's number color. */
color?: string;
};
export type KpiGridProps = MotionProps & {
items: KpiGridItem[];
/** BCP 47 locale applied to every card's numbers. */
locale?: string;
/** Card number font size in design units. Defaults by column count. */
size?: number;
/** Frames between cards popping in. Defaults to 0.15s. */
stagger?: number;
/** Width in design units. Defaults to the safe-area width. */
width?: number;
/** Delta badge color when positive. */
positiveColor?: string;
/** Delta badge color when negative. */
negativeColor?: string;
/** Card background. Defaults to the theme surface. */
cardColor?: string;
/** Card border. Defaults to the theme border. */
borderColor?: string;
style?: React.CSSProperties;
className?: string;
};
const clamp01 = (value: number) => Math.min(Math.max(value, 0), 1);
export function KpiGrid({
items,
locale = "en-US",
size,
stagger,
width,
positiveColor,
negativeColor,
cardColor,
borderColor,
style,
className,
...motion
}: KpiGridProps) {
const theme = useTheme();
const { u, width: canvasWidth, safe, orientation } = useViewport();
const m = useMotion(motion);
const sceneClock = useSceneClock();
const count = Math.max(items.length, 1);
const columns =
orientation === "landscape"
? // A 3-column cap leaves a lone card stranded on its own row whenever there are exactly 4 items,
// so let 4 items fill a single row and only fall back to 3 columns once there are more than that.
count <= 4
? count
: 3
: orientation === "square"
? Math.min(2, count)
: Math.min(count > 3 ? 2 : 1, count);
const w = width !== undefined ? u(width) : canvasWidth - safe.x * 2;
const gap = u(24);
const cardSize = size ?? (columns === 1 ? 108 : columns === 2 ? 92 : columns === 3 ? 78 : 64);
const step = stagger ?? Math.round(m.fps * 0.15);
const delay = motion.delay ?? 0;
const delayFor = (index: number, count: number) =>
sceneClock?.delayFor(index, count) ?? staggerDelay(index, count, { step });
return (
<div
className={className}
style={{
display: "grid",
gridTemplateColumns: `repeat(${columns}, 1fr)`,
gap,
width: w,
...style,
}}
>
{items.map((item, index) => {
const cardDelay = delay + delayFor(index, items.length);
const pop = clamp01(tween(m.frame, m.fps, { from: cardDelay, duration: m.enterFrames, motion: m.preset }));
return (
<div
key={index}
style={{
background: cardColor ?? theme.colors.surface,
border: `1px solid ${borderColor ?? theme.colors.border}`,
borderRadius: u(theme.radius),
padding: `${u(32)}px ${u(24)}px`,
opacity: pop,
scale: String(0.9 + 0.1 * pop),
translate: `0 ${(1 - pop) * u(18)}px`,
}}
>
<StatCounter
label={item.label}
to={item.to}
from={item.from}
format={item.format}
locale={locale}
prefix={item.prefix}
suffix={item.suffix}
size={cardSize}
color={item.color}
delta={item.delta}
caption={item.caption}
positiveColor={positiveColor}
negativeColor={negativeColor}
delay={cardDelay}
duration={motion.duration}
exit={motion.exit}
motion={motion.motion}
/>
</div>
);
})}
</div>
);
}