Changelog
A release feed with a left date rail, a monospaced version badge, and toned tags.
Server-compatible: no hooks, no client boundary, and no clock — every date comes from the entry. Tags render in a fixed order (new, improved, fixed, removed) whatever order they arrive in, so two releases with the same mix of changes read the same way down the page. Each tag carries its word as well as its colour, so it needs no icon of its own, and every date is stamped as a machine-readable time element. The list owns the columns and every entry lays out on them as a subgrid, so the date column is as wide as the widest date in the list and the notes start on one line all the way down; give the list one column — grid-cols-1, or max-sm:grid-cols-1 for a phone — and each date sits over its release instead.
Install
npx shadcn@latest add @vibra/changelogNeeds the @vibra registry in your components.json — set it up once.
Examples
import { Changelog, type ChangelogEntry } from "@/components/ui/changelog"
const ENTRIES: ChangelogEntry[] = [
{
version: "2.6.0",
date: "2026-09-02T12:00:00.000Z",
title: "Scheduled report delivery",
description: "Send any saved view to a mailing list on a cadence you choose.",
tags: ["new", "improved"],
items: [
"Daily, weekly, and monthly cadences",
"Per-recipient time zones",
"CSV and PDF attachments",
],
href: "#v2-6-0",
},
{
version: "2.5.2",
date: "2026-08-21T12:00:00.000Z",
title: "Cohort queries run about four times faster",
description:
"The warehouse connector now pushes filters down instead of loading a full period into memory.",
tags: ["improved", "fixed"],
href: "#v2-5-2",
},
{
version: "2.5.0",
date: "2026-08-04T12:00:00.000Z",
title: "Workspace roles",
description: "Invite people as admins, editors, or viewers, and change a role at any time.",
tags: ["new", "removed"],
items: ["The single owner seat is gone; existing owners became admins."],
href: "#v2-5-0",
},
]
export default function ChangelogDemo() {
return <Changelog className="w-full max-w-lg" entries={ENTRIES} />
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| entries | ChangelogEntry[] | — | One block each, newest first. |
| entries[].version | string | — | Shown in a monospaced outline badge; left off for an unversioned note. |
| entries[].date | Date | string | — | The release date, rendered on the left rail. |
| entries[].title | React.ReactNode | — | What shipped, in one line. |
| entries[].description | React.ReactNode | — | A sentence or two under the title. |
| entries[].tags | ("new" | "improved" | "fixed" | "removed")[] | — | Toned badges beside the version: new is green, improved blue, fixed amber, removed grey. |
| entries[].items | string[] | — | The line-by-line notes, as a bulleted list under the description. |
| entries[].href | string | — | Turns the title into a link to the full release note. |
Dependencies
Registry
Source
import * as React from "react"
import { cn } from "@/lib/utils"
import { formatDate } from "@/lib/format"
import { Badge } from "@/components/ui/badge"
/** What a release did to the product. */
export type ChangelogTag = "new" | "improved" | "fixed" | "removed"
const TAG_LABELS: Record<ChangelogTag, string> = {
new: "New",
improved: "Improved",
fixed: "Fixed",
removed: "Removed",
}
// COUPLED TO registry/vibra/ui/badge.tsx: outline is the one variant with no
// background of its own, which is what lets a tone supply one. Each class here
// cancels one the variant sets — border-border, and text-foreground. The tag
// always carries its word as well as its colour, so it needs no icon of its own.
const TAG_TONES: Record<ChangelogTag, string> = {
new: "border-transparent bg-success-muted text-success",
improved: "border-transparent bg-info-muted text-info",
fixed: "border-transparent bg-warning-muted text-warning",
removed: "border-transparent bg-muted text-muted-foreground",
}
const TAG_ORDER: ChangelogTag[] = ["new", "improved", "fixed", "removed"]
export type ChangelogEntry = {
/** Shown in a monospaced badge beside the tags; left off for an untagged release. */
version?: string
date: Date | string
title: React.ReactNode
description?: React.ReactNode
tags?: ChangelogTag[]
/** The line-by-line notes, rendered as a bulleted list under the description. */
items?: string[]
/** Turns the title into a link to the full release note. */
href?: string
}
export type ChangelogProps = React.ComponentProps<"div"> & {
entries: ChangelogEntry[]
}
function Changelog({ className, entries, ...props }: ChangelogProps) {
return (
<div
data-slot="changelog"
// The list owns the columns — its widest date, then the notes — and
// every entry lays out on them as a subgrid, so the notes start on one
// line all the way down. Given one column (grid-cols-1), each date sits
// over its release instead.
className={cn("grid w-full grid-cols-[max-content_minmax(0,1fr)] gap-x-6 divide-y", className)}
{...props}
>
{entries.map((entry, index) => {
const date = entry.date instanceof Date ? entry.date : new Date(entry.date)
// Sorted into one order whatever order they arrive in, so two releases
// that shipped the same mix of changes read the same way down the page.
const tags = entry.tags?.length
? TAG_ORDER.filter((tag) => entry.tags?.includes(tag))
: []
return (
<div
key={index}
data-slot="changelog-entry"
className="col-span-full grid grid-cols-subgrid gap-y-2 py-5 first:pt-0 last:pb-0"
>
<time
data-slot="changelog-date"
dateTime={date.toISOString()}
className="pt-0.5 text-xs tabular-nums text-muted-foreground"
>
{formatDate(date, "medium", { timeZone: "UTC" })}
</time>
<div data-slot="changelog-body" className="flex min-w-0 flex-col gap-2">
{entry.version || tags.length > 0 ? (
<div data-slot="changelog-badges" className="flex flex-wrap items-center gap-1.5">
{entry.version ? (
<Badge
data-slot="changelog-version"
variant="outline"
className="font-mono tabular-nums"
>
{entry.version}
</Badge>
) : null}
{tags.map((tag) => (
<Badge
key={tag}
data-slot="changelog-tag"
data-tag={tag}
variant="outline"
className={TAG_TONES[tag]}
>
{TAG_LABELS[tag]}
</Badge>
))}
</div>
) : null}
{/* A div rather than a heading, the way CardTitle and EmptyTitle
are: the right heading level belongs to the page around it. */}
<div data-slot="changelog-title" className="text-sm font-medium text-pretty">
{entry.href ? (
<a
href={entry.href}
className="rounded-sm hover:underline focus-ring"
>
{entry.title}
</a>
) : (
entry.title
)}
</div>
{entry.description ? (
<p
data-slot="changelog-description"
className="text-sm text-pretty text-muted-foreground"
>
{entry.description}
</p>
) : null}
{entry.items?.length ? (
<ul
data-slot="changelog-items"
className="flex list-disc flex-col gap-1 ps-4 text-sm text-muted-foreground marker:text-muted-foreground/50"
>
{entry.items.map((item, itemIndex) => (
<li key={itemIndex}>{item}</li>
))}
</ul>
) : null}
</div>
</div>
)
})}
</div>
)
}
export { Changelog }