Skip to content
goodtabs UI
BrowseBrowse components

Changelog section

A dated release log with a version, a title and short notes per entry.

npx shadcn@latest add https://ui.goodtabs.co/r/changelog-section.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/changelog-section

Changelog

What shipped

  1. v2.4

    Keyboard first tables

    • Arrow keys move between cells
    • Row selection keeps focus after a sort
  2. v2.3

    Dark mode tokens

    • Hairlines are translucent in both themes
  3. v2.2

    Faster command palette

A heading and three releases, newest first.

Dates and titles only

  1. Table cells take focus with the arrow keys

  2. Translucent hairlines in dark mode

  3. Command palette opens in under 50ms

  4. New date range picker

Entries without notes or versions.

Full width

Changelog

Every release, newest first

  1. v2.4

    Keyboard first tables

    • Arrow keys move between cells
    • Row selection keeps focus after a sort
  2. v2.3

    Dark mode tokens

    • Hairlines are translucent in both themes
  3. v2.2

    Faster command palette

  4. v2.1

    Form fields

    • Inline errors announce politely
    • Labels stay visible while typing
    • Number fields clamp on blur

Wider column, four releases and a longer list of notes.

Usage

import { ChangelogSection } from "@/components/changelog-section"

<ChangelogSection
  eyebrow="Changelog"
  title="What shipped"
  entries={[{ date: "2026-11-03", version: "v2.4", title: "Keyboard first tables", items: ["Arrow keys move between cells"] }]}
/>

Props

PropTypeDefaultDescription
eyebrowstringSmall uppercase line above the title.
titleReactNodeThe section heading.
entries{ date: string; version?: string; title: string; items?: string[] }[]Newest first. date is ISO and also sets the time element.
size"sm" | "default""default"Spacing scale. sm fits a card.

Source

changelog-section/changelog-section.tsx
import * as React from "react";
import { cn } from "@/lib/utils";

export type ChangelogEntry = {
  /** ISO date, for example 2026-11-05. Drives the label and the machine readable time. */
  date: string;
  /** Version or release name, shown in mono next to the date. */
  version?: string;
  /** What shipped, in a few words. */
  title: string;
  /** Short bullet lines. */
  items?: string[];
};

type ChangelogSectionProps = Omit<React.ComponentProps<"section">, "title"> & {
  /** Small uppercase line above the title. */
  eyebrow?: string;
  /** The section heading. */
  title?: React.ReactNode;
  /** Newest first. */
  entries: ChangelogEntry[];
  /** Spacing scale. "sm" fits a card or narrow column. */
  size?: "sm" | "default";
};

const formatter = new Intl.DateTimeFormat("en-GB", {
  day: "numeric",
  month: "short",
  year: "numeric",
  timeZone: "UTC",
});

function formatDate(iso: string) {
  const d = new Date(iso);
  return Number.isNaN(d.getTime()) ? iso : formatter.format(d);
}

/**
 * A dated release log: a heading, then one row per release with the date on the left and the notes on the right.
 * Server component. Entries are data, so nothing here needs state.
 */
export function ChangelogSection({
  eyebrow,
  title,
  entries,
  size = "default",
  className,
  ...props
}: ChangelogSectionProps) {
  const sm = size === "sm";
  return (
    <section className={cn(sm ? "py-8" : "py-16 lg:py-24", className)} {...props}>
      <div className={cn("mx-auto w-full px-4 md:px-6", sm ? "max-w-xl" : "max-w-3xl")}>
        {(eyebrow || title) && (
          <header className={cn("flex flex-col gap-2", sm ? "mb-6" : "mb-10")}>
            {eyebrow && (
              <p className="m-0 font-mono text-xs tracking-wide text-muted-foreground uppercase">{eyebrow}</p>
            )}
            {title && (
              <h2 className={cn("m-0 font-medium tracking-tight text-balance", sm ? "text-xl" : "text-3xl")}>
                {title}
              </h2>
            )}
          </header>
        )}
        <ol className="m-0 flex list-none flex-col p-0">
          {entries.map((entry) => (
            <li
              key={`${entry.date}-${entry.title}`}
              className={cn(
                "grid gap-x-8 gap-y-2 border-t py-5 first:border-t-0 first:pt-0 sm:grid-cols-[8rem_1fr]",
                sm && "sm:grid-cols-[6.5rem_1fr]",
              )}
            >
              <div className="flex flex-row items-baseline gap-2 sm:flex-col sm:gap-0.5">
                <time dateTime={entry.date} className="font-mono text-xs text-muted-foreground">
                  {formatDate(entry.date)}
                </time>
                {entry.version && <span className="font-mono text-xs text-foreground/70">{entry.version}</span>}
              </div>
              <div className="flex min-w-0 flex-col gap-2">
                <h3 className="m-0 text-base font-medium tracking-tight">{entry.title}</h3>
                {entry.items && entry.items.length > 0 && (
                  <ul className="m-0 flex list-none flex-col gap-1 p-0 text-sm text-muted-foreground">
                    {entry.items.map((item) => (
                      <li key={item} className="flex gap-2">
                        <span aria-hidden="true" className="mt-2 h-px w-2 shrink-0 bg-border" />
                        <span>{item}</span>
                      </li>
                    ))}
                  </ul>
                )}
              </div>
            </li>
          ))}
        </ol>
      </div>
    </section>
  );
}

For AI agents

Hand your agent this page, the markdown at /components/changelog-section/prompt.md, or the Copy AI prompt button above.

When to use

  • A public release log on a product or docs site
  • When dates matter and readers scan for what is new

When not to use

  • For one announcement, use an announcement bar
  • For long release notes with screenshots, link to a post

Agent notes

  • Server component with no state
  • Dates are ISO strings formatted in UTC as 5 Nov 2026, so server and client agree
  • The section carries py-16 lg:py-24 and a max-w-3xl column; place it directly in main