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.
@keyframeui
registry/demos/expand-card-changelog-demo.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'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>
);
}
Live preview, examples and props: https://keyframeui.com/components/expand-card#expand-card-changelog-demo























