Release card
A changelog entry with a version eyebrow, a title, grouped changes and a link.
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/release-card
v1.4.029 Oct 2026
Saved items sync
Saves now follow you between devices. Local saves migrate on first sign in.
Added
- Saved items sync across devices
- A keyboard shortcut for the theme toggle
Fixed
- Search no longer loses focus after Escape
Version and date, a title, a body, two change groups and a link.
Timeline
v1.4.029 Oct 2026
Saved items sync
Saves now follow you between devices.
Added
- Saved items sync across devices
Changed
- New this week now covers seven days, not five
v1.3.221 Oct 2026
Faster search
The index is built once at deploy time and shipped with the page.
Fixed
- Results flashed empty on slow connections
- Cmd K opened twice on Firefox
Two releases on a hairline rail with a dot per entry.
Minimal
v1.3.117 Oct 2026
Maintenance release
Dependency updates and a smaller client bundle. Nothing changes for you.
No change groups, just the eyebrow, a title, a line and a link.
Usage
import { ReleaseCard } from "@/components/ui/release-card"
<ReleaseCard
version="v1.4.0"
date="29 Oct 2026"
title="Saved items sync"
changes={[{ kind: "added", items: ["Saves sync across devices"] }]}
href="/changelog/1-4-0"
>
<p>Saves now follow you between devices.</p>
</ReleaseCard>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| version | string | The version, shown first in the mono eyebrow. | |
| date | string | The date, already formatted, shown after the version. | |
| title | string | The headline of the release. | |
| children | ReactNode | Short body under the title. | |
| changes | { kind: "added" | "changed" | "fixed" | "removed"; items: string[] }[] | Changes grouped by kind. Each group gets a small mono label. | |
| href | string | Link to the full notes, shown as a plain arrow link. |
Source
import * as React from "react";
import { ArrowUpRightIcon } from "lucide-react";
import { cn } from "@/lib/utils";
type ChangeKind = "added" | "changed" | "fixed" | "removed";
export type ReleaseChanges = {
kind: ChangeKind;
items: string[];
};
type ReleaseCardProps = Omit<React.ComponentProps<"article">, "title"> & {
/** The version, for example "v1.4.0". */
version: string;
/** The date, already formatted, for example "29 Oct 2026". */
date: string;
/** The headline of the release. */
title: string;
/** Changes grouped by kind. Each group gets a small mono label. */
changes?: ReleaseChanges[];
/** Link to the full notes. */
href?: string;
/** Short body under the title. */
children?: React.ReactNode;
};
const KIND_LABEL: Record<ChangeKind, string> = {
added: "Added",
changed: "Changed",
fixed: "Fixed",
removed: "Removed",
};
/**
* A changelog entry: a mono eyebrow with the version and date, a title, a short body,
* changes grouped by kind and an optional link to the full notes.
*/
export function ReleaseCard({ version, date, title, changes, href, className, children, ...props }: ReleaseCardProps) {
const groups = changes?.filter((group) => group.items.length > 0) ?? [];
return (
<article
data-slot="release-card"
className={cn("surface-shine flex flex-col gap-3 rounded-lg border bg-card p-5 text-card-foreground", className)}
{...props}
>
<p className="m-0 flex items-center gap-2 font-mono text-xs tracking-wide text-muted-foreground uppercase">
<span className="text-foreground">{version}</span>
<span aria-hidden="true">/</span>
<span>{date}</span>
</p>
<div className="flex flex-col gap-1">
<h3 className="m-0 text-base leading-snug font-medium text-foreground">{title}</h3>
{children && <div className="text-sm text-muted-foreground">{children}</div>}
</div>
{groups.length > 0 && (
<div className="flex flex-col gap-3">
{groups.map((group) => (
<div key={group.kind} data-kind={group.kind} className="flex flex-col gap-1">
<p className="m-0 font-mono text-xs tracking-wide text-muted-foreground uppercase">
{KIND_LABEL[group.kind]}
</p>
<ul className="m-0 flex list-disc flex-col gap-0.5 pl-4 text-sm text-foreground/85 marker:text-muted-foreground">
{group.items.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
))}
</div>
)}
{href && (
<a
href={href}
className="inline-flex w-fit items-center gap-1 rounded-sm text-sm text-foreground underline-offset-4 transition-colors duration-100 ease-out outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring/50"
>
Read the notes
<ArrowUpRightIcon className="size-3.5" aria-hidden="true" />
</a>
)}
</article>
);
}
For AI agents
Hand your agent this page, the markdown at /components/release-card/prompt.md, or the Copy AI prompt button above.
When to use
- A changelog page, one card per release
- A what's new panel inside an app or a docs sidebar
- Release notes in an email or a blog post rendered as cards
When not to use
- A single line per release, where a table or a plain list scans faster
- Commit logs; the groups assume human written notes
- Marketing announcements that need an image or a call to action
Agent notes
- Server component. Format the date before passing it; the card does not know about locales
- Groups render in the order given and empty groups are skipped; kinds are added, changed, fixed and removed
- The link is a plain anchor; wrap or replace it with your router's Link by passing href and handling navigation yourself
- For a timeline, put cards in a list with border-l and a dot per item; see the timeline demo