Animated List
A stacked notification feed that reveals items one at a time with a slide-up, fade, and subtle scale, newest on top, with optional auto-cycling.
Preview
Items slide in one at a time, newest on top, then cycle.
Disable autoplay to render a fixed window of recent items.
<div class="flex flex-col gap-6">
{{-- Auto-cycling notification feed --}}
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-foreground">{{ __('Auto-cycling feed') }}</label>
<div class="mx-auto w-full max-w-sm rounded-xl border border-border bg-muted/40 p-4">
<x-ui.animated-list />
</div>
<p class="text-xs text-muted-foreground">{{ __('Items slide in one at a time, newest on top, then cycle.') }}</p>
</div>
{{-- Static window (no autoplay) --}}
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-foreground">{{ __('Static list (autoplay off)') }}</label>
<div class="mx-auto w-full max-w-sm rounded-xl border border-border bg-muted/40 p-4">
<x-ui.animated-list :autoplay="false" :visible="3" />
</div>
<p class="text-xs text-muted-foreground">{{ __('Disable autoplay to render a fixed window of recent items.') }}</p>
</div>
</div>
Installation
php artisan ui:add animated-list
Note
This component ships an Alpine behavior module at
resources/js/ui/animated-list.js. Import it once from your bundle so it registers on alpine:init:
import './animated-list.js';
Registry contract
php artisan ui:add animated-list
writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.
-
resources/views/components/ui/animated-list.blade.php -
resources/js/ui/animated-list.js
- Registry dependencies
- None — installs on its own.
- Packages
-
composer: jml/brok:^0.2npm: alpinejs
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: Animated List (`animated-list`)
A stacked notification feed that reveals items one at a time with a slide-up, fade, and subtle scale, newest on top, with optional auto-cycling.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add animated-list
```
## Usage
```blade
<div class="flex flex-col gap-6">
{{-- Auto-cycling notification feed --}}
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-foreground">{{ __('Auto-cycling feed') }}</label>
<div class="mx-auto w-full max-w-sm rounded-xl border border-border bg-muted/40 p-4">
<x-ui.animated-list />
</div>
<p class="text-xs text-muted-foreground">{{ __('Items slide in one at a time, newest on top, then cycle.') }}</p>
</div>
{{-- Static window (no autoplay) --}}
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-foreground">{{ __('Static list (autoplay off)') }}</label>
<div class="mx-auto w-full max-w-sm rounded-xl border border-border bg-muted/40 p-4">
<x-ui.animated-list :autoplay="false" :visible="3" />
</div>
<p class="text-xs text-muted-foreground">{{ __('Disable autoplay to render a fixed window of recent items.') }}</p>
</div>
</div>
```
## Props
- `items` (mixed|null, default `null`) — Array of [icon, title, description, time, tone] tuples to cycle through; defaults to a sample notification feed.
- `interval` (int, default `2600`) — Milliseconds between each new row entering the feed.
- `visible` (int, default `5`) — Maximum number of rows kept in the visible window.
- `autoplay` (bool, default `true`) — Whether the feed advances automatically on the interval.
## Use when
- Use to summarize, sequence, or present data so users can scan it quickly.
- Streaming a live feed of short, timestamped events such as notifications, activity, or logs.
- Showing the most recent items with automatic enter motion while older ones shift down and stay visible.
## Avoid when
- Do not add display-only ornament when the user needs actionable structure or exact comparison instead.
- The list must be scanned, sorted, or acted on by the reader; use table or list-panel instead.
## Anti-patterns
- Adding display ornament without informational value
## Rules
- Use the `<brok:animated-list>` tag (or `<x-ui.animated-list>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.
## Links
- Docs: https://brokui.dev/docs/components/animated-list
- Registry JSON (files, props, contract): https://brokui.dev/r/open/animated-list.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| items | mixed | null | null | Array of [icon, title, description, time, tone] tuples to cycle through; defaults to a sample notification feed. |
| interval | int | 2600 | Milliseconds between each new row entering the feed. |
| visible | int | 5 | Maximum number of rows kept in the visible window. |
| autoplay | bool | true | Whether the feed advances automatically on the interval. |
Slots
default—
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Cycles through the source items on an interval, animating each new row in with a slide-up and fade while the rest shift down.
- Pauses on hover and resumes on mouse leave; under prefers-reduced-motion it renders the full window immediately and stops the interval.
- Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
- Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized, seo.
Guidance
Present data for rapid scanning.
Use when
- Use to summarize, sequence, or present data so users can scan it quickly.
- Streaming a live feed of short, timestamped events such as notifications, activity, or logs.
- Showing the most recent items with automatic enter motion while older ones shift down and stay visible.
Avoid when
- Do not add display-only ornament when the user needs actionable structure or exact comparison instead.
- The list must be scanned, sorted, or acted on by the reader; use table or list-panel instead.
Use instead
- Table for exact comparison
- Plain text for a single value
Anti-patterns
- Adding display ornament without informational value
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- managed
- Focus
none
- The root is a role=log region with aria-live=polite and aria-relevant=additions so screen readers announce each new row as it arrives.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add wire:ignore to the component root because its behavior owns rendered DOM.
<div wire:ignore>
<div class="flex flex-col gap-6">
{{-- Auto-cycling notification feed --}}
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-foreground">{{ __('Auto-cycling feed') }}</label>
<div class="mx-auto w-full max-w-sm rounded-xl border border-border bg-muted/40 p-4">
<x-ui.animated-list />
</div>
<p class="text-xs text-muted-foreground">{{ __('Items slide in one at a time, newest on top, then cycle.') }}</p>
</div>
{{-- Static window (no autoplay) --}}
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-foreground">{{ __('Static list (autoplay off)') }}</label>
<div class="mx-auto w-full max-w-sm rounded-xl border border-border bg-muted/40 p-4">
<x-ui.animated-list :autoplay="false" :visible="3" />
</div>
<p class="text-xs text-muted-foreground">{{ __('Disable autoplay to render a fixed window of recent items.') }}</p>
</div>
</div>
</div>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
@props([
'items' => null,
'interval' => 2600,
'visible' => 5,
'autoplay' => true,
])
{{--
Animated List — a stacked notification feed (magicui AnimatedList port).
Renders a maintained window of the most recent N notifications, newest on
top. When autoplay is on, the next item is pushed to the top on an interval;
the new row animates in with a slide-up + fade + subtle scale while the rest
shift down. The feed cycles indefinitely through the source items.
Animation is transform/opacity only (cheap to composite) and is expressed via
a CSS var (`--al-enter`) flipped by Alpine, so no variable is spliced into an
arbitrary Tailwind class. Under prefers-reduced-motion the JS renders the full
window immediately and stops the interval — no motion, no surprise.
A11y: the root is an aria-live region so screen readers announce each new
notification; each row is an <article>. Hovering pauses the cycle. The Alpine
component clears its interval in destroy() so unmounting can't leak a timer.
--}}
@php
// Tone → icon-chip token classes. Fixed strings only (JIT-visible, no raw colour).
$toneClasses = [
'primary' => 'bg-primary/10 text-primary',
'success' => 'bg-success/10 text-success',
'destructive' => 'bg-destructive/10 text-destructive',
'warning' => 'bg-warning/10 text-warning',
'muted' => 'bg-muted text-foreground',
];
// Rich, realistic default feed. Each item: [icon, title, description, time, tone].
$items ??= [
['bell', __('Payment received'), __('Acme Inc. paid invoice #1042 — $4,800.00'), __('just now'), 'success'],
['user-plus', __('New follower'), __('Mara Sundström started following you'), __('1m ago'), 'primary'],
['rocket', __('Server deployed'), __('api-gateway shipped to production in 47s'), __('3m ago'), 'muted'],
['message', __('New message'), __('“Can we move the call to 2pm?” — Devon'), __('6m ago'), 'primary'],
['shield', __('Security alert'), __('New sign-in from Lisbon, Portugal'), __('11m ago'), 'warning'],
['heart', __('Post liked'), __('Your launch announcement reached 1k likes'), __('14m ago'), 'destructive'],
['download', __('Backup complete'), __('Nightly snapshot stored — 2.1 GB'), __('22m ago'), 'success'],
['calendar', __('Event reminder'), __('Quarterly review starts in 30 minutes'), __('30m ago'), 'muted'],
];
// Inline SVG icon set keyed by the item icon name. fill="none"/stroke uses
// currentColor so the chip's text token drives the colour (no raw colour).
$icons = [
'bell' => '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>',
'user-plus' => '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" x2="19" y1="8" y2="14"/><line x1="22" x2="16" y1="11" y2="11"/>',
'rocket' => '<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/>',
'message' => '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
'shield' => '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>',
'heart' => '<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/>',
'download' => '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/>',
'calendar' => '<rect width="18" height="18" x="3" y="4" rx="2"/><line x1="16" x2="16" y1="2" y2="6"/><line x1="8" x2="8" y1="2" y2="6"/><line x1="3" x2="21" y1="10" y2="10"/>',
];
// Resolve each [icon, title, description, time, tone] tuple into a JS-ready
// row: the tone → chip token class and the icon → inline SVG markup are mapped
// here (PHP), so the JS never carries colour strings or icon geometry.
$rows = [];
foreach (array_values($items) as $item) {
[$icon, $title, $description, $time, $tone] = array_pad((array) $item, 5, null);
$rows[] = [
'icon' => $icon,
'iconSvg' => $icons[$icon] ?? $icons['bell'],
'title' => (string) $title,
'description' => (string) $description,
'time' => (string) $time,
'toneClass' => $toneClasses[$tone] ?? $toneClasses['muted'],
];
}
@endphp
<div
x-data="uiAnimatedList({
items: @js($rows),
interval: @js((int) $interval),
visible: @js((int) $visible),
autoplay: @js((bool) $autoplay),
})"
@mouseenter="pause()"
@mouseleave="resume()"
data-slot="animated-list"
role="log"
aria-live="polite"
aria-relevant="additions"
aria-label="{{ __('Notifications') }}"
{{ $attributes->merge(['class' => 'relative flex w-full max-w-md flex-col gap-2']) }}
>
<template x-for="(row, i) in window" :key="row.key">
<article
data-slot="animated-list-item"
:data-key="row.key"
class="flex items-center gap-3 rounded-lg border border-border bg-card p-6 shadow-sm transition-[transform,opacity] duration-300 ease-out motion-reduce:transition-none"
:class="(row.entering && i === 0) ? 'opacity-0 translate-y-3 scale-95' : 'opacity-100 translate-y-0 scale-100'"
>
{{-- Icon chip — tone token drives colour via currentColor. The SVG
geometry is trusted markup generated in PHP, so x-html is safe. --}}
<span
class="flex size-10 shrink-0 items-center justify-center rounded-full"
:class="row.toneClass"
aria-hidden="true"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-5"
x-html="row.iconSvg"
></svg>
</span>
{{-- Title + description. min-w-0 lets the description truncate cleanly. --}}
<div class="min-w-0 flex-1">
<p class="truncate font-medium text-foreground" x-text="row.title"></p>
<p class="truncate text-sm text-muted-foreground" x-text="row.description"></p>
</div>
<time class="ms-auto shrink-0 text-xs text-muted-foreground" x-text="row.time"></time>
</article>
</template>
</div>
/**
* Animated List behaviour — a stacked notification feed that reveals items one
* at a time, newest on top (magicui AnimatedList port).
*
* Maintains a `window` of the most recent N rows. On each interval the next
* source item is pushed to the top with `entering: true`; the Blade binds that
* flag to an opacity/translate/scale start state, then it is cleared on the next
* frame so the CSS transition animates the new row in while the rest shift down.
* Older rows past `visible` are trimmed off the bottom. The cursor wraps so the
* feed cycles indefinitely.
*
* Each row gets a monotonically increasing `key` so Alpine's keyed x-for never
* reuses a DOM node across pushes (which would skip the enter transition).
*
* Reduced motion: if the user prefers reduced motion the full window is rendered
* immediately with no `entering` state and the interval never starts — no motion.
*
* Hovering pauses the cycle (`pause`/`resume`); the interval is always cleared in
* destroy() so unmounting mid-cycle cannot leak a timer.
*
* Self-registers on `alpine:init` so import order does not matter.
*/
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiAnimatedList', (config = {}) => ({
source: config.items ?? [],
interval: config.interval ?? 2600,
visible: Math.max(1, config.visible ?? 5),
autoplay: config.autoplay ?? true,
window: [],
cursor: 0,
seq: 0,
paused: false,
timer: null,
reduced: false,
_ghostTimers: [],
init() {
this.reduced =
window.matchMedia &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!this.source.length) {
return;
}
// Seed the window with the first slice, newest on top. No enter
// animation on the initial paint.
const seed = this.source.slice(0, this.visible);
this.window = seed
.map((row) => this.frame(row, false))
.reverse();
this.cursor = seed.length % this.source.length;
if (this.autoplay && !this.reduced && this.source.length > this.visible) {
this.start();
}
},
// Build a render row from a source item plus an entering flag + unique key.
frame(row, entering) {
return { ...row, entering, key: this.seq++ };
},
start() {
this.stop();
this.timer = window.setInterval(() => this.advance(), this.interval);
},
stop() {
if (this.timer !== null) {
window.clearInterval(this.timer);
this.timer = null;
}
},
/** Live (non-ghost) row elements, top to bottom. */
rowElements() {
return Array.from(
this.$root.querySelectorAll('[data-slot="animated-list-item"]:not([data-ghost])'),
);
},
advance() {
if (this.paused || !this.source.length) {
return;
}
const next = this.source[this.cursor];
this.cursor = (this.cursor + 1) % this.source.length;
// FLIP: remember where every current row sits before the insert shifts them.
const rows = this.rowElements();
const prevTops = new Map(rows.map((el) => [el.dataset.key, el.getBoundingClientRect().top]));
// The row about to fall off the bottom exits as an absolutely-positioned
// ghost, so its removal never snaps the live layout.
if (this.window.length >= this.visible && rows.length) {
this.ghostOut(rows[rows.length - 1]);
}
// Insert at the top in the entering (off-screen) state, trim the tail.
this.window.unshift(this.frame(next, true));
if (this.window.length > this.visible) {
this.window.length = this.visible;
}
this.$nextTick(() => {
// FLIP surviving rows: start them at their old position (no
// transition), force a style flush, then release so the CSS
// transition carries them to their new slot.
for (const el of this.rowElements()) {
const prevTop = prevTops.get(el.dataset.key);
if (prevTop === undefined) {
continue;
}
const dy = prevTop - el.getBoundingClientRect().top;
if (!dy) {
continue;
}
el.style.transition = 'none';
el.style.transform = `translateY(${dy}px)`;
void el.offsetWidth;
el.style.transition = '';
el.style.transform = '';
}
});
// Clear the entering flag next frame so the enter transition plays.
requestAnimationFrame(() => {
if (this.window.length) {
this.window[0].entering = false;
}
});
},
/**
* Clone the outgoing row into an absolutely-positioned ghost and fade it
* down and out. The clone is aria-hidden so the live region never
* announces it, and z-index keeps it under the sliding live rows.
*/
ghostOut(el) {
const rootRect = this.$root.getBoundingClientRect();
const rect = el.getBoundingClientRect();
const ghost = el.cloneNode(true);
// The clone carries the row's Alpine bindings (x-text, :class); it is
// appended outside the x-for scope, so Alpine must not initialise it
// or every binding throws "row is not defined". The painted state
// is already in the markup — the ghost only needs to fade.
ghost.setAttribute('x-ignore', '');
ghost.setAttribute('data-ghost', 'true');
ghost.setAttribute('aria-hidden', 'true');
ghost.style.position = 'absolute';
ghost.style.top = `${rect.top - rootRect.top}px`;
ghost.style.insetInlineStart = '0';
ghost.style.width = '100%';
ghost.style.zIndex = '-1';
this.$root.appendChild(ghost);
void ghost.offsetWidth;
ghost.style.opacity = '0';
ghost.style.transform = 'translateY(0.75rem) scale(0.95)';
this._ghostTimers.push(window.setTimeout(() => ghost.remove(), 350));
},
pause() {
this.paused = true;
},
resume() {
this.paused = false;
},
destroy() {
this.stop();
this._ghostTimers.forEach((id) => window.clearTimeout(id));
this._ghostTimers = [];
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.1.5 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- No date recorded
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0