Stat Card

Headline KPI card for metrics, trends, and supporting context.

Report a bug

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/stat-card.json
bash

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

2 stories available:

Code

import * as React from "react";

import { cva, type VariantProps } from "class-variance-authority";
import { ArrowDownRight, ArrowRight, ArrowUpRight } from "lucide-react";

import { cn } from "../../lib/utils";
import { Card, CardContent, CardDescription, CardHeader } from "../card";

const statCardVariants = cva("overflow-hidden border shadow-sm", {
  defaultVariants: {
    tone: "neutral",
  },
  variants: {
    tone: {
      danger: "border-red-200/70 dark:border-red-950/70",
      neutral: "",
      success: "border-emerald-200/70 dark:border-emerald-950/70",
      warning: "border-amber-200/70 dark:border-amber-950/70",
    },
  },
});

const accentVariants = cva("h-1 w-full", {
  defaultVariants: {
    tone: "neutral",
  },
  variants: {
    tone: {
      danger: "bg-red-500/80",
      neutral: "bg-primary/70",
      success: "bg-emerald-500/80",
      warning: "bg-amber-500/80",
    },
  },
});

const changeVariants = cva(
  "inline-flex items-center gap-1 text-xs font-medium",
  {
    defaultVariants: {
      trend: "neutral",
    },
    variants: {
      trend: {
        down: "text-red-600 dark:text-red-400",
        neutral: "text-muted-foreground",
        up: "text-emerald-600 dark:text-emerald-400",
      },
    },
  },
);

type StatCardTrend = "down" | "neutral" | "up";

export type StatCardProps = React.HTMLAttributes<HTMLDivElement> &
  VariantProps<typeof statCardVariants> & {
    change?: React.ReactNode;
    description?: React.ReactNode;
    icon?: React.ReactNode;
    label: React.ReactNode;
    meta?: React.ReactNode;
    trend?: StatCardTrend;
    value: React.ReactNode;
  };

function TrendIcon({ trend }: { trend: StatCardTrend }) {
  if (trend === "up") {
    return <ArrowUpRight className="size-3.5" />;
  }

  if (trend === "down") {
    return <ArrowDownRight className="size-3.5" />;
  }

  return <ArrowRight className="size-3.5" />;
}

const StatCard = React.forwardRef<HTMLDivElement, StatCardProps>(
  (
    {
      change,
      className,
      description,
      icon,
      label,
      meta,
      tone,
      trend = "neutral",
      value,
      ...props
    },
    reference,
  ) => (
    <Card
      className={cn(statCardVariants({ tone }), className)}
      ref={reference}
      {...props}
    >
      <div className={accentVariants({ tone })} />
      <CardHeader className="flex flex-row items-start justify-between gap-4 space-y-0 pb-3">
        <div className="space-y-1">
          <CardDescription className="text-xs font-medium uppercase tracking-[0.14em]">
            {label}
          </CardDescription>
          <div className="text-3xl font-semibold tracking-tight">{value}</div>
        </div>
        {icon ? (
          <div className="rounded-lg border bg-muted/50 p-2 text-muted-foreground">
            {icon}
          </div>
        ) : null}
      </CardHeader>
      {description || change || meta ? (
        <CardContent className="space-y-3">
          {description ? (
            <p className="text-sm text-muted-foreground">{description}</p>
          ) : null}
          {change || meta ? (
            <div className="flex flex-wrap items-center justify-between gap-3">
              {change ? (
                <div className={changeVariants({ trend })}>
                  <TrendIcon trend={trend} />
                  <span>{change}</span>
                </div>
              ) : null}
              {meta ? (
                <div className="text-xs text-muted-foreground">{meta}</div>
              ) : null}
            </div>
          ) : null}
        </CardContent>
      ) : null}
    </Card>
  ),
);

StatCard.displayName = "StatCard";

export { StatCard, statCardVariants };
typescript

Dependencies

  • @vllnt/ui@^0.2.1