Skip to content
Brok UI

Loading…

No results

Animated List

Open source

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.

Version
v1.1.5
Stability
stable
License
MIT
Related
List Panel
Check List
Fact List

Preview

Items slide in one at a time, newest on top, then cycle.

Disable autoplay to render a fixed window of recent items.

previews.components.animated-list.default.blade.php 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>

Installation

terminal
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:

resources/js/ui/index.js JS
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.

  • blade resources/views/components/ui/animated-list.blade.php
  • js resources/js/ui/animated-list.js
Registry dependencies
None — installs on its own.
Packages
composer: jml/brok:^0.2
npm: 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.

animated-list.md
# 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

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
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.

animated-list animated-list-item

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

Scannable data display

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
root animated-list-item
Theming hooks
animated-list animated-list-item

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, 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 under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:ignore

Add wire:ignore to the component root because its behavior owns rendered DOM.

livewire-component.blade.php Blade
<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.

resources/views/components/ui/animated-list.blade.php Blade
@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>
resources/js/ui/animated-list.js JS
/**
 * 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