A product changelog with a version rail, date chips, tag badges, grid-rows expandable entries, and a subscribe input with confirmation swap.
npx shadcn@latest add @paragon/changelog-sectionAlso installs: button
"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/registry/paragon/ui/button";
/* ------------------------------------------------------------------ */
type Tag = "New" | "Improved" | "Fixed";
const TAG_STYLES: Record<Tag, string> = {
New: "bg-emerald-600/10 text-emerald-700 dark:bg-emerald-400/10 dark:text-emerald-400",
Improved: "bg-sky-600/10 text-sky-700 dark:bg-sky-400/10 dark:text-sky-400",
Fixed:
"bg-amber-600/10 text-amber-700 dark:bg-amber-400/10 dark:text-amber-400",
};
interface Entry {
version: string;
date: string;
title: string;
tags: Tag[];
summary: string;
details: string[];
}
const ENTRIES: Entry[] = [
{
version: "v2.16",
date: "Jun 30, 2026",
title: "Preview environments are generally available",
tags: ["New", "Improved"],
summary:
"Every pull request now gets an isolated, shareable environment with production-parity data seeding.",
details: [
"Preview environments now provision in a median of 41 seconds, down from 3 minutes.",
"Seeded databases are scrubbed of PII automatically before copying.",
"New environment lifecycle policies: auto-sleep after 48h idle, auto-delete on merge.",
"The environments list gained per-branch filtering and a status column.",
],
},
{
version: "v2.15",
date: "Jun 16, 2026",
title: "Faster builds and smarter caching",
tags: ["Improved", "Fixed"],
summary:
"Remote build caching is now content-addressed, cutting warm build times roughly in half.",
details: [
"Docker layer caching now survives base-image bumps when lockfiles are unchanged.",
"Fixed a race where concurrent deploys to the same environment could interleave migrations.",
"Build logs stream with 4× less latency on large monorepos.",
],
},
{
version: "v2.14",
date: "Jun 2, 2026",
title: "Audit log export and SCIM provisioning",
tags: ["New", "Fixed"],
summary:
"Enterprise workspaces can stream audit events to S3 or Datadog and manage seats via SCIM 2.0.",
details: [
"Audit export supports JSON Lines with per-event HMAC signatures.",
"SCIM provisioning maps IdP groups to workspace roles.",
"Fixed timezone drift in scheduled deploy windows for non-UTC workspaces.",
],
},
];
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
/* ------------------------------------------------------------------ */
function EntryCard({ entry, defaultOpen }: { entry: Entry; defaultOpen?: boolean }) {
const [open, setOpen] = React.useState(!!defaultOpen);
const bodyId = React.useId();
return (
<article className="relative pb-10 pl-8 last:pb-0 sm:pl-10">
{/* Rail dot */}
<span
aria-hidden
className="absolute top-1.5 left-0 flex size-[13px] items-center justify-center"
>
<span className="size-[9px] rounded-full border-2 border-background bg-muted-foreground/60 ring-1 ring-border" />
</span>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
<span className="font-mono text-xs font-medium text-muted-foreground">
{entry.version}
</span>
<time className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground tabular-nums">
{entry.date}
</time>
<span className="flex gap-1.5">
{entry.tags.map((tag) => (
<span
key={tag}
className={cn(
"rounded-full px-1.5 py-0.5 text-[11px] font-medium",
TAG_STYLES[tag],
)}
>
{tag}
</span>
))}
</span>
</div>
<h3 className="mt-2 text-base font-semibold tracking-tight">
{entry.title}
</h3>
<p className="mt-1 text-sm text-pretty text-muted-foreground">
{entry.summary}
</p>
{/* Expandable body via grid-template-rows */}
<div
id={bodyId}
className={cn(
"grid transition-[grid-template-rows] duration-[250ms] [transition-timing-function:var(--ease-out)] motion-reduce:transition-none",
open ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
)}
>
<div className="min-h-0 overflow-hidden">
<ul
className={cn(
"mt-3 space-y-1.5 border-l-2 border-border pl-4 text-sm text-muted-foreground",
"transition-opacity duration-200 ease-out",
open ? "opacity-100" : "opacity-0",
)}
>
{entry.details.map((detail) => (
<li key={detail} className="text-pretty">
{detail}
</li>
))}
</ul>
</div>
</div>
<button
type="button"
aria-expanded={open}
aria-controls={bodyId}
onClick={() => setOpen((o) => !o)}
className={cn(
"relative mt-2.5 inline-flex items-center gap-1 rounded-sm text-[13px] font-medium text-muted-foreground",
"transition-[color] duration-150 ease-out hover:text-foreground",
"outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"after:absolute after:top-1/2 after:left-1/2 after:h-10 after:w-[calc(100%+16px)] after:-translate-1/2",
)}
>
{open ? "Hide details" : "Show details"}
<ChevronDown
aria-hidden
className={cn(
"size-3.5 transition-transform duration-200 ease-out",
open && "rotate-180",
)}
/>
</button>
</article>
);
}
export interface ChangelogSectionProps extends React.ComponentProps<"section"> {
/** Product name in the heading. */
productName?: string;
}
/**
* A product changelog: version entries on a vertical rail with date chips
* and tag badges, entry bodies that expand via grid-template-rows, and a
* subscribe input whose button blur-swaps to a confirmation.
*/
export function ChangelogSection({
productName = "Relay",
className,
...props
}: ChangelogSectionProps) {
const reducedMotion = useReducedMotion();
const inputId = React.useId();
const [email, setEmail] = React.useState("");
const [subscribed, setSubscribed] = React.useState(false);
return (
<section
aria-label={`${productName} changelog`}
className={cn("w-full max-w-2xl", className)}
{...props}
>
<header className="flex flex-wrap items-end justify-between gap-x-8 gap-y-4">
<div>
<h2 className="text-xl font-semibold tracking-tight">Changelog</h2>
<p className="mt-1 text-sm text-muted-foreground">
New features, improvements, and fixes to {productName}.
</p>
</div>
<form
className="flex w-full items-center gap-2 sm:w-auto"
onSubmit={(e) => {
e.preventDefault();
if (EMAIL_RE.test(email.trim())) setSubscribed(true);
}}
>
<label htmlFor={inputId} className="sr-only">
Email address
</label>
<input
id={inputId}
type="email"
placeholder="you@company.com"
value={email}
disabled={subscribed}
onChange={(e) => setEmail(e.target.value)}
className={cn(
"h-8 w-full min-w-0 flex-1 rounded-lg border border-input bg-transparent px-3 text-sm outline-none sm:w-52",
"transition-[border-color,box-shadow,opacity] duration-150 ease-out",
"placeholder:text-muted-foreground disabled:opacity-60",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
)}
/>
<Button
type="submit"
size="sm"
className="w-[6.5rem] shrink-0"
disabled={subscribed}
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={subscribed ? "done" : "subscribe"}
initial={
reducedMotion
? { opacity: 0 }
: { opacity: 0, scale: 0.5, filter: "blur(4px)" }
}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={
reducedMotion
? { opacity: 0 }
: { opacity: 0, scale: 0.5, filter: "blur(4px)" }
}
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
className="inline-flex items-center gap-1.5"
>
{subscribed ? (
<>
<Check aria-hidden className="size-3.5" />
Subscribed
</>
) : (
"Subscribe"
)}
</motion.span>
</AnimatePresence>
</Button>
</form>
</header>
{/* Rail */}
<div className="relative mt-8">
<span
aria-hidden
className="absolute top-1.5 bottom-1.5 left-1.5 w-px bg-border"
/>
{ENTRIES.map((entry, i) => (
<EntryCard key={entry.version} entry={entry} defaultOpen={i === 0} />
))}
</div>
</section>
);
}