stats-2

PreviousNext

Cards stats section block with icons and trends

Docs
smoothuiui

Preview

Loading preview…
index.tsx
"use client";

import { DollarSign, Smartphone, Star, Users } from "lucide-react";
import { motion, useInView } from "motion/react";
import React, { useRef } from "react";

const STAGGER_DELAY = 0.1;
const ICON_STAGGER_OFFSET = 0.2;
const VALUE_DELAY_OFFSET = 0.3;
const TREND_STAGGER_OFFSET = 0.5;

type StatsCardsProps = {
  title?: string;
  description?: string;
  stats?: Array<{
    value: string;
    label: string;
    description?: string;
    icon?: string;
    trend?: {
      value: string;
      direction: "up" | "down";
    };
  }>;
};

const iconMap = {
  DollarSign,
  Users,
  Star,
  Smartphone,
};

export function StatsCards({
  title = "Key Metrics",
  description = "Track your success with these important numbers",
  stats = [
    {
      value: "2.5M",
      label: "Revenue",
      description: "Annual recurring revenue",
      icon: "DollarSign",
      trend: { value: "+12%", direction: "up" },
    },
    {
      value: "45K",
      label: "Customers",
      description: "Happy customers worldwide",
      icon: "Users",
      trend: { value: "+8%", direction: "up" },
    },
    {
      value: "98%",
      label: "Satisfaction",
      description: "Customer satisfaction rate",
      icon: "Star",
      trend: { value: "+2%", direction: "up" },
    },
    {
      value: "1.2M",
      label: "Downloads",
      description: "Total app downloads",
      icon: "Smartphone",
      trend: { value: "+15%", direction: "up" },
    },
  ],
}: StatsCardsProps) {
  const ref = useRef(null);
  const isInView = useInView(ref, { once: true });

  return (
    <section className="py-20">
      <div className="mx-auto max-w-7xl px-6">
        <motion.div
          className="mb-16 text-center"
          initial={{ opacity: 0, y: 20 }}
          transition={{ duration: 0.6 }}
          viewport={{ once: true }}
          whileInView={{ opacity: 1, y: 0 }}
        >
          <h2 className="mb-4 font-bold text-3xl text-foreground lg:text-4xl">
            {title}
          </h2>
          <p className="mx-auto max-w-2xl text-foreground/70 text-lg">
            {description}
          </p>
        </motion.div>

        <div
          className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4"
          ref={ref}
        >
          {stats.map((stat, index) => (
            <motion.div
              animate={
                isInView
                  ? { opacity: 1, y: 0, scale: 1 }
                  : { opacity: 0, y: 30, scale: 0.9 }
              }
              className="group relative overflow-hidden rounded-2xl border border-border bg-gradient-to-br from-background to-background/50 p-6 transition-all hover:scale-105 hover:border-brand hover:shadow-xl"
              initial={{ opacity: 0, y: 30, scale: 0.9 }}
              key={stat.label}
              transition={{
                duration: 0.6,
                delay: index * STAGGER_DELAY,
                type: "spring",
                stiffness: 100,
              }}
            >
              {/* Icon */}
              <motion.div
                animate={
                  isInView
                    ? { rotate: 0, scale: 1 }
                    : { rotate: -10, scale: 0.8 }
                }
                className="mb-4 text-3xl"
                initial={{ rotate: -10, scale: 0.8 }}
                transition={{
                  duration: 0.6,
                  delay: index * STAGGER_DELAY + ICON_STAGGER_OFFSET,
                  type: "spring",
                  stiffness: 200,
                }}
              >
                {React.createElement(
                  iconMap[stat.icon as keyof typeof iconMap] || DollarSign,
                  {
                    className: "h-8 w-8",
                  }
                )}
              </motion.div>

              {/* Value */}
              <motion.div
                animate={isInView ? { scale: 1 } : { scale: 0.5 }}
                className="mb-1 font-bold text-2xl text-foreground lg:text-3xl"
                initial={{ scale: 0.5 }}
                transition={{
                  duration: 0.8,
                  delay: index * STAGGER_DELAY + VALUE_DELAY_OFFSET,
                  type: "spring",
                  stiffness: 200,
                }}
              >
                {stat.value}
              </motion.div>

              {/* Label */}
              <h3 className="mb-2 font-semibold text-foreground text-sm uppercase tracking-wide">
                {stat.label}
              </h3>

              {/* Description */}
              {stat.description && (
                <p className="mb-3 text-foreground/70 text-xs">
                  {stat.description}
                </p>
              )}

              {/* Trend */}
              {stat.trend && (
                <motion.div
                  animate={
                    isInView ? { opacity: 1, x: 0 } : { opacity: 0, x: -10 }
                  }
                  className={`inline-flex items-center rounded-full px-2 py-1 font-medium text-xs ${
                    stat.trend.direction === "up"
                      ? "bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400"
                      : "bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400"
                  }`}
                  initial={{ opacity: 0, x: -10 }}
                  transition={{
                    duration: 0.4,
                    delay: index * STAGGER_DELAY + TREND_STAGGER_OFFSET,
                  }}
                >
                  <span className="mr-1">
                    {stat.trend.direction === "up" ? "↗" : "↘"}
                  </span>
                  {stat.trend.value}
                </motion.div>
              )}

              {/* Hover effect background */}
              <motion.div
                className="absolute inset-0 bg-gradient-to-br from-brand/10 via-transparent to-transparent opacity-0 group-hover:opacity-100"
                initial={{ opacity: 0 }}
                transition={{ duration: 0.3 }}
                whileHover={{ opacity: 1 }}
              />
            </motion.div>
          ))}
        </div>
      </div>
    </section>
  );
}

export default StatsCards;

Installation

npx shadcn@latest add @smoothui/stats-2

Usage

import { Stats2 } from "@/components/ui/stats-2"
<Stats2 />