Skip to contentVibraUI
Feedback & status

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/changelog

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
entriesChangelogEntry[]—One block each, newest first.
entries[].versionstring—Shown in a monospaced outline badge; left off for an unversioned note.
entries[].dateDate | string—The release date, rendered on the left rail.
entries[].titleReact.ReactNode—What shipped, in one line.
entries[].descriptionReact.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[].itemsstring[]—The line-by-line notes, as a bulleted list under the description.
entries[].hrefstring—Turns the title into a link to the full release note.

Dependencies

Source

components/ui/changelog.tsx
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 }