# Changelog Section v3

> Two-column changelog with a bracket timeline of long-form dates on the left and, on the right, entry title, description, and a contributor avatar group; entries sharing a date stack under one timeline node.

A shadcn/ui block from the [@uiception](https://uiception.com/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/uiception/changelog-section-v3).

- Page, with a live preview: https://shadcn.exchange/r/uiception/changelog-section-v3
- Screenshot: https://shadcn.exchange/shot/uiception/changelog-section-v3/light.png
- Registry item: https://uiception.com/r/changelog-section-v3.json
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/uiception/changelog-section-v3.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- Registry items: `https://uiception.com/r/block-media.json`, `avatar`

## Files

### `registry/new-york/blocks/changelog-section-v3/page.tsx`

```tsx
import ChangelogSectionV3 from "./components/changelog-section-v3"

export default function Page() {
  return <ChangelogSectionV3 />
}
```

### `registry/new-york/blocks/changelog-section-v3/components/changelog-section-v3.tsx`

```tsx
import ChangelogGroup from "./changelog-group"
import { sectionMeta } from "../lib/changelog-content"
import { changelogGroups } from "../lib/changelog-format"

export default function ChangelogSectionV3() {
  return (
    <section className="py-4 md:py-6 lg:py-8">
      <div className="mx-auto max-w-6xl px-4">
        <h2 className="text-3xl font-medium tracking-tight sm:text-4xl">
          {sectionMeta.title}
        </h2>

        <div className="mt-12 sm:mt-16">
          {changelogGroups.map((group, index) => (
            <ChangelogGroup
              key={group.date}
              group={group}
              isFirst={index === 0}
            />
          ))}
        </div>
      </div>
    </section>
  )
}
```

### `registry/new-york/blocks/changelog-section-v3/components/changelog-group.tsx`

```tsx
import Link from "next/link"

import ContributorGroup from "./contributor-group"
import type { ChangelogDateGroup } from "../lib/changelog-format"

/**
 * One date node on the timeline: a bracket rail + date on the left, and every
 * entry published on that date stacked on the right. Two columns from `sm` up;
 * on mobile the date sits above its entries with the rail still on the left.
 * The first node closes at the elbow (nothing above it); the rest run edge to
 * edge so the rail is continuous down the list.
 */
export default function ChangelogGroup({
  group,
  isFirst = false,
}: {
  group: ChangelogDateGroup
  isFirst?: boolean
}) {
  return (
    <div className="relative pl-7 sm:grid sm:grid-cols-[11rem_1fr] sm:gap-x-12 sm:pl-0">
      {/* vertical rail */}
      <span
        aria-hidden
        className={`absolute bottom-0 left-0 w-px bg-border ${
          isFirst ? "top-[0.45rem] sm:top-[1.45rem]" : "top-0"
        }`}
      />
      {/* elbow — the right angle into the date (offset on sm to match the entry's top padding) */}
      <span
        aria-hidden
        className="absolute top-[0.45rem] left-0 h-px w-4 bg-border sm:top-[1.45rem] sm:w-6"
      />

      <time
        dateTime={group.date}
        className="block text-sm leading-tight whitespace-nowrap text-muted-foreground sm:pt-4 sm:pl-9"
      >
        {group.label}
      </time>

      <div className="mt-2 space-y-8 pb-14 sm:mt-0 sm:space-y-10 sm:pb-24">
        {group.entries.map((entry) => (
          <article key={entry.id} className="-mx-4 sm:-mx-6">
            <Link
              href="#"
              target="_blank"
              rel="noreferrer"
              className="block cursor-pointer rounded-none px-4 py-4 transition-colors hover:bg-muted/40 sm:px-6"
            >
              <h3 className="text-xl font-medium tracking-tight text-balance text-foreground sm:text-2xl">
                {entry.title}
              </h3>
              <p className="mt-3 max-w-2xl leading-relaxed text-muted-foreground">
                {entry.description}
              </p>
              <div className="mt-5">
                <ContributorGroup contributors={entry.contributors} />
              </div>
            </Link>
          </article>
        ))}
      </div>
    </div>
  )
}
```

### `registry/new-york/blocks/changelog-section-v3/components/contributor-group.tsx`

```tsx
import {
  Avatar,
  AvatarFallback,
  AvatarGroup,
  AvatarImage,
} from "@/components/ui/avatar"

import type { Contributor } from "../lib/changelog-content"
import {
  MAX_VISIBLE_CONTRIBUTORS,
  formatContributorNames,
} from "../lib/changelog-format"

export default function ContributorGroup({
  contributors,
}: {
  contributors: Contributor[]
}) {
  // Never more than three avatars — anyone past that is covered by the names
  // label ("…and 2 others"), so there is no "+N" count bubble.
  const visible = contributors.slice(0, MAX_VISIBLE_CONTRIBUTORS)

  return (
    <div className="flex items-center gap-3">
      <AvatarGroup className="shrink-0">
        {visible.map((person) => (
          <Avatar key={person.id} size="sm">
            <AvatarImage
              src={person.avatarSrc}
              alt={person.name}
              className="object-cover object-center"
            />
            <AvatarFallback>{person.initials}</AvatarFallback>
          </Avatar>
        ))}
      </AvatarGroup>
      <span className="min-w-0 text-sm font-medium text-foreground">
        {formatContributorNames(contributors)}
      </span>
    </div>
  )
}
```

### `registry/new-york/blocks/changelog-section-v3/lib/changelog-content.ts`

```ts
import { createBlockImage } from "@/lib/block-media"

const blockAvatar = createBlockImage("changelog-section-v3")

export type Contributor = {
  id: string
  name: string
  initials: string
  avatarSrc: string
}

export type ChangelogEntry = {
  id: string
  /** ISO date (YYYY-MM-DD). Entries that share a date render under one timeline node. */
  date: string
  title: string
  description: string
  contributors: Contributor[]
}

export const sectionMeta = {
  title: "Changelog",
}

const people = {
  claire: {
    id: "claire-donovan",
    name: "Claire Donovan",
    initials: "CD",
    avatarSrc: blockAvatar("avatar-1.png"),
  },
  ethan: {
    id: "ethan-morrison",
    name: "Ethan Morrison",
    initials: "EM",
    avatarSrc: blockAvatar("avatar-2.png"),
  },
  mei: {
    id: "mei-lin-chen",
    name: "Mei Lin Chen",
    initials: "MC",
    avatarSrc: blockAvatar("avatar-3.png"),
  },
  jordan: {
    id: "jordan-ellis",
    name: "Jordan Ellis",
    initials: "JE",
    avatarSrc: blockAvatar("avatar-4.png"),
  },
  hannah: {
    id: "hannah-bergstrom",
    name: "Hannah Bergstrom",
    initials: "HB",
    avatarSrc: blockAvatar("avatar-5.png"),
  },
  ryan: {
    id: "ryan-park",
    name: "Ryan Park",
    initials: "RP",
    avatarSrc: blockAvatar("avatar-6.png"),
  },
} satisfies Record<string, Contributor>

export const changelog: ChangelogEntry[] = [
  {
    id: "composable-tool-sdk",
    date: "2026-09-02",
    title: "Composable Tool SDK",
    description:
      "Our new tool primitives let you build and deploy AI coding agents faster. Mix, chain, and extend them to fit however your team works.",
    contributors: [people.claire, people.ethan, people.mei],
  },
  {
    id: "deep-codebase-context",
    date: "2026-09-02",
    title: "Deep Codebase Context",
    description:
      "Your agents can now reason across multiple repositories, branches, and file trees all within a single session.",
    contributors: [people.jordan, people.hannah],
  },
  {
    id: "intelligent-model-routing",
    date: "2026-08-21",
    title: "Intelligent Model Routing",
    description:
      "Agents now pick the right model for each task on their own, so you get the best balance of speed, cost, and quality without lifting a finger.",
    contributors: [people.ryan, people.mei, people.claire, people.ethan],
  },
]
```

### `registry/new-york/blocks/changelog-section-v3/lib/changelog-format.ts`

```ts
import type { ChangelogEntry, Contributor } from "./changelog-content"
import { changelog } from "./changelog-content"

export type ChangelogDateGroup = {
  date: string
  /** Display label, e.g. "2 September 2026". */
  label: string
  entries: ChangelogEntry[]
}

const MONTHS = [
  "January",
  "February",
  "March",
  "April",
  "May",
  "June",
  "July",
  "August",
  "September",
  "October",
  "November",
  "December",
]

/** "2026-09-02" -> "2 September 2026". Returns the raw input unchanged if it
 *  isn't a valid YYYY-MM-DD date. */
export function formatChangelogDate(iso: string): string {
  const parts = iso.split("-")
  if (parts.length !== 3) return iso
  const [year, month, day] = parts.map(Number)
  if (
    !Number.isInteger(year) ||
    !Number.isInteger(month) ||
    !Number.isInteger(day) ||
    month < 1 ||
    month > 12 ||
    day < 1 ||
    day > 31
  ) {
    return iso
  }
  return `${day} ${MONTHS[month - 1]} ${year}`
}

/**
 * Names line under an entry. Up to two names are spelled out; everyone else is
 * folded into "and N other(s)" — e.g. "Jeremy Dopkin, Elliot Dauber, and 1 other".
 */
export function formatContributorNames(contributors: Contributor[]): string {
  const names = contributors.map((person) => person.name)
  if (names.length <= 2) return names.join(", ")
  const rest = names.length - 2
  return `${names[0]}, ${names[1]}, and ${rest} other${rest === 1 ? "" : "s"}`
}

/** Collapse a flat entry list into date groups, newest date first. */
export function groupEntriesByDate(
  entries: ChangelogEntry[]
): ChangelogDateGroup[] {
  const byDate = new Map<string, ChangelogEntry[]>()
  for (const entry of entries) {
    const existing = byDate.get(entry.date)
    if (existing) existing.push(entry)
    else byDate.set(entry.date, [entry])
  }

  return [...byDate.entries()]
    .sort(([a], [b]) => (a < b ? 1 : a > b ? -1 : 0))
    .map(([date, dateEntries]) => ({
      date,
      label: formatChangelogDate(date),
      entries: dateEntries,
    }))
}

export const changelogGroups = groupEntriesByDate(changelog)

/** Max avatars rendered before the "+N" count bubble takes over. */
export const MAX_VISIBLE_CONTRIBUTORS = 3
```

### `public/images/blocks/changelog-section-v3/avatar-1.png.gitkeep`

```gitkeep
Place your asset here. Same base name, any supported format: jpg, jpeg, png, webp, avif, gif, or svg. Remove the .gitkeep extension from the filename.
```

### `public/images/blocks/changelog-section-v3/avatar-2.png.gitkeep`

```gitkeep
Place your asset here. Same base name, any supported format: jpg, jpeg, png, webp, avif, gif, or svg. Remove the .gitkeep extension from the filename.
```

### `public/images/blocks/changelog-section-v3/avatar-3.png.gitkeep`

```gitkeep
Place your asset here. Same base name, any supported format: jpg, jpeg, png, webp, avif, gif, or svg. Remove the .gitkeep extension from the filename.
```

### `public/images/blocks/changelog-section-v3/avatar-4.png.gitkeep`

```gitkeep
Place your asset here. Same base name, any supported format: jpg, jpeg, png, webp, avif, gif, or svg. Remove the .gitkeep extension from the filename.
```

### `public/images/blocks/changelog-section-v3/avatar-5.png.gitkeep`

```gitkeep
Place your asset here. Same base name, any supported format: jpg, jpeg, png, webp, avif, gif, or svg. Remove the .gitkeep extension from the filename.
```

### `public/images/blocks/changelog-section-v3/avatar-6.png.gitkeep`

```gitkeep
Place your asset here. Same base name, any supported format: jpg, jpeg, png, webp, avif, gif, or svg. Remove the .gitkeep extension from the filename.
```
