DHxWhy

What's New Changelog Timeline

Component 69

About

What's New Changelog Timeline for React and Tailwind CSS (shadcn/ui) adds hover-to-expand headlines and an animated timeline rail. It is built to make release notes easy to scan and explore. Includes click-to-pin, month groups, live status, controlled expansion, keyboard support, reduced motion, and light/dark themes. Use it for product updates and release history. The included demo shows sample updates from CCgather; the component makes no requests. Requires React and lucide-react, with no animation library.

Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds What's New Changelog Timeline to your project and fits it to your theme.

Code

How What's New Changelog Timeline is used: the demo's code. The component's own source comes with a free account.

Usage.tsx

"use client";

import * as React from "react";
import { MousePointer2, Pause, RotateCcw } from "lucide-react";
import { ChangelogTimeline } from "@/components/ui/changelog-timeline";
import type { ChangelogEntry } from "@/components/ui/changelog-timeline";

const settings = { visibleEntries: 9, liveStatus: true, autoplay: true };
export const CHANGELOG_DEMO_ENTRIES: ChangelogEntry[] = [
  { id: "2026-10-04-smooth-scroll", date: "2026-10-04", category: "improved", title: "Smoother leaderboard scrolling", summary: "A steadier scroll through rankings, country views and profiles. Small refinements make long lists easier to explore." },
  { id: "2026-10-04-cli-submit-speed", date: "2026-10-04", category: "improved", title: "Faster CLI submissions", summary: "The duplicate check joins the scan, with clearer progress from your first file to the final submission." },
  { id: "2026-09-29-sonnet-5-5-pricing", date: "2026-09-29", category: "improved", title: "Updated model cost tracking", summary: "A refreshed cost breakdown keeps model usage, input, output and cache activity together in one view." },
  { id: "2026-09-05-speed", date: "2026-09-05", category: "improved", title: "Quicker leaderboard loading", summary: "Rankings arrive with the page. The globe loads afterward, so the list is ready to explore first." },
  { id: "2026-09-04-badges", date: "2026-09-04", category: "new", title: "A new badge collection", summary: "Explore the collection by rarity, with a small reveal when a new badge joins your profile." },
  { id: "2026-09-04-badge-award", date: "2026-09-04", category: "fixed", title: "More reliable badge unlocks", summary: "Session and streak activity now reaches the badge check together with each submission." },
  { id: "2026-08-06-star", date: "2026-08-06", category: "new", title: "Star the project from your CLI", summary: "A quick project shortcut appears after a sync, with confirmation before you continue." },
  { id: "2026-07-28-globe-flags", date: "2026-07-28", category: "new", title: "Country flags on the globe", summary: "Flags add another way to find a country as you explore the community globe." },
  { id: "2026-07-24-sprint", date: "2026-07-24", category: "new", title: "Season Sprint board", summary: "Follow the monthly ranking and see the distance to the next position." },
];

const CCGATHER_URL = "https://ccgather.com/?utm_source=21st&utm_medium=component&utm_campaign=changelog";
type DemoProps = { visibleEntries?: number; liveStatus?: boolean; autoplay?: boolean };

export default function Demo(props: DemoProps) {
  const s = { ...settings, ...props };
  const [expanded, setExpanded] = React.useState(null as string | null);
  const [pinned, setPinned] = React.useState(null as string | null);
  const [paused, setPaused] = React.useState(false);
  const [finished, setFinished] = React.useState(false);
  const [reduced, setReduced] = React.useState(true);
  const [phase, setPhase] = React.useState(0);
  const [run, setRun] = React.useState(0);
  const [pointer, setPointer] = React.useState({ x: 0, y: 0 });
  const [deployedAt] = React.useState(() => new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString());
  const stage = React.useRef(null as HTMLDivElement | null);
  const table = React.useRef(null as HTMLDivElement | null);
  const playing = s.autoplay && !paused && !finished && !reduced;

  React.useEffect(() => {
    const media = window.matchMedia("(prefers-reduced-motion: reduce)");
    const sync = () => setReduced(media.matches);
    sync();
    media.addEventListener("change", sync);
    return () => media.removeEventListener("change", sync);
  }, []);

  React.useEffect(() => {
    if (!playing) return;
    const steps: [number, () => void][] = [
      [1000, () => { setPhase(1); setExpanded(CHANGELOG_DEMO_ENTRIES[0]!.id); }],
      [2200, () => { setPhase(2); setExpanded(CHANGELOG_DEMO_ENTRIES[1]!.id); }],
      [3400, () => { setPhase(3); setExpanded(CHANGELOG_DEMO_ENTRIES[2]!.id); }],
      [4600, () => { setPhase(4); setExpanded(CHANGELOG_DEMO_ENTRIES[3]!.id); }],
      [5800, () => { setPhase(5); setExpanded(null); }],
      [6600, () => { setPhase(6); setExpanded(CHANGELOG_DEMO_ENTRIES[0]!.id); setPinned(CHANGELOG_DEMO_ENTRIES[0]!.id); }],
      [6900, () => setFinished(true)],
    ];
    const timers = steps.map(([delay, action]) => window.setTimeout(action, delay));
    return () => timers.forEach((timer) => window.clearTimeout(timer));
  }, [playing, run]);

  React.useLayoutEffect(() => {
    const frame = stage.current;
    const card = table.current;
    if (!frame || !card) return;
    const update = () => {
      const origin = frame.getBoundingClientRect();
      const row = Array.from(card.querySelectorAll("[data-entry-trigger]")).find((item) => (item as HTMLElement).dataset.entryTrigger === expanded)?.getBoundingClientRect();
      const box = card.getBoundingClientRect();
      setPointer({ x: row ? row.right - origin.left - 48 : box.right - origin.left - 30, y: row ? row.top + row.height / 2 - origin.top : box.top - origin.top + 24 });
    };
    update();
    const observer = new ResizeObserver(update);
    card.querySelectorAll("[data-entry]").forEach((row) => observer.observe(row));
    observer.observe(frame);
    return () => observer.disconnect();
  }, [expanded, phase]);

  const stop = () => setPaused(true);
  return (
    <main data-changelog-demo data-autoplay={s.autoplay} data-playing={playing} data-phase={phase} className="flex min-h-svh w-full items-center justify-center bg-background px-4 py-3 text-foreground">
      <div ref={stage} className="relative flex h-[594px] w-full max-w-[672px] flex-col">
        <div onPointerMoveCapture={stop} onPointerDownCapture={stop} onKeyDownCapture={stop}>
          <ChangelogTimeline ref={table} entries={CHANGELOG_DEMO_ENTRIES} visibleEntries={s.visibleEntries} expanded={expanded} onExpandedChange={setExpanded} pinned={pinned} onPinnedChange={setPinned}
            status={s.liveStatus ? { ref: "main", commit: "c99be8d", deployedAt, note: "Demo build" } : undefined}
            historyHref="https://github.com/DHxWhy/CCgather/commits/main" />
        </div>
        <span data-demo-pointer aria-hidden="true" className="pointer-events-none absolute left-0 top-0 z-10 text-foreground transition-transform duration-200 ease-out motion-reduce:transition-none" style={{ transform: `translate(${pointer.x}px, ${pointer.y}px)`, opacity: phase > 0 && !paused && !reduced ? 1 : 0 }}><MousePointer2 className="size-5 fill-card drop-shadow-sm" strokeWidth={1.5} /></span>
        <footer className="mt-auto flex min-h-7 items-center justify-between gap-2 px-1 text-[10px] text-muted-foreground">
          <p>Demo snapshot · From <a href={CCGATHER_URL} target="_blank" rel="noreferrer" className="rounded underline underline-offset-4 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">CCgather</a></p>
          {s.autoplay && !reduced && <button type="button" data-demo-playback onClick={() => {
            if (playing) stop();
            else { setExpanded(null); setPinned(null); setPhase(0); setPaused(false); setFinished(false); setRun((value) => value + 1); }
          }} className="flex items-center gap-1.5 rounded px-1 py-1 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">{playing ? <Pause aria-hidden="true" className="size-3" /> : <RotateCcw aria-hidden="true" className="size-3" />}{playing ? "Pause demo" : "Replay demo"}</button>}
        </footer>
      </div>
    </main>
  );
}

Dependencies

More by DHxWhy

View all
  • Sun Moon Dark Mode Theme Toggle

    Sun Moon Dark Mode Theme Toggle

    186
  • Country Flags Globe

    Country Flags Globe

    110
  • Live Notification Feed

    Live Notification Feed

    109
  • Airplane Window Gallery

    Airplane Window Gallery

    95
  • Domino Scroll Image Gallery

    Domino Scroll Image Gallery

    93
  • QR Code Scan Login Form with Password Fallback

    QR Code Scan Login Form with Password Fallback

    82
  • Constellation Image Gallery

    Constellation Image Gallery

    80
  • Ferris Wheel Image Gallery

    Ferris Wheel Image Gallery

    73