Text & Numbers

Animated List

A windowed, recede-style list where new entries slide in and older ones fade and shrink as they age.

A list that keeps a fixed number of rows on screen, animates new rows in, and visually demotes the rows above them. It reads like a live activity feed or a notification stack.

Two ideas do the work. Windowing: only the last maxVisible items render, so the list height stays fixed no matter how long the array gets. Receding: each row's opacity and scale are derived from its position — how far down the visible window it is — rather than from a single variant, so the top row is full size and each row below steps down by a fixed amount until it hits a floor.

maxVisible truncates by count, so items scroll off the top and are discarded from the DOM entirely. If you need them back, they are still in your array — nothing is removed. Use the exposed remove or the update:items event to actually delete them.

direction="down" renders newest-first, which is what makes the windowing feel like a feed. direction="up" appends at the bottom instead and reads like a chat log.

Usage

  • AlertCPU usage above 80%
  • PaymentInvoice #1042 paid
  • SignupNew account created
  • DeployProduction build shipped
<template>
  <MAnimatedList
    v-model:items="items"
    :max-visible="4"
    animation="slide"
    direction="down"
    class="w-full max-w-md"
  >
    <template #default="{ item }">
      <div class="flex items-center gap-2 rounded-xl border border-default bg-default px-3 py-2">
        <UBadge
          :label="item.label"
          color="primary"
          variant="subtle"
        />
        <span class="truncate text-sm text-default">{{ item.text }}</span>
      </div>
    </template>
  </MAnimatedList>
</template>

<script setup lang="ts">
const items = ref([
  { id: 1, label: 'Deploy', text: 'Production build shipped' },
  { id: 2, label: 'Signup', text: 'New account created' },
  { id: 3, label: 'Payment', text: 'Invoice #1042 paid' },
  { id: 4, label: 'Alert', text: 'CPU usage above 80%' },
])
</script>

With no slot the component renders a UCard per row. If you set onItemClick, that fallback becomes clickable; otherwise it includes a remove button already wired to remove(row.sourceIndex).

The three exposed methods — add, remove and clear — all emit update:items rather than mutating anything, so v-model:items is the intended wiring and the array stays owned by the parent. In the example the Clear button goes through the exposed clear() rather than assigning items.value = [] directly; both work, and the first keeps the exit animation.

Examples

Live activity feed

  • AlertCPU usage above 80%
  • PaymentInvoice #1042 paid
  • SignupNew account created
  • DeployDeploy finished for web
<template>
  <div class="flex w-full max-w-md flex-col gap-4">
    <MAnimatedList
      ref="list"
      v-model:items="items"
      :max-visible="4"
      animation="slide"
      direction="down"
      class="w-full"
    >
      <template #default="{ item, index, remove }">
        <div class="flex items-center justify-between gap-3 rounded-xl border border-default bg-default px-3 py-2">
          <div class="flex min-w-0 items-center gap-2">
            <UBadge
              :label="item.label"
              color="primary"
              variant="subtle"
            />
            <span class="truncate text-sm text-default">{{ item.text }}</span>
          </div>
          <UButton
            icon="i-lucide-x"
            color="neutral"
            variant="ghost"
            size="xs"
            :aria-label="`Dismiss ${item.text}`"
            @click.stop="remove(index)"
          />
        </div>
      </template>
    </MAnimatedList>

    <div class="flex items-center gap-3">
      <UButton
        label="Add alert"
        icon="i-lucide-bell"
        variant="outline"
        class="justify-center"
        @click="add"
      />
      <UButton
        label="Clear"
        icon="i-lucide-trash-2"
        color="neutral"
        variant="ghost"
        class="justify-center"
        @click="clear"
      />
    </div>
  </div>
</template>

<script setup lang="ts">
interface Alert {
  id: number
  label: string
  text: string
}

let counter = 4

const list = ref<{ clear: () => void } | null>(null)
const items = ref<Alert[]>([
  { id: 1, label: 'Deploy', text: 'Deploy finished for web' },
  { id: 2, label: 'Signup', text: 'New account created' },
  { id: 3, label: 'Payment', text: 'Invoice #1042 paid' },
  { id: 4, label: 'Alert', text: 'CPU usage above 80%' },
])

function add() {
  counter += 1
  items.value = [
    ...items.value,
    { id: counter, label: 'Deploy', text: `Event #${counter} received` },
  ]
}

function clear() {
  list.value?.clear()
}
</script>

Customize

  • Alert
  • Payment
  • Signup
  • Deploy

Customize

AnimationSlide
Max visible4
DirectionDown
Stagger (s)0.05
Recede
Feed
Feed interval (ms)2000
Pause on hover
On Item Click

Props

Prop Type Default Description
itemsAnimatedListItem[][]The full array. Only the last maxVisible entries are rendered. An item is { id?, label?, ...any }, a string, or a number; the index passed to the slot and to remove is the index into this array, not into the visible window.
animation'slide' | 'fade' | 'scale''slide'The entry animation. slide fades in from 0.95 scale, starting 14px above for direction="down" and 14px below for up. fade fades opacity only, scale comes from 0.8.
maxVisiblenumber5How many rows stay in the DOM. Everything older is not rendered at all.
direction'up' | 'down''down'down reverses the window so the newest item is first and recedes downward. up keeps source order and recedes upward.
staggernumber0.05Seconds of delay per position, so rows settle in sequence rather than all at once. Capped at 0.3s total.
recedebooleantrueFade and shrink rows by position — opacity steps down 0.18 per row to a floor of 0.35, scale steps down 0.05 to a floor of 0.85. Set false for a flat list that only animates entries.
feedbooleanfalseReveal items progressively, one per feedInterval ms, instead of showing everything at once. Useful for demos; the count only ever grows to items.length.
feedIntervalnumber2000Milliseconds between feed reveals.
pauseOnHoverbooleantrueSuspend the feed timer while the pointer is over the list, so a row cannot appear under the cursor. Has no effect unless feed is on.
onItemClick(item, index) => void-Callback for the default fallback card. It is a prop, not an event, and it is only consulted when you have not provided a slot.
uiRecord<string, any>{}Merged into the fallback card's ui.body prop. Ignored when a slot is supplied.

Events

Event Payload Description
update:itemsAnimatedListItem[]Emitted by add, remove and clear with the next array. Bind it with v-model:items.

Slots

Slot Props Description
default{ item: AnimatedListItem, index: number, position: number, remove: (index: number) => void }Renders one row. position is the row's slot in the visible window (0 is the newest for direction="down") — use it to mirror the recede effect with your own opacity. remove takes an index and is the same function the exposed remove uses.

UI

This component's theme exposes the slots below. Override any of them per instance with the ui prop, or app-wide under ui.animatedList in app.config.ts.

Prop Type Default Description
uiobject-Per-slot classes merged over the theme defaults.
Slot Props Description
root-flex flex-col gap-2 m-animated-list
row-list-none

Accessibility

The root is a real <ul aria-live="polite"> and each row is a <li>, so the structure is announced correctly. inheritAttrs is disabled and $attrs are re-bound manually, so class, aria-* and listeners land on the <ul> as you'd expect.

The aria-live region is a problem on a fast feed. Any add, remove or recede re-inserts elements inside a polite live region, and screen readers will read the new row out — which is usually what you want, but it means the region stays "noisy" for as long as the list is changing. If your rows are not announcements, set aria-live="off" yourself (or manage focus in a role="log" region) rather than leaving the default on.

The list does not move focus, and it should not: rows are not interactive unless you make them so. If a row contains a button, note that rows are removed from the DOM as they age out of the window — moving focus to a button that is about to be unmounted sends focus back to body.

Reduced motion is fully respected. useReducedMotion() short-circuits every variant: entries appear at their final values, the stagger delay becomes 0, and exits fade with duration: 0. The list still updates, it just doesn't travel.

Performance

  • Rendering is bounded by maxVisible, so the DOM size does not grow with the array. A 10,000-item array costs the same as a 5-item one.
  • Every row sets layout, so Motion measures positions before and after each change. That is a forced reflow per update and is the dominant cost — it is what makes the neighbouring rows slide instead of jump.
  • Stagger is capped at 0.3s (MAX_STAGGER_DELAY), so a large maxVisible cannot stretch a single update into a long, janky sequence.
  • Recede clamps at MIN_RECEDE_OPACITY / MIN_RECEDE_SCALE, so old rows never become invisible or invert — you never lose a row to rounding.
  • AnimatePresence uses mode="popLayout", which removes a leaving row from flow immediately so the remaining rows start moving straight away rather than waiting for the exit to finish.
  • The feed timer is a single setTimeout that reschedules itself, and it is cleared and re-armed by a watcher on feed, feedInterval, items.length and the pause state — so changing any of them mid-flight cannot leave a stale timer running.
Row keys come from item.id when present. Without an id, objects fall back to m-list-${index}, which means inserting at the top makes every row look new to Vue — the whole list re-animates and the recede states are recomputed. Primitives key off their own value instead, so a list of strings is fine as long as they are unique.
direction is 'up' | 'down'. The older 'left'/'right' values some snippets pass are not part of the type and will silently render in the down order, since the check is a strict equality against 'down'.
Copyright © 2026