Display

Changelog List

User-facing release notes — one block per version, entries grouped by kind with an icon and a word, a sticky version rail on wide containers, and real permalinks per release.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  ArrowUpRight,
  ChevronDown,
  CircleSlash,
  Link2,
  Minus,
  Pencil,
  Plus,
  ShieldAlert,
  Sparkles,
  Wrench,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/changelog-list.json

Prompt

Build a React + TypeScript + Tailwind "ChangelogList" component with
lucide-react icons.

Contract
- ChangelogEntry: { kind: "added" | "changed" | "deprecated" | "removed" |
  "fixed" | "security"; text: ReactNode }.
- ChangelogRelease: { version: string; date: string /* ISO 8601 */; title?:
  string; highlights?: ReactNode[]; entries: ChangelogEntry[]; tag?: "major" |
  "minor" | "patch"; url?: string }.
- Props: releases: ChangelogRelease[]; today?: string | number | Date;
  maxVisible = 10; latestBadge?: ReactNode = "Latest" (pass null to drop it);
  locale = "en-US"; timeZone = "UTC"; idPrefix?: string; headingLevel: 2|3|4 =
  3; stickyTop = "1rem"; emptyState?: ReactNode; className. forwardRef to the
  root div, remaining props spread onto it.
- Deliberately no zod contract and no `status` prop: this component fetches
  nothing. `releases.length === 0` IS the empty branch; loading and fetch
  failure belong to whatever renders around it.

Behavior
- Releases are sorted newest-first internally, so the caller can hand over any
  order and "latest" is always well defined. The sort is stable, so two
  releases cut on the same day keep the caller's order (1.4.1 above 1.4.0). An
  unparseable date sinks to the bottom instead of poisoning the comparator with
  NaN, and prints the raw string it was given — never "Invalid Date".
- Each release renders as an <article id={version}> whose aria-labelledby
  points at the version heading (plus the title element when there is one). Use
  <article>, NOT <section aria-labelledby>: a named <section> is a `region`
  landmark, and forty of them turn landmark navigation into a version list.
- The anchor is real: the version heading contains an <a href={"#" + version}>,
  so a release can be linked to directly. Repeated version strings get -2, -3
  suffixes so two anchors never collide; whitespace inside a version is dashed
  out; a blank version falls back to release-{index}. `idPrefix` namespaces
  every id for pages that render more than one changelog. The article carries
  scroll-margin-top: stickyTop so a fixed site header never covers the target.
- Entries are bucketed by `kind` and rendered in Keep a Changelog's canonical
  order (added, changed, deprecated, removed, fixed, security) no matter what
  order they arrived in. Each group is a labelled <ul role="list"> whose label
  is an ICON PLUS A WORD — kind must never be carried by colour alone, and on a
  monochrome palette it could not be anyway. A kind outside the union still
  renders, under a trailing "Other" group: a silently dropped entry is worse
  than a mislabelled one.
- `highlights` render above the grouped entries as the release's summary — the
  two or three sentences a reader should see before the itemised list.
- The version rail (version + date + tag + latest badge + optional release-notes
  link) sticks to the top on wide containers through a CONTAINER query
  (@container/changelog, threshold 36rem), not a viewport breakpoint: the same
  list is a full-width /changelog page and a 360px in-app "What's new" panel.
  Below the threshold the grid collapses to one column and the rail is simply a
  stacked heading. The rail sticks inside its own grid area (align-self: start),
  so it tracks this release's entries and is pushed out by the next block — it
  can never overlap a neighbour, and nothing from the entries column ever
  scrolls underneath it.
- Relative dates are opt-in and pure: pass `today` and every release gains a
  "3 days ago" / "yesterday" / "2 months ago" annotation next to its absolute
  date; omit it and no annotation renders. NEVER call new Date() during render —
  server and client would disagree on the first paint. A `today` that does not
  parse simply disables the annotation instead of throwing.
- Day arithmetic runs on whole days derived from Intl.DateTimeFormat
  .formatToParts in the `timeZone` prop, so "yesterday" is a calendar fact and
  not a 24-hour window, and a date-only string ("2026-03-04") reads as March 4
  for every reader instead of March 3 west of UTC.
- `maxVisible` caps the rendered releases; one click on "Show N earlier
  versions" reveals ALL of the rest — the label then states the exact truth
  instead of promising more than one click delivers — and moves focus to the
  first release it revealed, because the button unmounts itself. maxVisible is
  clamped to >= 1 (0 would hide everything behind a button) and a non-finite
  value means "no cap".
- Ownership chain: <ol role="list"> → <li role="listitem"> for releases, and a
  labelled <ul role="list"> → <li role="listitem"> inside each kind group and
  inside the highlights block. The explicit roles matter because
  `list-style: none` makes WebKit drop the implicit list semantics.

Rendering & styling
- Semantic tokens only: text-foreground for entry copy, text-muted-foreground
  for dates, group labels and the reveal button, bg-primary +
  text-primary-foreground for the Latest badge, border + bg-muted for the tag
  chip, bg-muted/40 inside the highlights box, border-dashed for the empty
  state, focus-visible:ring-2 ring-ring on every link and button. Never stack an
  extra opacity onto muted text — text-muted-foreground on bg-muted is already
  only 4.54:1.
- Every column is min-w-0 and entry text uses break-words: a flex/grid child
  defaults to min-width:auto, so one unbreakable identifier or signed URL would
  otherwise push the whole block wider than its card.
- The permalink icon fades in on hover/focus with transition-opacity plus
  motion-reduce:transition-none — the link behaves identically without motion.
- cn() merges the consumer className into the root.

Customization levers
- Density: the gap between releases (gap-10), the rail width
  (minmax(0,10.5rem)) and the sticky threshold (@xl → @lg / @2xl) are each a
  single token to change.
- Kind vocabulary: KIND_META and KIND_ORDER are the only two places that decide
  which categories exist, what they are called and in what order they read —
  swap icons, rename "Fixed" to "Bug fixes", or add a project-specific kind
  there. The "Other" fallback keeps unknown kinds visible while you migrate.
- Emphasis: pass latestBadge={null} on an internal page, omit `tag` to hide
  semver weight, or set headingLevel={2} on a dedicated /changelog route so the
  document outline stays correct.
- Sticky offset: `stickyTop` drives both the rail's top and the anchor's
  scroll-margin-top — set it to your header height once and both follow.
- Chrome: the rail is intentionally transparent so the component inherits the
  surface it is dropped on; add bg-card + border to make each release read as a
  card instead.
- Reuse: `maxVisible` plus a wrapper with a max-height and overflow-y-auto
  turns the same component into an in-app "What's new" panel, and the sticky
  rail then follows that scroll container.

Concepts

  • Version rail — the left column carrying the version number, its date, the semver tag and the permalink. It sticks inside its own grid area, so it tracks one release and is handed off to the next instead of floating over the whole list, and nothing from the entries column ever scrolls under it.
  • Container-query degradation — the two-column-to-stacked switch is driven by the component's own width (@container, 36rem), not the viewport, so the same list is correct as a full-width /changelog page and as a 360px in-app panel sitting beside a wide sidebar.
  • Kind grouping — entries arrive in any order and are bucketed into Keep a Changelog's six categories, each labelled with an icon and a word. Colour alone never encodes kind; an unrecognised kind lands in a trailing "Other" group instead of disappearing.
  • Deep-linkable release — every release is an <article> with a real id and a heading that is an <a href="#1.4.0">, so "fixed in 1.4.0" can be a link. Duplicate versions get numeric suffixes and idPrefix namespaces the whole list.
  • Injected now — "3 days ago" comes from a today prop, never from new Date() during render, so server and client agree on the first paint; the day arithmetic runs in an explicit timeZone, so "yesterday" is a calendar day rather than a 24-hour window.
  • Progressive revealmaxVisible keeps a long history short; one click expands everything that is left, and focus lands on the first newly revealed release because the button that had focus removes itself.

On This Page