Docs
Timeline Stats

Timeline Stats

A comprehensive analytics dashboard showing growth metrics over time. Perfect for SaaS dashboards with mini charts, trend indicators, and detailed timeline breakdowns.

Growth Analytics

Track your key metrics and growth over time

Last 30 days
23.5%

Total Users

12,543

+2,401 this month

18.2%

Monthly Revenue

$48,392

+$7,450 this month

5.3%

Active Sessions

3,847

-215 from last week

12.4%

Growth Rate

32.8%

+3.6% increase

Detailed Timeline

View your metrics breakdown over time

PeriodTotal UsersMonthly RevenueActive SessionsGrowth Rate
Week 1
8.5K+12%
$35K+15%
4.2K+8%
28%+5%
Week 2
9.2K+8.2%
$38.5K+10%
4.1K-2.4%
29.5%+5.4%
Week 3
10.8K+17.4%
$42.1K+9.4%
4.1K-1.2%
31.2%+5.8%
Week 4
12.5K+16.1%
$48.4K+14.9%
3.8K-5%
32.8%+5.1%

Installation

Install dependencies

npm install framer-motion lucide-react

Copy and paste the following code into your project.

"use client";
 
import React from "react";
import { motion } from "framer-motion";
import {
  TrendingUp,
  Users,
  DollarSign,
  Activity,
  ArrowUpRight,
  ArrowDownRight,
} from "lucide-react";
 
interface TimelineDataPoint {
  period: string;
  value: number;
  change?: number;
  label?: string;
}
 
interface StatCard {
  id: string;
  title: string;
  value: string;
  change: number;
  changeLabel: string;
  icon: "users" | "revenue" | "activity" | "growth";
  timeline: TimelineDataPoint[];
}
 
interface TimelineStatsProps {
  title?: string;
  description?: string;
  stats: StatCard[];
  period?: string;
  className?: string;
}
 
export function TimelineStats({
  title = "Growth Analytics",
  description = "Track your key metrics over time",
  stats,
  period = "Last 30 days",
  className = "",
}: TimelineStatsProps) {
  const getIcon = (iconType: StatCard["icon"]) => {
    switch (iconType) {
      case "users":
        return Users;
      case "revenue":
        return DollarSign;
      case "activity":
        return Activity;
      case "growth":
        return TrendingUp;
    }
  };
 
  const getIconColor = (iconType: StatCard["icon"]) => {
    switch (iconType) {
      case "users":
        return "bg-blue-500/10 text-blue-600 dark:text-blue-400";
      case "revenue":
        return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
      case "activity":
        return "bg-purple-500/10 text-purple-600 dark:text-purple-400";
      case "growth":
        return "bg-orange-500/10 text-orange-600 dark:text-orange-400";
    }
  };
 
  const renderMiniChart = (
    timeline: TimelineDataPoint[],
    isPositive: boolean,
    chartId: string,
  ) => {
    if (timeline.length === 0) return null;
 
    const maxValue = Math.max(...timeline.map((d) => d.value));
    const minValue = Math.min(...timeline.map((d) => d.value));
    const range = maxValue - minValue || 1;
 
    const points = timeline.map((point, index) => {
      const x = (index / (timeline.length - 1)) * 100;
      const y = 100 - ((point.value - minValue) / range) * 70 - 15;
      return `${x},${y}`;
    });
 
    const chartColor = isPositive
      ? "text-emerald-500 dark:text-emerald-400"
      : "text-red-500 dark:text-red-400";
 
    return (
      <svg
        className="h-16 w-full"
        viewBox="0 0 100 100"
        preserveAspectRatio="none"
      >
        <defs>
          <linearGradient
            id={`gradient-${chartId}`}
            x1="0%"
            y1="0%"
            x2="0%"
            y2="100%"
          >
            <stop offset="0%" stopColor="currentColor" stopOpacity="0.3" />
            <stop offset="100%" stopColor="currentColor" stopOpacity="0.05" />
          </linearGradient>
        </defs>
        <motion.path
          initial={{ pathLength: 0, opacity: 0 }}
          animate={{ pathLength: 1, opacity: 1 }}
          transition={{ duration: 1.2, ease: "easeOut" }}
          d={`M ${points.join(" L ")} L 100,100 L 0,100 Z`}
          fill={`url(#gradient-${chartId})`}
          className={chartColor}
        />
        <motion.polyline
          initial={{ pathLength: 0, opacity: 0 }}
          animate={{ pathLength: 1, opacity: 1 }}
          transition={{ duration: 1.2, ease: "easeOut", delay: 0.1 }}
          points={points.join(" ")}
          fill="none"
          stroke="currentColor"
          strokeWidth="2.5"
          strokeLinecap="round"
          strokeLinejoin="round"
          className={chartColor}
        />
        {points.map((point, index) => {
          const [x, y] = point.split(",");
          return (
            <motion.circle
              key={index}
              initial={{ scale: 0, opacity: 0 }}
              animate={{ scale: 1, opacity: 1 }}
              transition={{
                duration: 0.3,
                delay: 1.2 + index * 0.1,
                ease: "easeOut",
              }}
              cx={x}
              cy={y}
              r="2.5"
              fill="currentColor"
              className={chartColor}
            />
          );
        })}
      </svg>
    );
  };
 
  return (
    <section className={`w-full py-24 ${className}`}>
      <div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
        <motion.div
          initial={{ opacity: 0, y: 20 }}
          whileInView={{ opacity: 1, y: 0 }}
          transition={{ duration: 0.5, ease: "easeOut" }}
          className="mb-12 text-center"
        >
          <h2 className="mb-4 text-4xl font-bold tracking-tight text-zinc-900 dark:text-white sm:text-5xl">
            {title}
          </h2>
          <p className="mx-auto max-w-2xl text-lg text-zinc-600 dark:text-zinc-400">
            {description}
          </p>
          <div className="mt-4 inline-flex items-center gap-2 rounded-full bg-zinc-100 px-4 py-2 text-sm font-medium text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300">
            <Activity className="h-4 w-4" />
            {period}
          </div>
        </motion.div>
 
        <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
          {stats.map((stat, index) => {
            const Icon = getIcon(stat.icon);
            const isPositive = stat.change >= 0;
            const ChangeIcon = isPositive ? ArrowUpRight : ArrowDownRight;
 
            return (
              <motion.div
                key={stat.id}
                initial={{ opacity: 0, y: 20 }}
                whileInView={{ opacity: 1, y: 0 }}
                viewport={{ once: true }}
                transition={{
                  duration: 0.5,
                  delay: index * 0.1,
                  ease: "easeOut",
                }}
                className="group relative overflow-hidden rounded-2xl border border-zinc-200 bg-white p-6 transition-all hover:shadow-lg dark:border-zinc-800 dark:bg-zinc-900"
              >
                {/* Icon */}
                <div className="mb-4 flex items-center justify-between">
                  <div
                    className={`flex h-12 w-12 items-center justify-center rounded-xl ${getIconColor(stat.icon)}`}
                  >
                    <Icon className="h-6 w-6" />
                  </div>
                  <div
                    className={`flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-semibold ${
                      isPositive
                        ? "bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400"
                        : "bg-red-50 text-red-700 dark:bg-red-950/30 dark:text-red-400"
                    }`}
                  >
                    <ChangeIcon className="h-3 w-3" />
                    {Math.abs(stat.change)}%
                  </div>
                </div>
 
                <div className="mb-4">
                  <p className="mb-1 text-sm font-medium text-zinc-600 dark:text-zinc-400">
                    {stat.title}
                  </p>
                  <p className="text-3xl font-bold text-zinc-900 dark:text-white">
                    {stat.value}
                  </p>
                  <p className="mt-1 text-xs text-zinc-500 dark:text-zinc-500">
                    {stat.changeLabel}
                  </p>
                </div>
 
                <div className="relative h-16 overflow-hidden rounded-lg bg-zinc-50 dark:bg-zinc-800/50">
                  {renderMiniChart(stat.timeline, isPositive, stat.id)}
                </div>
                <div className="absolute inset-0 -z-10 bg-gradient-to-br from-blue-500/5 to-purple-500/5 opacity-0 transition-opacity group-hover:opacity-100" />
              </motion.div>
            );
          })}
        </div>
 
        <motion.div
          initial={{ opacity: 0, y: 20 }}
          whileInView={{ opacity: 1, y: 0 }}
          viewport={{ once: true }}
          transition={{ duration: 0.5, ease: "easeOut" }}
          className="mt-12 overflow-hidden rounded-2xl border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900"
        >
          <div className="border-b border-zinc-200 p-6 dark:border-zinc-800">
            <h3 className="text-xl font-bold text-zinc-900 dark:text-white">
              Detailed Timeline
            </h3>
            <p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
              View your metrics breakdown over time
            </p>
          </div>
          <div className="overflow-x-auto">
            <table className="w-full">
              <thead className="border-b border-zinc-200 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-800/50">
                <tr>
                  <th className="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider text-zinc-600 dark:text-zinc-400">
                    Period
                  </th>
                  {stats.map((stat) => (
                    <th
                      key={stat.id}
                      className="px-6 py-3 text-right text-xs font-semibold uppercase tracking-wider text-zinc-600 dark:text-zinc-400"
                    >
                      {stat.title}
                    </th>
                  ))}
                </tr>
              </thead>
              <tbody className="divide-y divide-zinc-200 dark:divide-zinc-800">
                {stats[0]?.timeline.map((_, periodIndex) => (
                  <motion.tr
                    key={periodIndex}
                    initial={{ opacity: 0, x: -20 }}
                    whileInView={{ opacity: 1, x: 0 }}
                    viewport={{ once: true }}
                    transition={{
                      duration: 0.3,
                      delay: periodIndex * 0.05,
                      ease: "easeOut",
                    }}
                    className="transition-colors hover:bg-zinc-50 dark:hover:bg-zinc-800/50"
                  >
                    <td className="whitespace-nowrap px-6 py-4 text-sm font-medium text-zinc-900 dark:text-white">
                      {stats[0].timeline[periodIndex].period}
                    </td>
                    {stats.map((stat) => {
                      const dataPoint = stat.timeline[periodIndex];
                      const change = dataPoint.change;
                      return (
                        <td
                          key={stat.id}
                          className="whitespace-nowrap px-6 py-4 text-right text-sm"
                        >
                          <div className="flex items-center justify-end gap-2">
                            <span className="font-semibold text-zinc-900 dark:text-white">
                              {dataPoint.label ||
                                dataPoint.value.toLocaleString()}
                            </span>
                            {change !== undefined && (
                              <span
                                className={`text-xs ${
                                  change >= 0
                                    ? "text-emerald-600 dark:text-emerald-400"
                                    : "text-red-600 dark:text-red-400"
                                }`}
                              >
                                {change >= 0 ? "+" : ""}
                                {change}%
                              </span>
                            )}
                          </div>
                        </td>
                      );
                    })}
                  </motion.tr>
                ))}
              </tbody>
            </table>
          </div>
        </motion.div>
      </div>
    </section>
  );
}

Features

  • Multiple stat cards - Display 4+ key metrics at once
  • Mini charts - Visual sparklines for each metric
  • Trend indicators - Show percentage changes with arrows
  • Detailed timeline - Comprehensive data table breakdown
  • Color-coded icons - Different colors for each metric type
  • Responsive design - Works on all screen sizes
  • Dark mode ready - Beautiful in both themes
  • Smooth animations - Framer Motion powered
  • TypeScript support - Full type safety
  • Customizable - Easy to adapt to your needs

Usage

Basic Usage

import TimelineStats from "@/components/ui/timeline-stats";
 
const stats = [
  {
    id: "users",
    title: "Total Users",
    value: "12,543",
    change: 23.5,
    changeLabel: "+2,401 this month",
    icon: "users",
    timeline: [
      {period: "Week 1", value: 8500, change: 12, label: "8.5K"},
      {period: "Week 2", value: 9200, change: 8.2, label: "9.2K"},
      {period: "Week 3", value: 10800, change: 17.4, label: "10.8K"},
      {period: "Week 4", value: 12543, change: 16.1, label: "12.5K"},
    ],
  },
];
 
export default function Dashboard() {
  return <TimelineStats stats={stats} period="Last 30 days" />;
}

With Multiple Metrics

const stats = [
  {
    id: "users",
    title: "Total Users",
    value: "12,543",
    change: 23.5,
    changeLabel: "+2,401 this month",
    icon: "users",
    timeline: [
      /* ... */
    ],
  },
  {
    id: "revenue",
    title: "Monthly Revenue",
    value: "$48,392",
    change: 18.2,
    changeLabel: "+$7,450 this month",
    icon: "revenue",
    timeline: [
      /* ... */
    ],
  },
  {
    id: "activity",
    title: "Active Sessions",
    value: "3,847",
    change: -5.3,
    changeLabel: "-215 from last week",
    icon: "activity",
    timeline: [
      /* ... */
    ],
  },
];
 
<TimelineStats
  title="Growth Analytics"
  description="Track your key metrics over time"
  stats={stats}
/>;

Props

TimelineStatsProps

PropTypeDefaultDescription
statsStatCard[]RequiredArray of stat cards
titlestring"Growth Analytics"Section title
descriptionstring"Track your key..."Section description
periodstring"Last 30 days"Time period label
classNamestring""Additional CSS classes

StatCard

PropTypeRequiredDescription
idstringYesUnique identifier
titlestringYesStat title
valuestringYesCurrent value (formatted)
changenumberYesPercentage change
changeLabelstringYesChange description
icon"users" | "revenue" | "activity" | "growth"YesIcon type
timelineTimelineDataPoint[]YesTimeline data points

TimelineDataPoint

PropTypeRequiredDescription
periodstringYesPeriod label (e.g. "Week 1")
valuenumberYesNumeric value
changenumberNoPercentage change
labelstringNoFormatted display label

TypeScript Interface

interface TimelineDataPoint {
  period: string;
  value: number;
  change?: number;
  label?: string;
}
 
interface StatCard {
  id: string;
  title: string;
  value: string;
  change: number;
  changeLabel: string;
  icon: "users" | "revenue" | "activity" | "growth";
  timeline: TimelineDataPoint[];
}
 
interface TimelineStatsProps {
  title?: string;
  description?: string;
  stats: StatCard[];
  period?: string;
  className?: string;
}

Use Cases

Perfect for:

  • SaaS dashboards - Show key business metrics
  • Analytics pages - Display growth trends
  • Admin panels - Monitor system performance
  • Business intelligence - Track KPIs over time
  • Investor reports - Visualize company growth
  • Product analytics - Monitor user engagement
  • Marketing dashboards - Track campaign performance
  • Financial reporting - Display revenue trends

Customization

Add Custom Icons

const getIcon = (iconType: StatCard["icon"]) => {
  switch (iconType) {
    case "users":
      return Users;
    case "revenue":
      return DollarSign;
    case "activity":
      return Activity;
    case "growth":
      return TrendingUp;
    case "custom": // Add your own
      return YourCustomIcon;
  }
};

Change Color Scheme

const getIconColor = (iconType: StatCard["icon"]) => {
  switch (iconType) {
    case "users":
      return "bg-blue-500/10 text-blue-600"; // Your brand color
    case "revenue":
      return "bg-emerald-500/10 text-emerald-600";
    // ... more colors
  }
};

Custom Period Selector

const [period, setPeriod] = useState("30d");
 
<div className="mb-8 flex justify-center gap-2">
  <button
    onClick={() => setPeriod("7d")}
    className={`rounded-lg px-4 py-2 ${period === "7d" ? "bg-blue-600 text-white" : "bg-zinc-100"}`}
  >
    7 Days
  </button>
  <button
    onClick={() => setPeriod("30d")}
    className={`rounded-lg px-4 py-2 ${period === "30d" ? "bg-blue-600 text-white" : "bg-zinc-100"}`}
  >
    30 Days
  </button>
  <button
    onClick={() => setPeriod("90d")}
    className={`rounded-lg px-4 py-2 ${period === "90d" ? "bg-blue-600 text-white" : "bg-zinc-100"}`}
  >
    90 Days
  </button>
</div>
 
<TimelineStats stats={stats} period={`Last ${period}`} />

Real-Time Data Updates

"use client";
 
import {useEffect, useState} from "react";
 
export default function LiveDashboard() {
  const [stats, setStats] = useState(initialStats);
 
  useEffect(() => {
    // Fetch data every 30 seconds
    const interval = setInterval(async () => {
      const response = await fetch("/api/analytics");
      const data = await response.json();
      setStats(data.stats);
    }, 30000);
 
    return () => clearInterval(interval);
  }, []);
 
  return <TimelineStats stats={stats} />;
}

Export Data

const exportToCSV = () => {
  const headers = ["Period", ...stats.map((s) => s.title)];
  const rows = stats[0].timeline.map((_, i) => [
    stats[0].timeline[i].period,
    ...stats.map((s) => s.timeline[i].value),
  ]);
 
  const csv = [headers, ...rows].map((row) => row.join(",")).join("\n");
  const blob = new Blob([csv], {type: "text/csv"});
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = "analytics.csv";
  a.click();
};
 
<button onClick={exportToCSV}>Export CSV</button>;

Common Patterns

Monthly Dashboard

const monthlyStats = [
  {
    id: "mrr",
    title: "Monthly Recurring Revenue",
    value: "$125,430",
    change: 15.8,
    changeLabel: "+$17,120 this month",
    icon: "revenue",
    timeline: [
      {period: "Jan", value: 95000, change: 12, label: "$95K"},
      {period: "Feb", value: 102000, change: 7.4, label: "$102K"},
      {period: "Mar", value: 115000, change: 12.7, label: "$115K"},
      {period: "Apr", value: 125430, change: 9.1, label: "$125K"},
    ],
  },
];

User Growth Tracking

const userGrowth = [
  {
    id: "total-users",
    title: "Total Users",
    value: "45,231",
    change: 28.3,
    changeLabel: "+9,987 new users",
    icon: "users",
    timeline: [
      /* weekly data */
    ],
  },
  {
    id: "active-users",
    title: "Active Users (DAU)",
    value: "12,543",
    change: 15.2,
    changeLabel: "+1,658 daily active",
    icon: "activity",
    timeline: [
      /* weekly data */
    ],
  },
];

Conversion Funnel

const conversionStats = [
  {
    id: "visitors",
    title: "Website Visitors",
    value: "125,430",
    change: 23.5,
    changeLabel: "+23,890 visitors",
    icon: "activity",
    timeline: [
      /* ... */
    ],
  },
  {
    id: "signups",
    title: "Sign Ups",
    value: "12,543",
    change: 18.2,
    changeLabel: "+1,928 signups",
    icon: "users",
    timeline: [
      /* ... */
    ],
  },
  {
    id: "conversions",
    title: "Paid Conversions",
    value: "1,254",
    change: 32.1,
    changeLabel: "+305 conversions",
    icon: "revenue",
    timeline: [
      /* ... */
    ],
  },
  {
    id: "conversion-rate",
    title: "Conversion Rate",
    value: "10.0%",
    change: 8.7,
    changeLabel: "+0.8% improvement",
    icon: "growth",
    timeline: [
      /* ... */
    ],
  },
];

Integration

With API

async function fetchAnalytics(period: string) {
  const response = await fetch(`/api/analytics?period=${period}`);
  const data = await response.json();
 
  return data.metrics.map((metric) => ({
    id: metric.id,
    title: metric.name,
    value: formatValue(metric.current),
    change: metric.percentChange,
    changeLabel: metric.changeDescription,
    icon: metric.iconType,
    timeline: metric.history,
  }));
}

With Database

// Example with Prisma
async function getGrowthStats() {
  const users = await prisma.user.groupBy({
    by: ["createdAt"],
    _count: true,
    orderBy: {createdAt: "asc"},
  });
 
  return {
    id: "users",
    title: "Total Users",
    value: users.reduce((sum, day) => sum + day._count, 0).toString(),
    timeline: users.map((day) => ({
      period: formatDate(day.createdAt),
      value: day._count,
    })),
  };
}