Skip to content
TextChangelog
Text

ChangelogFree

A release list that remembers what each visitor has read, with unread dots, a "since your last visit" divider, dwell-to-read and tag filters.

Worst case

Changelog with the data that usually breaks a component: long German words, Arabic right to left, odd numbers, empty and broken values. It runs live here, so try it. Set the frame to 320 px to see a small phone.

Loading the worst case…

Also checked with reduced motion turned on, in dark mode, and against stored 320 px screenshots with a pixel diff before every release.

Use Changelog for a release list that shows each visitor what is new since their last visit.

A "What's new" list that knows what each visitor hasn't read. Releases hang off a rail on the left: a measuring tape with versions as the major ticks (the default), or a plain line with a node per version. Releases new since the last visit carry a "New to you" chip under their version and an unread dot, and a "Before your last visit" divider heads the older ones, like the "new messages" line in a chat. A release that stays on screen for a moment is marked read and its dot fades; "Mark all as read" clears the rest in a sweep down the list while the count rolls to zero. Tag chips filter the list. Read state is stored per visitor in localStorage, and every UI string can be replaced through labels.

Quick answers

  • Dependencies: react, react-dom and motion 12 or newer. Nothing else.
  • React version: 19 or newer.
  • SSR and 'use client': Renders on the server. The file starts with 'use client', so the Next.js App Router can import it straight into a Server Component. In the Pages Router, Vite, React Router and Astro that line does nothing. See Troubleshooting.
  • Licence: MIT. Use it in any project, commercial or not.
  • Paid access: None. No account and no key.

Installation

shadcn

npx shadcn@latest add https://brioui.com/r/changelog.json

Manual

  1. Copy changelog.tsx into your project, for example components/brio/changelog.tsx.
  2. Install the peer dependency: npm install motion.
  3. Requires React 19. The theme tokens (--ui-*) are optional; the component falls back to its own defaults.
  4. The file starts with 'use client', so it can be imported straight from Next.js App Router server components.

Usage

TSX
import { Changelog, type Release } from '@/components/brio/changelog'

const releases: Release[] = [
  {
    version: '2.4.0',
    date: '2026-09-22',
    title: 'Saved views for reports',
    tags: ['New'],
    body: <p>Save any combination of filters and columns as a view, then share it with your team.</p>,
  },
  {
    version: '2.3.2',
    date: '2026-09-15',
    title: 'Faster search on large workspaces',
    tags: ['Improved', 'Fixed'],
    body: <p>Search results load up to three times faster in workspaces with many projects.</p>,
  },
]

export function WhatsNew() {
  return <Changelog releases={releases} />
}

On a first visit only the newest release is unread. After that, anything the visitor hasn't read is unread, and the divider reads "Since your last visit · ".

Examples

Choose what a first visit sees as new

initialSeen takes a release's version or its id. On a first visit, releases above it start unread; it and everything below count as seen. "2.3.0", "v2.3.0" and the id "v2-3-0" all point at the same release (ids default to "v" + the version with dots turned into dashes).

TSX
import { Changelog, type Release } from '@/components/brio/changelog'

export function ProductUpdates({ releases }: { releases: Release[] }) {
  return <Changelog releases={releases} storageKey="acme:changelog" initialSeen="2.3.0" />
}

Releases loaded async, with server rendering

Read state waits until releases is non-empty, so an empty first render doesn't mark everything seen. Pass locale when rendering on the server so dates match on hydration. While releases is empty the component shows its empty state (labels.empty), so show your own loading placeholder until the first fetch resolves.

TSX
import { useEffect, useState } from 'react'
import { Changelog, type Release } from '@/components/brio/changelog'

type ApiRelease = { version: string; date: string; title: string; tags: string[]; notes: string[] }

export function RemoteChangelog() {
  const [releases, setReleases] = useState<Release[]>([])
  const [loaded, setLoaded] = useState(false)

  useEffect(() => {
    fetch('/api/releases')
      .then(r => r.json() as Promise<ApiRelease[]>)
      .then(list =>
        setReleases(
          list.map(r => ({
            version: r.version,
            date: r.date,
            title: r.title,
            tags: r.tags,
            body: (
              <ul>
                {r.notes.map(n => (
                  <li key={n}>{n}</li>
                ))}
              </ul>
            ),
          })),
        ),
      )
      .finally(() => setLoaded(true))
  }, [])

  if (!loaded) return <p>Loading updates…</p>
  return <Changelog releases={releases} locale="en-US" />
}

Line rail, no filters, faster reading

rail="line" draws a plain 1px rail with a node per release instead of the default measuring tape. filters={false} hides the tag chips. dwell sets how long a release must stay on screen to count as read.

TSX
import { Changelog, type Release } from '@/components/brio/changelog'

export function CompactChangelog({ releases }: { releases: Release[] }) {
  return <Changelog releases={releases} rail="line" filters={false} dwell={1200} />
}

Stable ids for anchors and storage

Set id when versions may be renamed. The id is the anchor (#id) and the key saved as read. When two changelogs on one page could share ids, give each an anchorPrefix so their anchors stay unique (heading ids used for aria-labelledby are already scoped per instance).

TSX
import { Changelog, type Release } from '@/components/brio/changelog'

const releases: Release[] = [
  { id: 'api-2026-09', version: '2026.09', date: '2026-09-01T09:00:00Z', title: 'Webhooks v2', tags: ['New'] },
  { id: 'api-2026-08', version: '2026.08', date: '2026-08-04T09:00:00Z', title: 'Rate limit headers', tags: ['Improved'] },
]

export function ApiChangelog() {
  // anchors become #api-api-2026-09, #api-api-2026-08
  return <Changelog releases={releases} storageKey="acme:api-changelog" anchorPrefix="api-" />
}

Labels

Every fixed string can be replaced; pass only the ones you need.

TSX
import { Changelog, type Release } from '@/components/brio/changelog'

// Stand-in release data.
const releases: Release[] = [
  { version: '2.4.0', date: '2026-10-01', title: 'Faster sync', tags: ['New'], body: 'Sync is faster.' },
  { version: '2.3.0', date: '2026-09-12', title: 'Dark mode fixes', tags: ['Fixed'], body: 'Contrast fixed.' },
]

export function Example() {
  return (
    <Changelog
      releases={releases}
      labels={{
        region: 'Nouveautés',
        newSince: 'nouveautés depuis votre dernière visite',
        caughtUp: 'Vous êtes à jour',
        markAll: 'Tout marquer comme lu',
        filters: 'Filtrer les versions',
        all: 'Tout',
        unread: 'Non lu :',
        sinceLastVisit: 'Avant votre dernière visite',
        newToYou: 'Nouveau pour vous',
      }}
      locale="fr-FR"
    />
  )
}

Props

Changelog

PropTypeDefaultDescription
releasesRelease[]requiredNewest first. Releases can be added at any time; new ones show up unread.
storageKeystring'changelog'localStorage key. The value is { seen: string[], at: number, visit: number }: the ids read, when that list was last saved, and when the latest visit started. sessionStorage key `${storageKey}:last-visit` pins the previous visit's date for the rest of the browser session, so reloads don't move the divider. localStorage key for the visitor's read state.
initialSeenstringonly the newest is unreadRelease version ('2.3.0' or 'v2.3.0') or id ('v2-3-0'). On a first visit, releases newer than it start unread; it and older ones are seen. A value matching no release makes every release unread. Accepts the release id / slug ("v2-3-0") or its version ("2.3.0", "v2.3.0"). Default: only the newest is unread.
anchorPrefixstring''Prepended to each release's anchor id and link (#${anchorPrefix}${id}). Leave empty for plain deep links (#v2-3-0); set it when two changelogs on one page share release ids.
labelsPartial<ChangelogLabels>EnglishUI strings. See ChangelogLabels. English defaults; see ChangelogLabels.
dwellnumber2500ms a release must stay on screen (60% visible) to count as read. ms a release must stay at least 60% visible to be marked read.
localestringvisitor's localeBCP 47 locale for dates. Defaults to the visitor's locale; pass it when rendering on the server.
filtersbooleantrueShow tag filter chips with counts. Hidden when no release has tags.
maxFiltersnumber8Tag chips shown before the rest fold behind an "N more" chip (labels.moreFilters), which expands them all and becomes "Fewer". The "All" chip and the active tag are always shown. Order: New, Improved, Fixed, then by number of releases, then alphabetically. New, Improved and Fixed come first, then by release count.
rail'line' | 'tape''tape''tape': a measuring-tape rail with versions as major ticks. 'line': a 1px rail with a node per release.
classNamestring—Extra class on the root <section class="cl-root">.
styleCSSProperties—Inline styles on the root. Use it to set the --changelog-* variables.

Changelog is also the default export.

With an empty releases array the component shows labels.empty. Note that read state is only loaded once there are releases, so async data can start as [] (the empty state shows until it arrives).

Read state is stored as the set of ids seen, so removing or renaming old releases never makes other releases look unread. Marking releases read updates at but never visit, so "Since your last visit" always dates from the start of the previous visit, not from the last thing read. Read state saved before ids dropped a leading "v" from versions (vv2-3-0) is still recognised. If storage is blocked, read state lasts for the page view only.

Release

FieldTypeDefaultDescription
versionstringrequiredShown as v + version. A version that already starts with v or V ('v2.4.0') is shown as given. Version string, shown as "v" + version ("v" is only added when it doesn't already start with v / V).
datestringrequiredISO date. A plain YYYY-MM-DD is a calendar day, formatted in UTC so it never shows as the day before. A full timestamp is shown in the visitor's time zone. A date that doesn't exist ('2026-02-29') or can't be parsed is left out, with a one-time console warning in development. Years before 100 ('0001-01-01') are kept as written. A plain "YYYY-MM-DD" is treated as a calendar day (no time zone shift). Invalid dates are left out (dev warning).
titlestringrequiredRelease heading.
tagsstring[]—Tag chips and filters. New, Improved and Fixed (any case) get their own colour and icon; other tags get a neutral chip. Filters are case-insensitive: New and new are one chip (labelled with the first spelling in the list) and one count. Case-insensitive duplicates on one release (['fixed', 'FIXED']) show once; empty tags are ignored. A long tag is truncated with the full text as a tooltip.
bodyReactNode—Release notes, rendered as given (paragraphs, lists…).
idstring"v" + version (minus a leading v), non-alphanumerics → -Anchor + storage id. Defaults to "v" + version (minus any leading v / V) with every run of non-alphanumerics turned into "-" ("2.3.0" and "v2.3.0" → "v2-3-0"). 2.3.0 and v2.3.0 both become v2-3-0. Must be unique within one changelog.

ChangelogLabels

KeyDefaultWhere
region'Changelog'Accessible name of the root <section>.
newSince'new since your last visit'After the unread count in the status line.
caughtUp'You’re all caught up'Status line with nothing unread.
markAll'Mark all as read'Button label and tooltip.
filters'Filter releases'Accessible name of the filter chip group.
all'All'First filter chip.
unread'Unread:'Visually hidden text before an unread release's title.
sinceLastVisit'Before your last visit'Heading of the divider above the releases from before the last visit, followed by · <date> once there was a previous visit.
newToYou'New to you'Chip under the version of each release that came out since the last visit.
moreFiltersn => `${n} more` Chip that reveals the tag filters folded by maxFilters. A function of the hidden count.
fewerFilters'Fewer'The same chip once expanded.
empty'No releases yet'Shown (with an icon, and no status line or filters) when releases is empty.
tagt => tDisplay text of a tag, e.g. t => ({ New: 'Nouveau', Improved: 'Amélioré' })[t] ?? t. Translates the chip without changing its colour, icon or filter, which follow the tag as written in releases.

Customisation

  • Variants: none. The rail prop switches between the line and tape rails.
  • Theme tokens: --ui-ink (text, rail), --ui-accent (unread dots, divider, "New" tag, focus rings), --ui-surface (rail node fill), --ui-ok ("all caught up" icon, "Fixed" tag), --ui-info ("Improved" tag), --ui-font-mono (versions, dates, tags). Corners: --ui-radius-sm (buttons, fields, rows and tags, scaled from it), --ui-radius-pill (chips, switches, badges and round buttons). Dots and avatars stay round.
  • Component CSS variables: --changelog-ink, --changelog-accent, --changelog-surface, --changelog-ok, --changelog-info, --changelog-mono override the matching token for this component only. Defaults: #17140f, #d4452b, #fbf8f1, #2f7d4f, #3f7fbf, ui-monospace, Menlo, monospace.
  • Class names: className goes on .cl-root, which carries data-rail. Stable classes: .cl-head, .cl-status, .cl-markall, .cl-filters, .cl-chip (with aria-pressed), .cl-chip-more (the "N more" / "Fewer" chip, with aria-expanded), .cl-list, .cl-rel (one release, with data-unread), .cl-mark (version and date column), .cl-version, .cl-fresh (the "New to you" chip), .cl-entry, .cl-title, .cl-dot, .cl-tags, .cl-tag (with data-tone="new" | "improved" | "fixed" | "other"), .cl-body, .cl-divider, .cl-empty (the empty state; the root then carries data-empty).
  • Labels: labels replaces every UI string (see ChangelogLabels). Tag text comes from your data.

Responsive behaviour

  • Under 400px of width the version and date sit on one line above each entry, so release notes get the full width.
  • Expanded tag filters scroll inside a short box there.
  • Release titles are capped at 70 characters per line.
  • On touch screens the chips, mark-all button and version permalinks have 44px targets.

Keyboard

KeyAction
TabMove through "Mark all as read", the filter chips and each release's version link.
Enter / SpacePress the focused button: mark all as read, or switch filter.
Enter (on a version link)Follow the #id anchor (#${anchorPrefix}${id}) to that release.

Accessibility

  • The root is a <section aria-label="Changelog"> (labels.region) with an ordered list. Each release is an <article> labelled by its <h3> title (heading ids are scoped per instance with useId), with a <time dateTime>.
  • Unread releases have visually hidden "Unread:" text before the title.
  • The status line ("3 new since your last visit" / "You’re all caught up") is a polite live region.
  • Filter chips are aria-pressed buttons in a group labelled "Filter releases".
  • On screens 560 px wide or less, and wherever the component itself is 440 px wide or less (a container query on .cl-root, e.g. in a sidebar or card), the rail narrows and "Mark all as read" shrinks to its icon and keeps its accessible name. A long status line wraps instead of being squeezed; a long "Mark all as read" label truncates, with the full text as a tooltip.
  • Long content stays inside the component: long versions truncate (full version in the tooltip), titles, links and hashes in the notes wrap, <pre> blocks scroll sideways, and titles and notes get dir="auto" so right-to-left text lays out correctly.
  • Reduced motion: dots, entries and the divider appear and disappear with a fade, "Mark all as read" clears at once with no sweep, and the count jumps instead of rolling.

Agent prompt

Agent prompt
Add the Brio UI "Changelog" component to this project.
1. Run `npx shadcn@latest add https://brioui.com/r/changelog.json`.
2. Make sure `motion` is installed and React is 19 or newer.
3. Import `Changelog` from the file the CLI created and render it with `releases` (newest first: { version, date, title, tags?, body?, id? }[]) and a project-specific `storageKey`.
4. Set `initialSeen` to the version (e.g. "2.3.0") or id of the last release first-time visitors should treat as already seen; pass `locale` if the page is server-rendered.
Full docs: https://brioui.com/docs/components/changelog.md
Free set rolling out now

Pro launches in November

Changelog is free to copy today, one of 10 free components rolling out now. The other 40 are Pro: plan comparisons, onboarding checklists, notification inboxes and more, all tested against the same worst cases. Founding members get 50% off Pro for life.

No payment. Just three emails: welcome, launch, Pro access.