# Expand Card: Changelog With Links

> Each post has its own link. Opening one updates the URL, the back button closes it, and loading a post's link opens it directly. The notes load while the cover grows.

A shadcn/ui block from the [@keyframeui](https://keyframeui.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/keyframeui/expand-card-changelog-demo).

- Page, with a live preview: https://shadcn.exchange/r/keyframeui/expand-card-changelog-demo
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/keyframeui/expand-card-changelog-demo-light-421a0cea79dbdd5d.png
- Registry item: https://keyframeui.com/r/expand-card-changelog-demo.json
- Categories: card, gesture, motion
- Listed: 2026-10-14, source last changed 2026-10-15

## Install

```bash
npx shadcn@latest add https://keyframeui.com/r/expand-card-changelog-demo.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- Registry items: `https://keyframeui.com/r/expand-card.json`

## Notes from the registry

Live preview, examples and props: https://keyframeui.com/components/expand-card#expand-card-changelog-demo

## Files

### `registry/demos/expand-card-changelog-demo.tsx`

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion } from "motion/react";

import { ExpandCard } from "@/components/ui/expand-card";

type Release = {
  slug: string;
  version: string;
  date: string;
  title: string;
  summary: string;
  tone: string;
  glyph: string;
  intro: string;
  notes: { heading: string; items: string[] }[];
};

const releases: Release[] = [
  {
    slug: "relay-2-4",
    version: "2.4",
    date: "Oct 2, 2026",
    title: "Shared inboxes",
    summary: "One address, the whole team, nobody answering twice.",
    tone: "from-zinc-600 to-zinc-900",
    glyph: "M3 13h5l2 3h4l2-3h5M5 5h14l2 8v6H3v-6z",
    intro:
      "Support, sales and billing can now share one inbox. Everyone sees the same threads, and Relay shows who's replying so two people never answer the same customer.",
    notes: [
      { heading: "New", items: ["Shared inboxes for any address on your domain", "Live “typing” indicator on threads someone else has open", "Assign a thread with a single keystroke: A"] },
      { heading: "Improved", items: ["Threads load twice as fast on large inboxes", "Mentions now notify on every device you're signed in on"] },
      { heading: "Fixed", items: ["Attachments over 20 MB no longer fail silently"] },
    ],
  },
  {
    slug: "relay-2-3",
    version: "2.3",
    date: "Sep 4, 2026",
    title: "Snooze, rethought",
    summary: "Put a thread away until it's actually useful again.",
    tone: "from-amber-400 to-orange-600",
    glyph: "M12 7v5l3 2M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z",
    intro:
      "Snooze now understands more than times. Bring a thread back when someone replies, when a date in it arrives, or the next time you open your laptop.",
    notes: [
      { heading: "New", items: ["Snooze until a reply, a date, or your next session", "A Later view that shows everything waiting to come back"] },
      { heading: "Improved", items: ["Natural-language times: “Monday morning”, “in 3 days”", "Snoozed threads keep their place in shared inboxes"] },
    ],
  },
  {
    slug: "relay-2-2",
    version: "2.2",
    date: "Aug 7, 2026",
    title: "Keyboard everything",
    summary: "Every action, two keys away. Press ? to see them all.",
    tone: "from-emerald-400 to-teal-700",
    glyph: "M3 6h18v12H3zM7 10h.01M11 10h.01M15 10h.01M7 14h10",
    intro:
      "Everything you can click, you can now reach from the keyboard. Shortcuts are listed in a sheet you open with ?, and you can rebind any of them.",
    notes: [
      { heading: "New", items: ["A shortcut for every action, listed under ?", "Rebind any shortcut in Settings → Keyboard", "Vim-style J and K to move between threads"] },
      { heading: "Fixed", items: ["Escape now closes the composer without losing the draft"] },
    ],
  },
  {
    slug: "relay-2-1",
    version: "2.1",
    date: "Jul 10, 2026",
    title: "Search in a blink",
    summary: "Results as you type, across every inbox you can see.",
    tone: "from-sky-400 to-blue-700",
    glyph: "M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14ZM20 20l-4-4",
    intro:
      "Search was rebuilt from the index up. Results arrive as you type, filters stack, and attachments are searched by their contents, not just their names.",
    notes: [
      { heading: "New", items: ["Filters: from:, has:attachment, before: and after:", "Search inside PDFs and documents"] },
      { heading: "Improved", items: ["First results in under 50 ms on most inboxes"] },
    ],
  },
];

// Stands in for fetching each post's notes. The cover animates straight away,
// since it's already on the card; the notes arrive while it grows.
const loaded = new Map<string, Promise<Release>>();
function load(release: Release) {
  let promise = loaded.get(release.slug);
  if (!promise) {
    promise = new Promise((resolve) => setTimeout(() => resolve(release), 650));
    loaded.set(release.slug, promise);
  }
  return promise;
}

export function ExpandCardChangelogDemo() {
  const [current, setCurrent] = React.useState<string | null>(null);

  return (
    <div className="w-full overflow-hidden rounded-xl border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-950">
      <div className="h-[var(--demo-height,36rem)] overflow-y-auto overscroll-contain [scroll-padding-top:3.5rem]">
        <AddressBar hash={current ? `#${current}` : ""} />

        <div className="mx-auto max-w-2xl px-5 pt-10 pb-16 sm:px-8">
          <p className="font-mono text-xs tracking-widest text-zinc-500 uppercase">Changelog</p>
          <h2 className="mt-2 text-3xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
            What&apos;s new in Relay
          </h2>
          <p className="mt-2 text-sm text-pretty text-zinc-500">
            Every post has its own link. Open one, then use the back button to close it.
          </p>

          <ol className="mt-10 space-y-8">
            {releases.map((release) => (
              <li key={release.slug} className="grid gap-3 sm:grid-cols-[6.5rem_minmax(0,1fr)]">
                <time className="pt-1 font-mono text-xs text-zinc-500 tabular-nums">{release.date}</time>
                <ExpandCard
                  id={release.slug}
                  width={680}
                  className="h-44 rounded-2xl shadow-[0_10px_30px_-14px_rgb(0_0_0/0.45)]"
                  articleClassName="bg-white text-zinc-900 dark:bg-zinc-950 dark:text-zinc-50"
                  onOpenChange={(open) =>
                    setCurrent((slug) => (open ? release.slug : slug === release.slug ? null : slug))
                  }
                  cover={<ReleaseCover release={release} />}
                >
                  <ReleaseNotes release={release} />
                </ExpandCard>
              </li>
            ))}
          </ol>
        </div>
      </div>
    </div>
  );
}

function AddressBar({ hash }: { hash: string }) {
  return (
    <div className="sticky top-0 z-10 flex items-center gap-3 border-b border-zinc-200/80 bg-white/80 px-4 py-2.5 backdrop-blur-md dark:border-zinc-800/80 dark:bg-zinc-950/80">
      <div aria-hidden className="flex gap-1.5">
        {[0, 1, 2].map((dot) => (
          <span key={dot} className="size-2.5 rounded-full bg-zinc-200 dark:bg-zinc-800" />
        ))}
      </div>
      <p className="flex min-w-0 flex-1 items-center gap-1.5 rounded-lg bg-zinc-100 px-3 py-1.5 font-mono text-xs text-zinc-500 dark:bg-zinc-900">
        <svg aria-hidden viewBox="0 0 24 24" className="size-3 shrink-0" fill="none" stroke="currentColor" strokeWidth={2.25} strokeLinecap="round">
          <path d="M6 11h12v9H6zM8.5 11V8a3.5 3.5 0 0 1 7 0v3" />
        </svg>
        <span className="truncate">
          relay.app/changelog
          <AnimatePresence initial={false} mode="popLayout">
            {hash ? (
              <motion.span
                key={hash}
                initial={{ opacity: 0, x: -4 }}
                animate={{ opacity: 1, x: 0 }}
                exit={{ opacity: 0 }}
                transition={{ duration: 0.2, ease: "easeOut" }}
                className="inline-block text-zinc-900 dark:text-zinc-100"
              >
                {hash}
              </motion.span>
            ) : null}
          </AnimatePresence>
        </span>
      </p>
    </div>
  );
}

function ReleaseCover({ release }: { release: Release }) {
  return (
    <div className={`relative flex size-full flex-col justify-between overflow-hidden bg-linear-to-br p-5 text-white ${release.tone}`}>
      <svg
        aria-hidden
        viewBox="0 0 24 24"
        className="absolute -right-6 -bottom-8 size-44 text-white/15"
        fill="none"
        stroke="currentColor"
        strokeWidth={1.5}
        strokeLinecap="round"
        strokeLinejoin="round"
      >
        <path d={release.glyph} />
      </svg>
      <p className="relative w-fit rounded-full bg-white/20 px-2.5 py-0.5 font-mono text-xs font-medium backdrop-blur-sm">
        Relay {release.version}
      </p>
      <div className="relative">
        <h3 className="text-[22px] leading-tight font-semibold tracking-tight">{release.title}</h3>
        <p className="mt-1 max-w-[19rem] text-sm leading-snug text-white/80">{release.summary}</p>
      </div>
    </div>
  );
}

function ReleaseNotes({ release }: { release: Release }) {
  const { intro, notes, date } = React.use(load(release));

  return (
    <div className="mx-auto max-w-[34rem] px-6 pt-6 pb-12 sm:px-8">
      <div className="flex items-center justify-between gap-4">
        <time className="font-mono text-xs text-zinc-500 tabular-nums">{date}</time>
        <CopyLink />
      </div>
      <p className="mt-4 text-[16px] leading-relaxed text-pretty text-zinc-600 dark:text-zinc-400">{intro}</p>
      {notes.map((section) => (
        <section key={section.heading} className="mt-8">
          <h4 className="font-mono text-xs tracking-widest text-zinc-500 uppercase">{section.heading}</h4>
          <ul className="mt-3 space-y-2.5">
            {section.items.map((item) => (
              <li key={item} className="flex gap-3 text-[15px] leading-snug text-zinc-800 dark:text-zinc-200">
                <span aria-hidden className="mt-[7px] size-1.5 shrink-0 rounded-full bg-zinc-300 dark:bg-zinc-700" />
                {item}
              </li>
            ))}
          </ul>
        </section>
      ))}
    </div>
  );
}

function CopyLink() {
  const [copied, setCopied] = React.useState(false);

  React.useEffect(() => {
    if (!copied) return;
    const timer = window.setTimeout(() => setCopied(false), 1600);
    return () => window.clearTimeout(timer);
  }, [copied]);

  return (
    <button
      type="button"
      onClick={() => {
        navigator.clipboard?.writeText(window.location.href).then(() => setCopied(true), () => {});
      }}
      className="flex items-center gap-1.5 rounded-full bg-zinc-100 px-3 py-1 text-xs font-medium text-zinc-700 transition-[background-color,scale] duration-150 ease-out hover:bg-zinc-200 active:scale-95 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:bg-zinc-800"
    >
      <svg aria-hidden viewBox="0 0 24 24" className="size-3.5" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
        <path d={copied ? "M5 12l5 5L20 7" : "M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"} />
      </svg>
      <span aria-live="polite">{copied ? "Copied" : "Copy link"}</span>
    </button>
  );
}
```
