Skip to content
goodtabs UI
BrowseBrowse components

Release card

A changelog entry with a version eyebrow, a title, grouped changes and a link.

npx shadcn@latest add https://ui.goodtabs.co/r/release-card.json

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
Read the notes

Version and date, a title, a body, two change groups and a link.

Timeline

  1. 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
  2. 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.

Read the notes

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

PropTypeDefaultDescription
versionstringThe version, shown first in the mono eyebrow.
datestringThe date, already formatted, shown after the version.
titlestringThe headline of the release.
childrenReactNodeShort body under the title.
changes{ kind: "added" | "changed" | "fixed" | "removed"; items: string[] }[]Changes grouped by kind. Each group gets a small mono label.
hrefstringLink to the full notes, shown as a plain arrow link.

Source

release-card/release-card.tsx
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