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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/changelog-list.jsonPrompt
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/changelogpage 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 realidand 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 andidPrefixnamespaces the whole list. - Injected now — "3 days ago" comes from a
todayprop, never fromnew Date()during render, so server and client agree on the first paint; the day arithmetic runs in an explicittimeZone, so "yesterday" is a calendar day rather than a 24-hour window. - Progressive reveal —
maxVisiblekeeps 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.
Session List
Signed-in devices with per-row and bulk sign-out — the current session is highlighted and never revocable, failures keep the row, and staleness is shown in words, not just colour.
Tree Map
A squarified treemap that tiles a hierarchy by value — nested groups, click-to-drill with a breadcrumb, rank-based shading and keyboard-reachable DOM tiles.