---
title: Changelog
description: 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.
slug: changelog
seoTitle: React changelog component with unread dots
tier: free
category: Text
dependencies: [motion]
exports: [Changelog, Release, ChangelogLabels, ChangelogProps]
---

# Changelog

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](/docs/troubleshooting).
- **Licence:** MIT. Use it in any project, commercial or not.
- **Paid access:** None. No account and no key.

## Installation

### shadcn

```bash
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 · <date of the previous 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

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `releases` | `Release[]` | required | Newest first. Releases can be added at any time; new ones show up unread. |
| `storageKey` | `string` | `'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. |
| `initialSeen` | `string` | only the newest is unread | Release 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. |
| `anchorPrefix` | `string` | `''` | 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. |
| `labels` | `Partial<ChangelogLabels>` | `English` | UI strings. See [ChangelogLabels](#changeloglabels). English defaults; see `ChangelogLabels`. |
| `dwell` | `number` | `2500` | ms 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. |
| `locale` | `string` | visitor's locale | BCP 47 locale for dates. Defaults to the visitor's locale; pass it when rendering on the server. |
| `filters` | `boolean` | `true` | Show tag filter chips with counts. Hidden when no release has tags. |
| `maxFilters` | `number` | `8` | Tag 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. |
| `className` | `string` | — | Extra class on the root `<section class="cl-root">`. |
| `style` | `CSSProperties` | — | 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

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `version` | `string` | required | Shown 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). |
| `date` | `string` | required | ISO 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). |
| `title` | `string` | required | Release heading. |
| `tags` | `string[]` | — | 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. |
| `body` | `ReactNode` | — | Release notes, rendered as given (paragraphs, lists…). |
| `id` | `string` | `"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

| Key | Default | Where |
| --- | --- | --- |
| `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. |
| `moreFilters` | ``n => `${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. |
| `tag` | `t => t` | Display 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

| Key | Action |
| --- | --- |
| `Tab` | Move through "Mark all as read", the filter chips and each release's version link. |
| `Enter` / `Space` | Press 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

```text
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
```
