Docs
Changelog

Changelog

A modern, collapsible changelog component for SaaS products. Features color-coded change types, expandable entries, and smooth animations.

Changelog

Stay up to date with the latest features and improvements

NewAI-Powered Analytics Dashboard

Get intelligent insights and recommendations based on your data patterns using advanced machine learning.

NewTeam Workspaces

Create dedicated workspaces for different teams with custom permissions and settings.

ImprovedFaster Page Load Times

Optimized performance with 40% faster page loads and improved caching.

ImprovedEnhanced Search

Improved search algorithm with fuzzy matching and instant results.

FixedFixed Export Issues

Resolved issues with CSV exports containing special characters.

Installation

Install dependencies

npm install framer-motion lucide-react

Copy and paste the following code into your project.

"use client";
 
import React, { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Sparkles, Bug, Zap, Plus, ChevronDown } from "lucide-react";
 
interface ChangelogEntry {
  id: string;
  version: string;
  date: string;
  title: string;
  description?: string;
  changes: {
    type: "feature" | "improvement" | "bugfix" | "breaking";
    title: string;
    description?: string;
  }[];
}
 
interface ChangelogProps {
  title?: string;
  description?: string;
  entries: ChangelogEntry[];
  className?: string;
}
 
export function Changelog({
  title = "Changelog",
  description = "Stay up to date with the latest features and improvements",
  entries,
  className = "",
}: ChangelogProps) {
  const [expandedEntries, setExpandedEntries] = useState<Set<string>>(
    new Set([entries[0]?.id]),
  );
 
  const toggleEntry = (id: string) => {
    const newExpanded = new Set(expandedEntries);
    if (newExpanded.has(id)) {
      newExpanded.delete(id);
    } else {
      newExpanded.add(id);
    }
    setExpandedEntries(newExpanded);
  };
 
  const getChangeTypeConfig = (type: ChangelogEntry["changes"][0]["type"]) => {
    switch (type) {
      case "feature":
        return {
          label: "New",
          color: "text-blue-600 dark:text-blue-400",
          bg: "bg-blue-50 dark:bg-blue-950/30",
          icon: Plus,
        };
      case "improvement":
        return {
          label: "Improved",
          color: "text-emerald-600 dark:text-emerald-400",
          bg: "bg-emerald-50 dark:bg-emerald-950/30",
          icon: Zap,
        };
      case "bugfix":
        return {
          label: "Fixed",
          color: "text-amber-600 dark:text-amber-400",
          bg: "bg-amber-50 dark:bg-amber-950/30",
          icon: Bug,
        };
      case "breaking":
        return {
          label: "Breaking",
          color: "text-red-600 dark:text-red-400",
          bg: "bg-red-50 dark:bg-red-950/30",
          icon: Sparkles,
        };
    }
  };
 
  return (
    <section className={`w-full py-24 ${className}`}>
      <div className="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8">
        <motion.div
          initial={{ opacity: 0, y: 20 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ duration: 0.5, ease: "easeOut" }}
          className="mb-16 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>
        </motion.div>
        <div className="space-y-6">
          {entries.map((entry, index) => {
            const isExpanded = expandedEntries.has(entry.id);
            const isLatest = index === 0;
 
            return (
              <motion.div
                key={entry.id}
                initial={{ opacity: 0, y: 20 }}
                animate={{ opacity: 1, y: 0 }}
                transition={{
                  duration: 0.5,
                  delay: index * 0.1,
                  ease: "easeOut",
                }}
              >
                <div
                  className={`overflow-hidden rounded-2xl border bg-white shadow-sm transition-all duration-300 dark:bg-zinc-900 ${
                    isLatest
                      ? "border-blue-200 ring-2 ring-blue-100 dark:border-blue-800 dark:ring-blue-950/50"
                      : "border-zinc-200 dark:border-zinc-800"
                  }`}
                >
                  <button
                    onClick={() => toggleEntry(entry.id)}
                    className="flex w-full items-center justify-between p-6 text-left transition-colors hover:bg-zinc-50 dark:hover:bg-zinc-800/50"
                  >
                    <div className="flex-1">
                      <div className="mb-2 flex items-center gap-3">
                        <span className="inline-flex items-center gap-1.5 rounded-full bg-zinc-100 px-3 py-1 text-sm font-semibold text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300">
                          v{entry.version}
                        </span>
                        {isLatest && (
                          <span className="inline-flex items-center gap-1.5 rounded-full bg-blue-50 px-3 py-1 text-xs font-semibold text-blue-600 dark:bg-blue-950/30 dark:text-blue-400">
                            <Sparkles className="h-3 w-3" />
                            Latest
                          </span>
                        )}
                        <span className="text-sm text-zinc-500 dark:text-zinc-400">
                          {entry.date}
                        </span>
                      </div>
                      <h3 className="text-xl font-bold text-zinc-900 dark:text-white">
                        {entry.title}
                      </h3>
                      {entry.description && (
                        <p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
                          {entry.description}
                        </p>
                      )}
                    </div>
                    <motion.div
                      animate={{ rotate: isExpanded ? 180 : 0 }}
                      transition={{ duration: 0.2 }}
                      className="ml-4"
                    >
                      <ChevronDown className="h-5 w-5 text-zinc-400" />
                    </motion.div>
                  </button>
 
                  <AnimatePresence initial={false}>
                    {isExpanded && (
                      <motion.div
                        initial={{ height: 0, opacity: 0 }}
                        animate={{ height: "auto", opacity: 1 }}
                        exit={{ height: 0, opacity: 0 }}
                        transition={{ duration: 0.3, ease: "easeOut" }}
                        className="overflow-hidden"
                      >
                        <div className="border-t border-zinc-100 bg-zinc-50/50 p-6 dark:border-zinc-800 dark:bg-zinc-900/50">
                          <div className="space-y-4">
                            {entry.changes.map((change, idx) => {
                              const config = getChangeTypeConfig(change.type);
                              const Icon = config.icon;
 
                              return (
                                <motion.div
                                  key={idx}
                                  initial={{ opacity: 0, x: -10 }}
                                  animate={{ opacity: 1, x: 0 }}
                                  transition={{
                                    delay: idx * 0.05,
                                    duration: 0.3,
                                    ease: "easeOut",
                                  }}
                                  className="flex gap-3"
                                >
                                  <div
                                    className={`flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg ${config.bg}`}
                                  >
                                    <Icon
                                      className={`h-4 w-4 ${config.color}`}
                                    />
                                  </div>
                                  <div className="flex-1">
                                    <div className="mb-1 flex items-center gap-2">
                                      <span
                                        className={`text-xs font-semibold ${config.color}`}
                                      >
                                        {config.label}
                                      </span>
                                      <span className="font-medium text-zinc-900 dark:text-white">
                                        {change.title}
                                      </span>
                                    </div>
                                    {change.description && (
                                      <p className="text-sm text-zinc-600 dark:text-zinc-400">
                                        {change.description}
                                      </p>
                                    )}
                                  </div>
                                </motion.div>
                              );
                            })}
                          </div>
                        </div>
                      </motion.div>
                    )}
                  </AnimatePresence>
                </div>
              </motion.div>
            );
          })}
        </div>
 
        <motion.div
          initial={{ opacity: 0 }}
          animate={{ opacity: 1 }}
          transition={{ duration: 0.5, delay: 0.4, ease: "easeOut" }}
          className="mt-12 text-center"
        >
          <a
            href="#"
            className="inline-flex items-center gap-2 text-sm font-semibold text-blue-600 transition-colors hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
          >
            View full changelog
            <ChevronDown className="h-4 w-4 -rotate-90" />
          </a>
        </motion.div>
      </div>
    </section>
  );
}

Features

  • Collapsible entries - Click to expand/collapse each version
  • Latest badge - Highlights the newest release
  • Color-coded changes - Different colors for each change type
  • Change type icons - Visual indicators for each change
  • Smooth animations - Expand/collapse with smooth transitions
  • Clean design - Modern card-based layout
  • Responsive - Works beautifully on all screen sizes
  • Dark mode ready - Elegant in both themes
  • TypeScript support - Full type safety

Usage

Basic Usage

import Changelog from "@/components/ui/changelog";
 
const changelogEntries = [
  {
    id: "v2.5.0",
    version: "2.5.0",
    date: "November 15, 2024",
    title: "AI-Powered Analytics",
    description: "Major update with AI-powered insights.",
    changes: [
      {
        type: "feature",
        title: "AI Analytics Dashboard",
        description: "Get intelligent insights based on your data patterns.",
      },
      {
        type: "improvement",
        title: "Faster Page Loads",
        description: "40% faster page loads with improved caching.",
      },
      {
        type: "bugfix",
        title: "Fixed Export Issues",
        description: "Resolved CSV export problems.",
      },
    ],
  },
];
 
export default function Page() {
  return <Changelog entries={changelogEntries} />;
}

Custom Title and Description

<Changelog
  title="What's New"
  description="See what we've been working on"
  entries={changelogEntries}
/>

Props

ChangelogProps

PropTypeDefaultDescription
entriesChangelogEntry[]RequiredArray of changelog entries
titlestring"Changelog"Section title
descriptionstring"Stay up to date with..."Section description
classNamestring""Additional CSS classes

ChangelogEntry

PropTypeRequiredDescription
idstringYesUnique identifier
versionstringYesVersion number (e.g., "2.5.0")
datestringYesRelease date
titlestringYesRelease title
descriptionstringNoRelease description
changesChange[]YesArray of changes

Change

PropTypeRequiredDescription
type"feature" | "improvement" | "bugfix" | "breaking"YesType of change
titlestringYesChange title
descriptionstringNoChange description

TypeScript Interface

interface ChangelogEntry {
  id: string;
  version: string;
  date: string;
  title: string;
  description?: string;
  changes: {
    type: "feature" | "improvement" | "bugfix" | "breaking";
    title: string;
    description?: string;
  }[];
}
 
interface ChangelogProps {
  title?: string;
  description?: string;
  entries: ChangelogEntry[];
  className?: string;
}

Use Cases

Perfect for:

  • Product changelog pages
  • Release notes
  • Update announcements
  • Version history
  • Developer documentation
  • Customer communication
  • Transparency pages
  • What's new sections

Customization

Change Colors

// In the component, modify getChangeTypeConfig function
 
case "feature":
  return {
    label: "New",
    color: "text-purple-600 dark:text-purple-400", // Your brand color
    bg: "bg-purple-50 dark:bg-purple-950/30",
    icon: Plus,
  };

Add Custom Change Types

// Add a new change type
type ChangeType = "feature" | "improvement" | "bugfix" | "breaking" | "security";
 
// Add to getChangeTypeConfig
case "security":
  return {
    label: "Security",
    color: "text-violet-600 dark:text-violet-400",
    bg: "bg-violet-50 dark:bg-violet-950/30",
    icon: Shield,
  };

Change Default Expanded State

// Expand all entries by default
const [expandedEntries, setExpandedEntries] = useState<Set<string>>(
  new Set(entries.map((e) => e.id))
);
 
// Collapse all by default
const [expandedEntries, setExpandedEntries] = useState<Set<string>>(new Set());
 
// Expand first 3 entries
const [expandedEntries, setExpandedEntries] = useState<Set<string>>(
  new Set(entries.slice(0, 3).map((e) => e.id))
);

Remove Latest Badge

// If you don't want the "Latest" badge
// Remove or comment out this section:
 
{
  isLatest && (
    <span className="inline-flex items-center gap-1.5 rounded-full bg-blue-50 px-3 py-1 text-xs font-semibold text-blue-600">
      <Sparkles className="h-3 w-3" />
      Latest
    </span>
  );
}

Change Animation Speed

// Make animations faster or slower
<motion.div
  initial={{height: 0, opacity: 0}}
  animate={{height: "auto", opacity: 1}}
  exit={{height: 0, opacity: 0}}
  transition={{duration: 0.5}} // Slower: 0.5, Faster: 0.2
>

Common Patterns

SaaS Product Changelog

const saasChangelog = [
  {
    id: "v3.0.0",
    version: "3.0.0",
    date: "December 1, 2024",
    title: "Major Platform Upgrade",
    description: "Complete redesign with new features and improvements.",
    changes: [
      {
        type: "feature",
        title: "New Dashboard",
        description: "Completely redesigned dashboard with better UX.",
      },
      {
        type: "feature",
        title: "Advanced Analytics",
        description: "AI-powered insights and predictions.",
      },
      {
        type: "breaking",
        title: "API v2 Required",
        description: "API v1 is deprecated. Please upgrade to v2.",
      },
    ],
  },
];

Mobile App Changelog

const mobileChangelog = [
  {
    id: "v1.5.0",
    version: "1.5.0",
    date: "November 20, 2024",
    title: "Performance & Stability",
    changes: [
      {
        type: "improvement",
        title: "Faster App Launch",
        description: "App now launches 50% faster.",
      },
      {
        type: "bugfix",
        title: "Fixed Crash on iOS 18",
        description: "Resolved crash when opening notifications.",
      },
    ],
  },
];

API Changelog

const apiChangelog = [
  {
    id: "v2.1.0",
    version: "2.1.0",
    date: "November 10, 2024",
    title: "New Endpoints & Rate Limits",
    changes: [
      {
        type: "feature",
        title: "Webhooks API",
        description: "New endpoints for managing webhooks.",
      },
      {
        type: "improvement",
        title: "Increased Rate Limits",
        description: "Rate limits doubled for all paid plans.",
      },
      {
        type: "breaking",
        title: "Authentication Changes",
        description: "OAuth 2.0 now required for all endpoints.",
      },
    ],
  },
];

Integration Examples

With RSS Feed

// Generate RSS feed from changelog
export async function GET() {
  const feed = new RSS({
    title: "Product Changelog",
    description: "Latest updates",
    feed_url: "https://example.com/changelog/rss",
    site_url: "https://example.com",
  });
 
  changelogEntries.forEach((entry) => {
    feed.item({
      title: `v${entry.version}: ${entry.title}`,
      description: entry.description,
      url: `https://example.com/changelog#${entry.id}`,
      date: entry.date,
    });
  });
 
  return new Response(feed.xml(), {
    headers: {"Content-Type": "application/xml"},
  });
}

With Email Notifications

// Send email when new version is released
async function notifyUsers(entry: ChangelogEntry) {
  await sendEmail({
    to: subscribers,
    subject: `New Update: v${entry.version}`,
    html: `
      <h1>${entry.title}</h1>
      <p>${entry.description}</p>
      <ul>
        ${entry.changes.map((c) => `<li>${c.title}</li>`).join("")}
      </ul>
    `,
  });
}

With Analytics

// Track which changelog entries users view
const trackChangelogView = (version: string) => {
  analytics.track("changelog_entry_viewed", {
    version,
    timestamp: Date.now(),
  });
};
 
// Track which entries users expand
const trackChangelogExpand = (version: string) => {
  analytics.track("changelog_entry_expanded", {
    version,
    timestamp: Date.now(),
  });
};