# StatsCard

A responsive statistics group with animated numbers, exact display values, captions, and badges.

> For the complete documentation index, see [llms.txt](/llms.txt). Markdown variants are available by appending `.md` to any URL or sending an `Accept: text/markdown` header. An agent skill is available at [/.well-known/agent-skills/site-skill.md](/.well-known/agent-skills/site-skill.md).





## Playground [#playground]

```tsx
"use client";

import { Activity } from "lucide-react";
import { StatsCard } from "@/components/ui/stats-card";

export function StatsCardDemo() {
  return <StatsCard ariaLabel={"Webhook overview"} items={[
    {
      key: "metric-0",
      title: "Active webhooks",
      value: {"target":1248},
      caption: "Last 30 days",
      badge: {"label":"Status","variant":"secondary"},
      icon: Activity
    },
    {
      key: "metric-1",
      title: "Delivered",
      value: {"target":438},
      caption: "Last 30 days",
      badge: {"label":"Status","variant":"secondary"},
      icon: Activity
    }
  ]} />;
}
```

## Installation [#installation]

Initialize shadcn in your React project, then install this component from the Vandor UI registry:

```bash
npx shadcn@latest add https://vandor-ui.vercel.app/r/stats-card.json
```

## Dependencies [#dependencies]

Installed automatically by the registry CLI. For manual setup:

```bash
npm install cn lucide-react motion@^12.38.0
```

## Usage [#usage]

```tsx
"use client";

import { Activity } from "lucide-react";
import { StatsCard } from "@/components/ui/stats-card";

export function Demo() {
  return (
    <StatsCard
      ariaLabel="Delivery overview"
      items={[
        {
          key: "delivered",
          title: "Delivered",
          value: { target: 1248 },
          caption: "Last 30 days",
          badge: { label: "Healthy", variant: "secondary" },
          icon: Activity,
        },
      ]}
    />
  );
}
```

Requires React 19, Tailwind CSS 4 with shadcn tokens, Motion 12.38+, `lucide-react`, and `cn`. Use your configured UI alias if it differs. Installation includes `stats-card.tsx` and its colocated supporting `stats-card-badge.tsx` and `stats-card-layout.ts`; it does not install or overwrite your existing Badge component. No providers or website-specific styles are required. Copying the displayed Source alone is insufficient for manual installation: use the CLI command above to receive all three files.

## Examples [#examples]

### One metric [#one-metric]

Titles, values, captions, and badges wrap on narrow screens. The icon remains decorative.

<ComponentPreview name="stats-card-demo" title="stats-card-demo.tsx">
  <StatsCardDemo />
</ComponentPreview>

### Formatting and exact values [#formatting-and-exact-values]

Use `value.target` for finite numbers and `value.format` for application-specific formatting. The default formatter uses `id-ID` with zero fractional digits. Formatters receive intermediate fractional numbers during animation, so round or use `Intl.NumberFormat` as appropriate.

Use `value.display` for exact preformatted strings, including financial values beyond JavaScript's safe integer range. StatsCard does not parse, coerce, or animate these values. Numeric animation is presentation only, not financial arithmetic or a guarantee of integer precision. `null`, `NaN`, and infinite targets show `—`; zero and negative targets are valid. Display and target modes are mutually exclusive in TypeScript.

<ComponentPreview name="stats-card-values" title="stats-card-values.tsx">
  <StatsCardValues />
</ComponentPreview>

### Missing data [#missing-data]

Use `target: null` when a measurement is unavailable. An optional badge can explain why no value is shown.

<ComponentPreview name="stats-card-missing" title="stats-card-missing.tsx">
  <StatsCardMissing />
</ComponentPreview>

### Updating values [#updating-values]

Keep each item's key stable. The first numeric value counts from zero; subsequent changes animate from the current displayed number over 1.2 seconds. A new target interrupts the previous transition, and unmounting stops it. Reset in this example changes the target, not the component's identity.

<ComponentPreview name="stats-card-updating" title="stats-card-updating.tsx">
  <StatsCardUpdating />
</ComponentPreview>

## Behavior & Accessibility [#behavior--accessibility]

* The outer group requires `ariaLabel`. Metrics use a definition list, with titles in `dt` and values and supporting content in `dd`.
* Screen readers receive the final formatted target, not every animated frame. Values are not a live region; applications should announce important asynchronous updates separately.
* Reduced-motion preferences show the final number immediately. Server rendering also includes the final value before the client starts animation.
* One metric fills the width. Mobile stacks. Exactly three metrics use three columns from `sm`; other counts balance across at most two columns at `sm` and four at `lg`. Desktop five becomes 3+2, six becomes 3+3, seven becomes 4+3, and every row fills the available width. Separators follow actual row/column ends. Use the playground's metric count control or FiveMetrics/SixMetrics/SevenMetrics stories to inspect these cases. An empty array renders an empty group, not a loading or empty-state message.
* The API is deliberately restricted to `ariaLabel`, `items`, and `className`. It does not forward native attributes, refs, or event handlers. Values, captions, and badge labels accept React nodes; avoid adding interactive content inside the animated numeric value.
* Styling uses `card`, `card-foreground`, `border`, `muted-foreground`, `primary`, `primary-foreground`, `secondary`, `secondary-foreground`, `destructive`, and `destructive-foreground` tokens. Light and dark themes inherit your application setup.

## Storybook Stories (Optional) [#storybook-stories-optional]

Install StatsCard first, then add its portable stories to an existing React Storybook:

```bash
npx shadcn@latest add https://vandor-ui.vercel.app/r/stats-card-stories.json
```

This installs only `stats-card.stories.tsx` beside the component in your configured UI directory. It does not reinstall StatsCard, add Storybook packages, or change `.storybook/`. Review overwrite prompts for existing stories. Supply `@storybook/react` matching your existing Storybook packages; stories are typechecked and composed against version 10.6.1. No addons are required for Canvas and Controls.

Include `../components/ui/**/*.stories.@(js|jsx|mjs|ts|tsx)` in your stories patterns, adjusting for your UI directory. Import your application's Tailwind/global stylesheet in `.storybook/preview.ts`, include the UI directory in Tailwind source scanning, and apply your dark-theme selector inside the preview iframe. Stories inherit your theme and decorators. Numeric transitions honor the preview's reduced-motion media preference.

## Origin [#origin]

Adapted from Valcentra's StatsCard implementation, including its data API, numeric animation, and accessible definition-list structure. Vandor UI adds distributable badge support, wrapping for long content, and dividers for arbitrary item counts. The component does not depend on the Valcentra application.

## Props [#props]

| Prop              | Type                                                                                                        | Default                                                          | Description                                                                                                                              |
| ----------------- | ----------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| ariaLabel         | string                                                                                                      | Required                                                         | Accessible name of the statistics group.                                                                                                 |
| className         | string                                                                                                      | Not set                                                          | Classes merged onto the outer group.                                                                                                     |
| item.badge        | \{ label: ReactNode; variant?: 'default' \| 'secondary' \| 'destructive' \| 'outline'; className?: string } | Not set (variant: secondary)                                     | Optional non-interactive badge beneath the value. Classes affect only this badge.                                                        |
| item.caption      | ReactNode                                                                                                   | Not set                                                          | Optional supporting content beneath the value.                                                                                           |
| item.icon         | LucideIcon                                                                                                  | Required                                                         | Decorative icon component, hidden from assistive technology.                                                                             |
| item.key          | string                                                                                                      | Required                                                         | Stable unique identifier. Preserve it across updates to animate from the current value.                                                  |
| item.title        | string                                                                                                      | Required                                                         | Visible metric label in a definition list.                                                                                               |
| item.value        | StatsCardValue                                                                                              | Required                                                         | Either \{ target: number \| null, format?: (value: number) => string } or \{ display: ReactNode }, not both.                             |
| item.value.format | (value: number) => string                                                                                   | Intl.NumberFormat("id-ID", \{ maximumFractionDigits: 0 }).format | Formats numeric animation frames and the accessible final value. Must accept fractional values. Use display for exact financial strings. |
| items             | readonly StatsCardItem\[]                                                                                   | Required                                                         | Ordered metrics. Each item requires a unique key, title, value, and Lucide icon. Empty arrays render an empty group.                     |

StatsCard supports only ariaLabel, items, and className. It does not forward native attributes, refs, or event handlers.

## Source [#source]

```tsx
"use client";

// Adapted from Valcentra's StatsCard. Uses local supporting styles, not website UI.
import { cn } from "cn";
import type { LucideIcon } from "lucide-react";
import {
  animate,
  motion,
  useMotionValue,
  useReducedMotion,
  useTransform,
} from "motion/react";
import { useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";

import { StatsCardBadge } from "./stats-card-badge";
import type { StatsCardBadgeVariant } from "./stats-card-badge";
import { getStatsCardLayout } from "./stats-card-layout";

export type StatsCardValue =
  | {
      target: number | null;
      format?: (value: number) => string;
      display?: never;
    }
  | { display: ReactNode; target?: never; format?: never };

export interface StatsCardItem {
  key: string;
  title: string;
  value: StatsCardValue;
  icon: LucideIcon;
  caption?: ReactNode;
  badge?: {
    label: ReactNode;
    variant?: StatsCardBadgeVariant;
    className?: string;
  };
}

export interface StatsCardProps {
  ariaLabel: string;
  items: readonly StatsCardItem[];
  className?: string;
}

const defaultNumberFormat = new Intl.NumberFormat("id-ID", {
  maximumFractionDigits: 0,
}).format;

const AnimatedStatsValue = ({
  value,
}: {
  value: Extract<StatsCardValue, { target: number | null }>;
}) => {
  const target =
    value.target !== null && Number.isFinite(value.target)
      ? value.target
      : null;
  const format = value.format ?? defaultNumberFormat;
  const formatRef = useRef(format);
  formatRef.current = format;
  const motionValue = useMotionValue(0);
  const formattedValue = useTransform(motionValue, (current) =>
    formatRef.current(current)
  );
  const shouldReduceMotion = useReducedMotion();
  const [hasMounted, setHasMounted] = useState(false);
  const hasStarted = useRef(false);
  const finalValue = target === null ? "—" : format(target);

  useEffect(() => {
    if (target === null) {
      motionValue.set(0);
      setHasMounted(true);
      return;
    }
    if (shouldReduceMotion) {
      motionValue.set(target);
      hasStarted.current = true;
      setHasMounted(true);
      return;
    }
    if (!hasStarted.current) {
      motionValue.set(0);
      hasStarted.current = true;
    }
    const controls = animate(motionValue, target, {
      duration: 1.2,
      ease: "easeOut",
    });
    setHasMounted(true);
    return () => controls.stop();
  }, [motionValue, shouldReduceMotion, target]);

  if (!hasMounted || shouldReduceMotion || target === null) {
    return <span>{finalValue}</span>;
  }
  return (
    <>
      <span className="sr-only">{finalValue}</span>
      <motion.span aria-hidden="true">{formattedValue}</motion.span>
    </>
  );
};

const StatsValue = ({ value }: { value: StatsCardValue }) =>
  "display" in value ? (
    <span>{value.display}</span>
  ) : (
    <AnimatedStatsValue value={value} />
  );

export const StatsCard = ({ ariaLabel, items, className }: StatsCardProps) => {
  const tabletColumns = items.length === 3 ? 3 : 2;
  const tablet = getStatsCardLayout(items.length, tabletColumns);
  const desktop = getStatsCardLayout(items.length, 4);
  return (
    <div
      data-slot="stats-card"
      role="group"
      aria-label={ariaLabel}
      className={cn(
        "w-full min-w-0 overflow-hidden rounded-xl border border-border bg-card text-card-foreground",
        className
      )}
    >
      <dl className={cn("grid grid-cols-12")}>
        {items.map((item, index) => {
          const Icon = item.icon;
          const hasCaption =
            item.caption !== null && item.caption !== undefined;
          const tabletCell = tablet[index];
          const desktopCell = desktop[index];
          const spanClass: Record<number, string> = {
            1: "sm:col-span-1",
            12: "sm:col-span-12",
            2: "sm:col-span-2",
            3: "sm:col-span-3",
            4: "sm:col-span-4",
            6: "sm:col-span-6",
          };
          const desktopSpanClass: Record<number, string> = {
            12: "lg:col-span-12",
            3: "lg:col-span-3",
            4: "lg:col-span-4",
            6: "lg:col-span-6",
          };
          return (
            <div
              key={item.key}
              data-slot="stats-card-item"
              data-tablet-columns={tabletCell?.columns}
              data-desktop-columns={desktopCell?.columns}
              className={cn(
                "col-span-12 min-w-0 border-border",
                tabletCell && spanClass[tabletCell.columns],
                desktopCell && desktopSpanClass[desktopCell.columns],
                index < items.length - 1 && "border-b",
                tabletCell?.lastRow ? "sm:border-b-0" : "sm:border-b",
                tabletCell?.lastColumn ? "sm:border-e-0" : "sm:border-e",
                desktopCell?.lastRow ? "lg:border-b-0" : "lg:border-b",
                desktopCell?.lastColumn ? "lg:border-e-0" : "lg:border-e"
              )}
            >
              <div className="flex h-full items-start justify-between gap-4 p-5">
                <div className="flex min-w-0 flex-1 flex-col gap-3">
                  <dt className="text-sm font-medium wrap-anywhere">
                    {item.title}
                  </dt>
                  <dd className="min-w-0">
                    <div className="text-2xl font-semibold tracking-tight tabular-nums wrap-anywhere">
                      <StatsValue value={item.value} />
                    </div>
                    {(hasCaption || item.badge) && (
                      <div className="mt-1 flex flex-wrap items-center gap-2">
                        {hasCaption && (
                          <div className="text-xs text-muted-foreground wrap-anywhere">
                            {item.caption}
                          </div>
                        )}
                        {item.badge && (
                          <StatsCardBadge
                            className={item.badge.className}
                            variant={item.badge.variant}
                          >
                            {item.badge.label}
                          </StatsCardBadge>
                        )}
                      </div>
                    )}
                  </dd>
                </div>
                <div className="flex size-10 shrink-0 items-center justify-center rounded-full border border-border text-muted-foreground [&_svg]:size-4">
                  <Icon aria-hidden="true" />
                </div>
              </div>
            </div>
          );
        })}
      </dl>
    </div>
  );
};

```
