# Stats Card
*URL: https://www.grettech.com/components/stats-card*

An animated statistics card with trend indicators.

- Counts up when scrolled into view
- Gradient colour variants, or your own gradient
- Up and down trend indicators
- Respects reduced-motion settings

## Install with the shadcn CLI
```bash
npx shadcn@latest add https://www.grettech.com/api/registry/stats-card
```
With its demo: `npx shadcn@latest add https://www.grettech.com/api/registry/stats-card-demo`

**Dependencies:** npm: clsx, tailwind-merge, lucide-react, class-variance-authority; shadcn/ui: card

## Manual installation
**Step 1: Install dependencies**
Install the packages this component uses and the shadcn/ui components it builds on. It also needs the `cn` helper in `lib/utils.ts`, which `npx shadcn@latest init` adds.

```bash
npm install clsx tailwind-merge lucide-react class-variance-authority
npx shadcn@latest add card
```

**Step 2: Copy the source code**
Add these files to your project components directory.

File: `components/ui/stats-card.tsx`
```tsx
"use client"

import { Card } from "@/components/ui/card"
import { Activity } from "lucide-react"
import * as React from "react"
import { ReactNode, useEffect, useRef, useState } from "react"
import { cn } from "@/lib/utils"
import { cva, type VariantProps } from "class-variance-authority"

const COUNT_DURATION = 1200

const reducedMotionQuery = "(prefers-reduced-motion: reduce)"
function subscribeReducedMotion(callback: () => void) {
  const media = window.matchMedia(reducedMotionQuery)
  media.addEventListener("change", callback)
  return () => media.removeEventListener("change", callback)
}
function usePrefersReducedMotion() {
  return React.useSyncExternalStore(
    subscribeReducedMotion,
    () => window.matchMedia(reducedMotionQuery).matches,
    () => false
  )
}

const statsCardVariants = cva(
  "group relative w-full max-w-[300px] cursor-default overflow-hidden p-5 transition-[transform,box-shadow] duration-300 hover:-translate-y-0.5 hover:shadow-md",
  {
    variants: {
      variant: {
        default:
          "[--stats-gradient-from:oklch(0.65_0.25_270)] [--stats-gradient-to:oklch(0.55_0.2_300)]",
        destructive:
          "[--stats-gradient-from:var(--color-red-500)] [--stats-gradient-to:var(--color-orange-400)]",
        success:
          "[--stats-gradient-from:var(--color-emerald-500)] [--stats-gradient-to:var(--color-green-400)]",
        blue: "[--stats-gradient-from:var(--color-blue-500)] [--stats-gradient-to:var(--color-cyan-400)]",
      },
    },
    defaultVariants: {
      variant: "default",
    },
  }
)

export interface StatsCardProps
  extends
    React.HTMLAttributes<HTMLDivElement>,
    VariantProps<typeof statsCardVariants> {
  title?: string
  value?: number
  icon?: ReactNode
  trend?: "up" | "down"
  trendValue?: string
  gradient?: string
  description?: string
}

export const StatsCard = React.forwardRef<HTMLDivElement, StatsCardProps>(
  (
    {
      title = "Total Revenue",
      value = 12345,
      icon = <Activity />,
      trend = "up",
      trendValue = "+12%",
      gradient,
      variant,
      description,
      className,
      ...props
    },
    forwardedRef
  ) => {
    const [display, setDisplay] = useState(0)
    const [inView, setInView] = useState(false)
    const cardRef = useRef<HTMLDivElement | null>(null)
    const fromRef = useRef(0)
    const reduceMotion = usePrefersReducedMotion()

    // Start counting once the card is actually on screen
    useEffect(() => {
      const node = cardRef.current
      if (!node || inView) return
      const observer = new IntersectionObserver(
        ([entry]) => {
          if (entry.isIntersecting) {
            setInView(true)
            observer.disconnect()
          }
        },
        { threshold: 0.4 }
      )
      observer.observe(node)
      return () => observer.disconnect()
    }, [inView])

    // Count from the last shown number, so value changes animate too
    useEffect(() => {
      if (!inView || reduceMotion) return
      const from = fromRef.current
      // Timed from the first frame: a frame's timestamp can predate this
      // effect, which would start the count below zero
      let start: number | undefined
      let frame = requestAnimationFrame(function tick(now) {
        start ??= now
        const progress = Math.min((now - start) / COUNT_DURATION, 1)
        const eased = 1 - Math.pow(1 - progress, 3)
        const current = Math.round(from + (value - from) * eased)
        fromRef.current = current
        setDisplay(current)
        if (progress < 1) frame = requestAnimationFrame(tick)
      })
      return () => cancelAnimationFrame(frame)
    }, [inView, reduceMotion, value])

    const shown = reduceMotion ? value : display

    const setRefs = (node: HTMLDivElement | null) => {
      cardRef.current = node
      if (typeof forwardedRef === "function") forwardedRef(node)
      else if (forwardedRef) forwardedRef.current = node
    }

    return (
      <Card
        ref={setRefs}
        className={cn(statsCardVariants({ variant, className }))}
        {...props}
      >
        <div
          aria-hidden="true"
          className={cn(
            "absolute top-0 right-0 left-0 h-0.5 opacity-60 transition-opacity group-hover:opacity-100",
            gradient
              ? `bg-linear-to-r ${gradient}`
              : "bg-linear-to-r from-(--stats-gradient-from) to-(--stats-gradient-to)"
          )}
        />
        <div className="flex items-start justify-between gap-3">
          <div className="space-y-2">
            <p className="text-sm font-medium text-muted-foreground">{title}</p>
            <p className="text-3xl font-bold tracking-tight text-foreground tabular-nums">
              <span aria-hidden="true">{shown.toLocaleString()}</span>
              <span className="sr-only">{value.toLocaleString()}</span>
            </p>
            {trend && trendValue && (
              <div className="flex items-center gap-1.5">
                <span className="sr-only">
                  {trend === "up" ? "Up" : "Down"}
                </span>
                {trend === "up" ? (
                  <svg
                    aria-hidden="true"
                    width="14"
                    height="14"
                    viewBox="0 0 24 24"
                    fill="none"
                    stroke="oklch(0.7 0.2 145)"
                    strokeWidth="2.5"
                    strokeLinecap="round"
                    strokeLinejoin="round"
                  >
                    <polyline points="22 7 13.5 15.5 8.5 10.5 2 17" />
                    <polyline points="16 7 22 7 22 13" />
                  </svg>
                ) : (
                  <svg
                    aria-hidden="true"
                    width="14"
                    height="14"
                    viewBox="0 0 24 24"
                    fill="none"
                    stroke="oklch(0.65 0.2 25)"
                    strokeWidth="2.5"
                    strokeLinecap="round"
                    strokeLinejoin="round"
                  >
                    <polyline points="22 17 13.5 8.5 8.5 13.5 2 7" />
                    <polyline points="16 17 22 17 22 11" />
                  </svg>
                )}
                <span
                  className={cn(
                    "text-xs font-medium",
                    trend === "up"
                      ? "text-[oklch(0.7_0.2_145)]"
                      : "text-[oklch(0.65_0.2_25)]"
                  )}
                >
                  {trendValue}
                </span>
                {description && (
                  <span className="ml-1 text-xs text-muted-foreground">
                    {description}
                  </span>
                )}
              </div>
            )}
          </div>
          <div
            className={cn(
              "flex size-11 shrink-0 items-center justify-center rounded-xl text-white shadow-md [&_svg]:size-5",
              gradient
                ? `bg-linear-to-br ${gradient}`
                : "bg-linear-to-br from-(--stats-gradient-from) to-(--stats-gradient-to)"
            )}
          >
            {icon}
          </div>
        </div>
      </Card>
    )
  }
)
StatsCard.displayName = "StatsCard"

```

**Step 3: Framework Adjustments (Optional)**
If you are manually copying this code into a Vite or Remix project (without using the CLI), make sure to remove the `"use client";` directive from the very top of the file.

## Props
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
| `title` | `string` | `"Total Revenue"` | No | Label above the value. |
| `value` | `number` | `12345` | No | The number to show. It counts up when the card scrolls into view, and animates again when it changes. |
| `icon` | `React.ReactNode` | `<Activity />` | No | Icon shown in the gradient tile on the right. |
| `trend` | `"up" \| "down"` | `"up"` | No | Direction of the trend arrow: green for up, red for down. |
| `trendValue` | `string` | `"+12%"` | No | Text next to the arrow. The trend row only shows when both trend and trendValue are set. |
| `description` | `string` | - | No | Text after the trend value. Only shown with the trend row. |
| `variant` | `"default" \| "destructive" \| "success" \| "blue"` | `"default"` | No | Preset accent gradient. Ignored when gradient is set. |
| `gradient` | `string` | - | No | Tailwind gradient stops for the accent line and icon tile, e.g. "from-red-500 to-orange-400". Overrides variant. |
| `className` | `string` | - | No | Extra classes for the card. |
| `...props` | `React.HTMLAttributes<HTMLDivElement>` | - | No | Any other attributes (id, style, aria-*, etc.), passed to the card. |

## Usage
```tsx
"use client"

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

export default function StatsCardDemo() {
  return (
    <StatsCard
      title="Total Users"
      value={12450}
      icon={<Users className="h-5 w-5" />}
      trend="up"
      trendValue="12%"
      description="Compared to last month"
    />
  )
}

```
