Changelog
Changelog / release-notes sections — a version-rail timeline, a card grid, and a date-rail timeline with per-entry media.
Preview
@props([
'eyebrow' => 'Changelog',
'heading' => 'What\'s new',
'lead' => 'Follow along as we ship improvements, fixes, and new features every week.',
'entries' => null,
// Opt-in client-side filter: a search field above the timeline narrows the
// list to the changes whose text matches. Off by default (static markup).
'searchable' => false,
'searchPlaceholder' => 'Filter by item, prop or keyword…',
])
@php
// Each entry is one release. Two shapes are supported:
//
// 1. A single category per release (the original shape): `category`,
// `variant`, `title`, `body` and a flat `changes` list.
// 2. Typed sections per release (opt-in): `sections` — a list of
// ['title' => 'Added', 'variant' => 'soft-success', 'intro' => null,
// 'changes' => [...]] groups, rendered as badge + list, the shape a real
// Keep-a-Changelog release has. `title`/`body`/`category` become optional.
//
// Pass an empty string for `eyebrow`, `heading` and `lead` to drop the
// section header when the block sits under a page's own title.
//
// Optional keys on either shape: `id` (deep-link anchor for the release,
// defaults to a slug of the version) and `dateLabel` (defaults to `date`).
// A change may be a plain string (translated and escaped) or an Htmlable
// (rendered as-is — for release notes the app already converted from
// trusted Markdown).
$entries ??= [
[
'version' => 'v2.4.0', 'date' => '2026-06-10', 'dateLabel' => 'June 10, 2026',
'category' => 'Added', 'variant' => 'success',
'title' => 'Changelog block family & timeline layout',
'body' => 'Introduced a dedicated changelog section with a sticky version rail on large screens and a fully responsive stacked layout on mobile.',
'changes' => ['New changelog block family with a timeline layout.', 'Left-rail version and date column becomes sticky at large breakpoints.', 'Category tags use semantic badge variants.', 'Full RTL support via logical-property utilities.'],
],
[
'version' => 'v2.3.2', 'date' => '2026-05-28', 'dateLabel' => 'May 28, 2026',
'category' => 'Fixed', 'variant' => 'destructive',
'title' => 'Badge contrast & separator RTL fixes',
'body' => 'Resolved a pair of accessibility regressions: the destructive badge now meets WCAG 2.2 AA contrast, and the separator rendered on the wrong side in RTL documents.',
'changes' => ['Destructive badge foreground adjusted to meet a 4.5:1 contrast ratio.', 'Separator logical-property side restored for RTL layouts.'],
],
[
'version' => 'v2.3.0', 'date' => '2026-05-14', 'dateLabel' => 'May 14, 2026',
'category' => 'Improved', 'variant' => 'secondary',
'title' => 'Badge sizes & dark-mode polish',
'body' => 'Bumped the badge to v1.1.0 with an expanded size scale and unified token coverage. Dark-mode palettes were reviewed and tightened across the library.',
'changes' => ['Badge gains an expanded size scale via the shared styles map.', 'All badge variants use semantic tokens exclusively.', 'Dark-mode muted and accent surfaces re-mapped.'],
],
];
$renderChange = static fn ($change) => $change instanceof \Illuminate\Contracts\Support\Htmlable ? $change : __($change);
@endphp
<section
data-slot="changelog"
@if ($searchable)
x-data="{
query: '',
matches: null,
filter() {
const q = this.query.trim().toLowerCase();
let total = 0;
const releases = Array.from(this.$root.querySelectorAll('[data-slot=changelog-release]'));
releases.forEach((release) => {
const sectioned = release.querySelector('[data-slot=changelog-section]') !== null;
let visible = 0;
release.querySelectorAll('[data-slot=changelog-change]').forEach((change) => {
const haystack = (sectioned ? change : release).textContent.toLowerCase();
const hit = q === '' || haystack.includes(q);
change.hidden = ! hit;
if (hit) visible++;
});
release.querySelectorAll('[data-slot=changelog-section]').forEach((section) => {
const shownInSection = section.querySelectorAll('[data-slot=changelog-change]:not([hidden])').length;
section.hidden = shownInSection === 0;
const count = section.querySelector('[data-slot=changelog-count]');
if (count) count.textContent = shownInSection;
});
release.hidden = visible === 0;
total += visible;
});
// A divider shows only between two visible releases.
const shown = releases.filter((release) => ! release.hidden);
this.$root.querySelectorAll('[data-slot=changelog-divider]').forEach((divider) => {
const before = divider.previousElementSibling;
divider.hidden = ! before || before.hidden || shown.indexOf(before) === shown.length - 1;
});
this.matches = q === '' ? null : total;
},
}"
x-init="$watch('query', () => filter())"
@endif
{{ $attributes->merge(['class' => 'bg-background py-16 text-foreground sm:py-24']) }}
>
<div class="mx-auto max-w-3xl px-4">
@if ($eyebrow || $heading || $lead)
<div class="mb-12 text-center sm:mb-16">
@if ($eyebrow)
<x-ui.badge variant="outline" class="mb-4">{{ __($eyebrow) }}</x-ui.badge>
@endif
@if ($heading)
<h2 class="mt-2 text-3xl font-bold tracking-tight sm:text-4xl">{{ __($heading) }}</h2>
@endif
@if ($lead)
<p class="mt-4 text-base text-muted-foreground sm:text-lg">{{ __($lead) }}</p>
@endif
</div>
@endif
@if ($searchable)
<div data-slot="changelog-search" class="mb-6">
<x-ui.input type="search" autocomplete="off" x-model.debounce.120ms="query" :placeholder="__($searchPlaceholder)" :aria-label="__('Filter changes')" />
<p class="mt-2 min-h-6 text-sm text-muted-foreground" role="status" aria-live="polite" x-text="matches === null ? '' : (matches === 0 ? {{ Js::from(__('No changes match.')) }} : {{ Js::from(__(':count matching')) }}.replace(':count', matches))"></p>
</div>
@endif
<ol class="relative" aria-label="{{ __('Release history') }}">
@foreach ($entries as $index => $entry)
@php
$sections = $entry['sections'] ?? null;
$anchor = $entry['id'] ?? \Illuminate\Support\Str::slug((string) $entry['version']);
@endphp
@if ($index > 0)
<li aria-hidden="true" data-slot="changelog-divider" class="my-10"><x-ui.separator /></li>
@endif
{{-- In sections mode the release is a real heading (h2 → the
sections' h3), carries the anchor, and its rail stays in view
while a long release scrolls; the single-category shape keeps
its version badge. --}}
<li @if ($sections === null) id="{{ $anchor }}" @endif data-slot="changelog-release" class="flex scroll-mt-24 flex-col gap-6 lg:flex-row lg:gap-10">
<div class="flex shrink-0 flex-row flex-wrap items-center gap-4 lg:sticky lg:top-24 lg:w-40 lg:flex-col lg:items-start lg:gap-2 lg:self-start lg:pt-2">
<span aria-hidden="true" class="mt-2 hidden size-2 shrink-0 rounded-full bg-primary ring-4 ring-background lg:block"></span>
@if ($sections !== null)
<h2 id="{{ $anchor }}" class="scroll-mt-24 text-base font-semibold leading-snug"><a href="#{{ $anchor }}" class="hover:underline">{{ $entry['version'] }}</a></h2>
@else
<x-ui.badge as="a" href="#{{ $anchor }}" variant="outline" size="sm" class="shrink-0">{{ $entry['version'] }}</x-ui.badge>
@endif
@if (! empty($entry['date']))
<time datetime="{{ $entry['date'] }}" class="whitespace-nowrap text-sm text-muted-foreground">{{ __($entry['dateLabel'] ?? $entry['date']) }}</time>
@elseif (! empty($entry['dateLabel']))
<span class="min-w-0 text-sm text-muted-foreground">{{ __($entry['dateLabel']) }}</span>
@endif
</div>
<div class="min-w-0 flex-1 space-y-4 border-s-2 border-border ps-6 lg:border-s-0 lg:ps-0">
@if (! empty($entry['category']) || ! empty($entry['title']))
<div class="flex flex-wrap items-center gap-2">
@if (! empty($entry['category']))
<x-ui.badge :variant="$entry['variant'] ?? 'secondary'" size="sm">{{ __($entry['category']) }}</x-ui.badge>
@endif
@if (! empty($entry['title']))
<h3 class="text-base font-semibold leading-snug">{{ __($entry['title']) }}</h3>
@endif
</div>
@endif
@if (! empty($entry['body']))
<p class="text-sm leading-relaxed text-muted-foreground">{{ __($entry['body']) }}</p>
@endif
@if ($sections !== null)
<div class="space-y-8">
@foreach ($sections as $section)
<section data-slot="changelog-section">
<div class="flex items-center gap-2 text-sm">
<h3 class="font-semibold"><x-ui.badge :variant="$section['variant'] ?? 'secondary'" size="sm">{{ __($section['title']) }}</x-ui.badge></h3>
<span data-slot="changelog-count" class="text-muted-foreground">{{ count($section['changes'] ?? []) }}</span>
</div>
@if (! empty($section['intro']))
<div class="prose prose-sm mt-4 max-w-none text-foreground">{{ $renderChange($section['intro']) }}</div>
@endif
@if (! empty($section['changes']))
<ul role="list" class="mt-4 space-y-2">
@foreach ($section['changes'] as $change)
<li data-slot="changelog-change" class="flex items-start gap-2 text-sm text-muted-foreground">
<span aria-hidden="true" class="mt-2 size-2 shrink-0 rounded-full bg-primary"></span>
<span class="min-w-0 [&_a]:text-foreground [&_a]:underline-offset-4 [&_a:hover]:underline [&_code]:rounded-sm [&_code]:bg-muted [&_code]:px-0.5 [&_code]:font-mono [&_code]:text-foreground [&_strong]:font-semibold [&_strong]:text-foreground">{{ $renderChange($change) }}</span>
</li>
@endforeach
</ul>
@endif
</section>
@endforeach
</div>
@elseif (! empty($entry['changes']))
<ul role="list" class="space-y-2">
@foreach ($entry['changes'] as $change)
<li data-slot="changelog-change" class="flex items-start gap-2 text-sm text-muted-foreground">
<span aria-hidden="true" class="mt-2 size-2 shrink-0 rounded-full bg-primary"></span>
<span class="min-w-0">{{ $renderChange($change) }}</span>
</li>
@endforeach
</ul>
@endif
</div>
</li>
@endforeach
</ol>
</div>
</section>
Installation
php artisan ui:add blocks/changelog-version-rail
Registry contract
Install confidence
php artisan ui:add blocks/changelog-version-rail
writes only the generated targets below. The CLI validates each file hash before writing and
prompts before replacing local changes unless --force is used.
- Version
- 1.1.2
- License
- open
- Stability
- stable
- Contract
- v1
- Foundation
- ≥ 1.0.0
| Type | Generated target |
|---|---|
| blade | resources/views/blocks/changelog-version-rail.blade.php |
Registry dependencies
Package dependencies
composer: jml/brok:^0.2
npm: alpinejs
Use with AI
A brief for your coding agent: what the block is, the install command, how to render it, its props and the rules. Copy it, or open a prompt about this block in an assistant.
# Brok UI block: Changelog (`changelog-version-rail`)
A changelog/release-notes timeline with a sticky version and date rail beside each release. A release carries one category tag, title, summary and bullet list, or typed sections (Added, Changed, Fixed, …) each with its own list; an opt-in search field filters the changes client-side.
Brok UI is a Laravel Blade registry. `ui:add` copies this block into the app as plain Blade the app owns; it composes installed `<brok:*>` primitives and semantic design tokens.
## Install
```bash
php artisan ui:add blocks/changelog-version-rail
```
## Render it
```blade
<x-blocks.changelog-version-rail />
```
## Props
- `eyebrow` (string, default `Changelog`)
- `heading` (string, default `What\'s new`)
- `lead` (string, default `Follow along as we ship improvements, fixes, and new features every week.`)
- `entries` (mixed|null, default `null`)
- `searchable` (bool, default `false`)
- `search-placeholder` (string, default `Filter by item, prop or keyword…`)
## Use when
- Publishing a release-notes timeline with a sticky version/date rail, where each release carries a category tag and change list.
- Supporting typed sections per release (Added, Changed, Fixed) as an opt-in shape alongside the simpler single-category release.
## Avoid when
- Use changelog-cards for a card-grid changelog or changelog-date-rail for a date-marker timeline with images instead.
## Rules
- Render the installed block with `<x-blocks.changelog-version-rail />` and pass data through its props; edit the copied file only for structural changes.
- Keep the semantic design tokens (`bg-background`, `text-muted-foreground`); never swap in raw colour utilities.
- Keep the `data-slot` attributes and the logical (start/end) spacing so the markup still mirrors under `dir="rtl"`.
## Links
- Docs: https://brokui.dev/blocks/changelog-version-rail
- Registry JSON (files, props, contract): https://brokui.dev/r/open/blocks/changelog-version-rail.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Guidance
Use when
- Publishing a release-notes timeline with a sticky version/date rail, where each release carries a category tag and change list.
- Supporting typed sections per release (Added, Changed, Fixed) as an opt-in shape alongside the simpler single-category release.
Avoid when
- Use changelog-cards for a card-grid changelog or changelog-date-rail for a date-marker timeline with images instead.
Anti-patterns
- Do not replace semantic props with conflicting utility classes.
- Do not remove labels, focus styles, or state attributes.
- Anatomy
Usage
Render the block as a component, passing data where useful:
<x-blocks.changelog-version-rail />
Built from primitives
This block composes installed <brok:*> primitives and semantic design
tokens only — it does not reimplement any primitive. Re-theme it (light, dark, admin, customer) by
editing CSS variables; flip the preview to RTL to confirm it mirrors.
Source
The exact, editable file ui:add writes
into your app. The preview above renders this same source — there are no preview-only blocks.
@props([
'eyebrow' => 'Changelog',
'heading' => 'What\'s new',
'lead' => 'Follow along as we ship improvements, fixes, and new features every week.',
'entries' => null,
// Opt-in client-side filter: a search field above the timeline narrows the
// list to the changes whose text matches. Off by default (static markup).
'searchable' => false,
'searchPlaceholder' => 'Filter by item, prop or keyword…',
])
@php
// Each entry is one release. Two shapes are supported:
//
// 1. A single category per release (the original shape): `category`,
// `variant`, `title`, `body` and a flat `changes` list.
// 2. Typed sections per release (opt-in): `sections` — a list of
// ['title' => 'Added', 'variant' => 'soft-success', 'intro' => null,
// 'changes' => [...]] groups, rendered as badge + list, the shape a real
// Keep-a-Changelog release has. `title`/`body`/`category` become optional.
//
// Pass an empty string for `eyebrow`, `heading` and `lead` to drop the
// section header when the block sits under a page's own title.
//
// Optional keys on either shape: `id` (deep-link anchor for the release,
// defaults to a slug of the version) and `dateLabel` (defaults to `date`).
// A change may be a plain string (translated and escaped) or an Htmlable
// (rendered as-is — for release notes the app already converted from
// trusted Markdown).
$entries ??= [
[
'version' => 'v2.4.0', 'date' => '2026-06-10', 'dateLabel' => 'June 10, 2026',
'category' => 'Added', 'variant' => 'success',
'title' => 'Changelog block family & timeline layout',
'body' => 'Introduced a dedicated changelog section with a sticky version rail on large screens and a fully responsive stacked layout on mobile.',
'changes' => ['New changelog block family with a timeline layout.', 'Left-rail version and date column becomes sticky at large breakpoints.', 'Category tags use semantic badge variants.', 'Full RTL support via logical-property utilities.'],
],
[
'version' => 'v2.3.2', 'date' => '2026-05-28', 'dateLabel' => 'May 28, 2026',
'category' => 'Fixed', 'variant' => 'destructive',
'title' => 'Badge contrast & separator RTL fixes',
'body' => 'Resolved a pair of accessibility regressions: the destructive badge now meets WCAG 2.2 AA contrast, and the separator rendered on the wrong side in RTL documents.',
'changes' => ['Destructive badge foreground adjusted to meet a 4.5:1 contrast ratio.', 'Separator logical-property side restored for RTL layouts.'],
],
[
'version' => 'v2.3.0', 'date' => '2026-05-14', 'dateLabel' => 'May 14, 2026',
'category' => 'Improved', 'variant' => 'secondary',
'title' => 'Badge sizes & dark-mode polish',
'body' => 'Bumped the badge to v1.1.0 with an expanded size scale and unified token coverage. Dark-mode palettes were reviewed and tightened across the library.',
'changes' => ['Badge gains an expanded size scale via the shared styles map.', 'All badge variants use semantic tokens exclusively.', 'Dark-mode muted and accent surfaces re-mapped.'],
],
];
$renderChange = static fn ($change) => $change instanceof \Illuminate\Contracts\Support\Htmlable ? $change : __($change);
@endphp
<section
data-slot="changelog"
@if ($searchable)
x-data="{
query: '',
matches: null,
filter() {
const q = this.query.trim().toLowerCase();
let total = 0;
const releases = Array.from(this.$root.querySelectorAll('[data-slot=changelog-release]'));
releases.forEach((release) => {
const sectioned = release.querySelector('[data-slot=changelog-section]') !== null;
let visible = 0;
release.querySelectorAll('[data-slot=changelog-change]').forEach((change) => {
const haystack = (sectioned ? change : release).textContent.toLowerCase();
const hit = q === '' || haystack.includes(q);
change.hidden = ! hit;
if (hit) visible++;
});
release.querySelectorAll('[data-slot=changelog-section]').forEach((section) => {
const shownInSection = section.querySelectorAll('[data-slot=changelog-change]:not([hidden])').length;
section.hidden = shownInSection === 0;
const count = section.querySelector('[data-slot=changelog-count]');
if (count) count.textContent = shownInSection;
});
release.hidden = visible === 0;
total += visible;
});
// A divider shows only between two visible releases.
const shown = releases.filter((release) => ! release.hidden);
this.$root.querySelectorAll('[data-slot=changelog-divider]').forEach((divider) => {
const before = divider.previousElementSibling;
divider.hidden = ! before || before.hidden || shown.indexOf(before) === shown.length - 1;
});
this.matches = q === '' ? null : total;
},
}"
x-init="$watch('query', () => filter())"
@endif
{{ $attributes->merge(['class' => 'bg-background py-16 text-foreground sm:py-24']) }}
>
<div class="mx-auto max-w-3xl px-4">
@if ($eyebrow || $heading || $lead)
<div class="mb-12 text-center sm:mb-16">
@if ($eyebrow)
<x-ui.badge variant="outline" class="mb-4">{{ __($eyebrow) }}</x-ui.badge>
@endif
@if ($heading)
<h2 class="mt-2 text-3xl font-bold tracking-tight sm:text-4xl">{{ __($heading) }}</h2>
@endif
@if ($lead)
<p class="mt-4 text-base text-muted-foreground sm:text-lg">{{ __($lead) }}</p>
@endif
</div>
@endif
@if ($searchable)
<div data-slot="changelog-search" class="mb-6">
<x-ui.input type="search" autocomplete="off" x-model.debounce.120ms="query" :placeholder="__($searchPlaceholder)" :aria-label="__('Filter changes')" />
<p class="mt-2 min-h-6 text-sm text-muted-foreground" role="status" aria-live="polite" x-text="matches === null ? '' : (matches === 0 ? {{ Js::from(__('No changes match.')) }} : {{ Js::from(__(':count matching')) }}.replace(':count', matches))"></p>
</div>
@endif
<ol class="relative" aria-label="{{ __('Release history') }}">
@foreach ($entries as $index => $entry)
@php
$sections = $entry['sections'] ?? null;
$anchor = $entry['id'] ?? \Illuminate\Support\Str::slug((string) $entry['version']);
@endphp
@if ($index > 0)
<li aria-hidden="true" data-slot="changelog-divider" class="my-10"><x-ui.separator /></li>
@endif
{{-- In sections mode the release is a real heading (h2 → the
sections' h3), carries the anchor, and its rail stays in view
while a long release scrolls; the single-category shape keeps
its version badge. --}}
<li @if ($sections === null) id="{{ $anchor }}" @endif data-slot="changelog-release" class="flex scroll-mt-24 flex-col gap-6 lg:flex-row lg:gap-10">
<div class="flex shrink-0 flex-row flex-wrap items-center gap-4 lg:sticky lg:top-24 lg:w-40 lg:flex-col lg:items-start lg:gap-2 lg:self-start lg:pt-2">
<span aria-hidden="true" class="mt-2 hidden size-2 shrink-0 rounded-full bg-primary ring-4 ring-background lg:block"></span>
@if ($sections !== null)
<h2 id="{{ $anchor }}" class="scroll-mt-24 text-base font-semibold leading-snug"><a href="#{{ $anchor }}" class="hover:underline">{{ $entry['version'] }}</a></h2>
@else
<x-ui.badge as="a" href="#{{ $anchor }}" variant="outline" size="sm" class="shrink-0">{{ $entry['version'] }}</x-ui.badge>
@endif
@if (! empty($entry['date']))
<time datetime="{{ $entry['date'] }}" class="whitespace-nowrap text-sm text-muted-foreground">{{ __($entry['dateLabel'] ?? $entry['date']) }}</time>
@elseif (! empty($entry['dateLabel']))
<span class="min-w-0 text-sm text-muted-foreground">{{ __($entry['dateLabel']) }}</span>
@endif
</div>
<div class="min-w-0 flex-1 space-y-4 border-s-2 border-border ps-6 lg:border-s-0 lg:ps-0">
@if (! empty($entry['category']) || ! empty($entry['title']))
<div class="flex flex-wrap items-center gap-2">
@if (! empty($entry['category']))
<x-ui.badge :variant="$entry['variant'] ?? 'secondary'" size="sm">{{ __($entry['category']) }}</x-ui.badge>
@endif
@if (! empty($entry['title']))
<h3 class="text-base font-semibold leading-snug">{{ __($entry['title']) }}</h3>
@endif
</div>
@endif
@if (! empty($entry['body']))
<p class="text-sm leading-relaxed text-muted-foreground">{{ __($entry['body']) }}</p>
@endif
@if ($sections !== null)
<div class="space-y-8">
@foreach ($sections as $section)
<section data-slot="changelog-section">
<div class="flex items-center gap-2 text-sm">
<h3 class="font-semibold"><x-ui.badge :variant="$section['variant'] ?? 'secondary'" size="sm">{{ __($section['title']) }}</x-ui.badge></h3>
<span data-slot="changelog-count" class="text-muted-foreground">{{ count($section['changes'] ?? []) }}</span>
</div>
@if (! empty($section['intro']))
<div class="prose prose-sm mt-4 max-w-none text-foreground">{{ $renderChange($section['intro']) }}</div>
@endif
@if (! empty($section['changes']))
<ul role="list" class="mt-4 space-y-2">
@foreach ($section['changes'] as $change)
<li data-slot="changelog-change" class="flex items-start gap-2 text-sm text-muted-foreground">
<span aria-hidden="true" class="mt-2 size-2 shrink-0 rounded-full bg-primary"></span>
<span class="min-w-0 [&_a]:text-foreground [&_a]:underline-offset-4 [&_a:hover]:underline [&_code]:rounded-sm [&_code]:bg-muted [&_code]:px-0.5 [&_code]:font-mono [&_code]:text-foreground [&_strong]:font-semibold [&_strong]:text-foreground">{{ $renderChange($change) }}</span>
</li>
@endforeach
</ul>
@endif
</section>
@endforeach
</div>
@elseif (! empty($entry['changes']))
<ul role="list" class="space-y-2">
@foreach ($entry['changes'] as $change)
<li data-slot="changelog-change" class="flex items-start gap-2 text-sm text-muted-foreground">
<span aria-hidden="true" class="mt-2 size-2 shrink-0 rounded-full bg-primary"></span>
<span class="min-w-0">{{ $renderChange($change) }}</span>
</li>
@endforeach
</ul>
@endif
</div>
</li>
@endforeach
</ol>
</div>
</section>
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.1.2 - Status
- Stable
- License
-
open - Deprecation
- Not deprecated
- Contract
-
v1 - Foundation
-
≥ 1.0.0