For the complete documentation index, see 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.
1
Support

StatsCard

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

Playground

Live preview
Active webhooks
1.248
Last 30 days
Status
Delivered
438
Last 30 days
Status
Numeric targets animate for 1.2 seconds. Exact display values stay static; missing targets show a dash. Reset restores configuration and remounts the preview, counting from zero again.
Customize StatsCard
Customize
2.00
1248.00
statscard-demo.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

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

$ pnpm dlx shadcn@latest add https://vandor-ui.vercel.app/r/stats-card.json

Dependencies

Installed automatically by the registry CLI. For manual setup:

Dependencies3 required packages
  • cn
  • lucide-react
  • motion@^12.38.0
$ pnpm add cn lucide-react motion@^12.38.0

Usage

"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

One metric

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

Delivered
1.248
Last 30 days
Healthy
stats-card-demo.tsx
"use client";

import { Activity } from "lucide-react";

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

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

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.

Balance change
-Rp 125.000
Compared with last month
Exact payment volume
Rp9.007.199.254.740.993
Preformatted by the application
stats-card-values.tsx
"use client";

import { Activity, Wallet } from "lucide-react";

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

const currency = new Intl.NumberFormat("id-ID", {
  currency: "IDR",
  maximumFractionDigits: 0,
  style: "currency",
});

export const StatsCardValues = () => (
  <StatsCard
    ariaLabel="Payment overview"
    items={[
      {
        caption: "Compared with last month",
        icon: Activity,
        key: "change",
        title: "Balance change",
        value: { format: currency.format, target: -125_000 },
      },
      {
        caption: "Preformatted by the application",
        icon: Wallet,
        key: "volume",
        title: "Exact payment volume",
        value: { display: "Rp9.007.199.254.740.993" },
      },
    ]}
  />
);

Missing data

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

Success rate
—
Awaiting data
stats-card-missing.tsx
"use client";

import { CircleCheck } from "lucide-react";

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

export const StatsCardMissing = () => (
  <StatsCard
    ariaLabel="Success rate overview"
    items={[
      {
        badge: { label: "Awaiting data", variant: "outline" },
        icon: CircleCheck,
        key: "rate",
        title: "Success rate",
        value: { target: null },
      },
    ]}
  />
);

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.

Delivered
1.248
Stable key keeps updates continuous
stats-card-updating.tsx
"use client";

import { Activity } from "lucide-react";
import { useState } from "react";

import { Button } from "@/components/ui/button";
import { StatsCard } from "@/components/ui/stats-card";

export const StatsCardUpdating = () => {
  const [target, setTarget] = useState(1248);
  return (
    <div className="flex w-full flex-col items-start gap-4">
      <StatsCard
        ariaLabel="Live deliveries"
        items={[
          {
            caption: "Stable key keeps updates continuous",
            icon: Activity,
            key: "delivered",
            title: "Delivered",
            value: { target },
          },
        ]}
      />
      <div className="flex flex-wrap gap-2">
        <Button
          variant="outline"
          onClick={() => setTarget((current) => current + 100)}
        >
          Add 100
        </Button>
        <Button
          variant="outline"
          onClick={() => setTarget((current) => current - 100)}
        >
          Subtract 100
        </Button>
        <Button variant="outline" onClick={() => setTarget(1248)}>
          Reset value
        </Button>
      </div>
    </div>
  );
};

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)

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

$ pnpm dlx 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

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

Component props, types, defaults, and descriptions
PropType / DescriptionDefault
ariaLabelstring

Accessible name of the statistics group.

Required
classNamestring

Classes merged onto the outer group.

Not set
item.badge{ label: ReactNode; variant?: 'default' | 'secondary' | 'destructive' | 'outline'; className?: string }

Optional non-interactive badge beneath the value. Classes affect only this badge.

Not set (variant: secondary)
item.captionReactNode

Optional supporting content beneath the value.

Not set
item.iconLucideIcon

Decorative icon component, hidden from assistive technology.

Required
item.keystring

Stable unique identifier. Preserve it across updates to animate from the current value.

Required
item.titlestring

Visible metric label in a definition list.

Required
item.valueStatsCardValue

Either { target: number | null, format?: (value: number) => string } or { display: ReactNode }, not both.

Required
item.value.format(value: number) => string

Formats numeric animation frames and the accessible final value. Must accept fractional values. Use display for exact financial strings.

Intl.NumberFormat("id-ID", { maximumFractionDigits: 0 }).format
itemsreadonly StatsCardItem[]

Ordered metrics. Each item requires a unique key, title, value, and Lucide icon. Empty arrays render an empty group.

Required

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

Source

"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>
  );
};